Forráskód Böngészése

Squashed 'theme_m22tc/' content from commit a9e775d

git-subtree-dir: theme_m22tc
git-subtree-split: a9e775de9da400e6eaf0b8b251570ae4211a2d53
odoo 2 hónapja
commit
a679c57b16

+ 30 - 0
.gitignore

@@ -0,0 +1,30 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+*.egg-info/
+dist/
+build/
+
+# Odoo
+*.pyc
+*.pyo
+*.log
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Temporary files
+*.tmp
+*.bak
+*.orig

+ 550 - 0
README.md

@@ -0,0 +1,550 @@
+# Theme M22 Tech Consulting
+
+Tema personalizado para Odoo 18 con estilo **"Futurismo Cálido"** - una combinación de diseño dark mode moderno con acentos vibrantes en naranja y magenta.
+
+## 📋 Información General
+
+| Campo | Valor |
+|-------|-------|
+| **Nombre técnico** | `theme_m22tc` |
+| **Versión** | 1.0.3 |
+| **Categoría** | Theme/Corporate |
+| **Licencia** | LGPL-3 |
+| **Compatibilidad** | Odoo 18.0 |
+| **Dependencias** | `website`, `auth_signup` |
+
+## 🎨 Design System
+
+### Concepto: Split Theme + Glassmorphism
+
+El tema implementa un **"Split Theme"** (Tema Dividido) con **Glassmorphism** aplicado consistentemente:
+
+- **Login/Auth**: 100% Dark + Glassmorphism intenso
+- **Sidebar**: Dark + Glassmorphism sutil
+- **Contenido Principal**: Light + Glassmorphism elegante
+
+### Paleta de Colores
+
+| Variable | Color | Hex | Uso |
+|----------|-------|-----|-----|
+| `o-color-1` | 🟠 Naranja M22 | `#FF7C00` | Acento primario, CTAs, links |
+| `o-color-2` | 🩷 Magenta | `#E0407B` | Acento secundario, gradientes |
+| `o-color-3` | 🌑 Midnight Blue | `#0F111A` | Fondo sidebar (dark mode) |
+| `o-color-4` | ⚪ Soft Gray | `#F8F8F8` | Fondo contenido principal (light mode) |
+| `o-color-5` | ⬜ Blanco | `#FFFFFF` | Textos sobre fondo oscuro, reportes |
+
+### Glassmorphism
+
+Efecto glassmorphism aplicado en diferentes intensidades:
+
+```scss
+// Dark Glassmorphism (Sidebar)
+background: rgba(#0F111A, 0.95);
+backdrop-filter: blur(20px);
+border: 1px solid rgba(255, 255, 255, 0.08);
+
+// Light Glassmorphism (Contenido)
+background: rgba(#FFFFFF, 0.75);
+backdrop-filter: blur(12px);
+border: 1px solid rgba(0, 0, 0, 0.08);
+```
+
+### Tipografía
+
+- **Fuente principal**: Inter (sans-serif)
+- **Fuente alternativa**: Montserrat (sans-serif)
+- **Pesos disponibles**: 300, 400, 500, 600, 700, 800
+
+### Gradientes Principales
+
+```css
+/* Gradiente primario (botones, logos) */
+background: linear-gradient(90deg, #FF6B00, #E1467C);
+
+/* Gradiente para fondos */
+background-image:
+    radial-gradient(ellipse 80% 50% at 10% 10%, rgba(255, 107, 0, 0.15), transparent),
+    radial-gradient(ellipse 80% 50% at 90% 90%, rgba(225, 70, 124, 0.15), transparent);
+```
+
+## 📁 Estructura del Proyecto
+
+```
+theme_m22tc/
+├── __init__.py
+├── __manifest__.py
+├── README.md
+│
+├── data/
+│   ├── generate_primary_template.xml  # Template generator para snippets
+│   ├── ir_asset.xml                   # Registro de assets (SCSS, JS)
+│   └── menu_data.xml                  # Datos de menú
+│
+├── controllers/
+│   ├── __init__.py
+│   └── helpdesk_portal.py            # Extensiones del portal de helpdesk (dashboard, aprobación)
+│
+├── models/
+│   ├── __init__.py
+│   ├── theme_m22tc.py                 # Modelo principal del tema
+│   └── website_menu.py                # Extensiones al menú del website
+│
+├── static/
+│   └── src/
+│       ├── js/
+│       │   ├── m22_sidebar.js         # Widget sidebar interactivo
+│       │   └── m22_bottom_sheet.js    # Widget bottom sheet móvil (iOS style)
+│       │
+│       └── scss/
+│           ├── primary_variables.scss  # Variables de diseño (colores, fuentes)
+│           ├── bootstrap_overridden.scss # Overrides de Bootstrap
+│           └── m22tc_styles.scss       # Estilos personalizados
+│
+└── views/
+    ├── customizations.xml             # Personalizaciones generales (Tailwind, login enforcement)
+    ├── frontend_layout.xml            # Layout para usuarios autenticados + sidebar M22
+    ├── login_custom.xml               # Login, Signup, Reset Password
+    ├── portal_sidebar.xml             # Cleanup de vistas legacy (sin overrides)
+    ├── snippets.xml                   # Snippets personalizados (Bento Grid)
+    ├── website_menu_view.xml          # Vistas de menú
+    ├── helpdesk_dashboard.xml         # Template del dashboard de tickets
+    └── helpdesk_portal_approval.xml    # Template de botones de aprobación/rechazo
+```
+
+## 🛠 Tecnologías Utilizadas
+
+### Backend (Odoo)
+- **QWeb Templates**: Motor de plantillas de Odoo para renderizado HTML
+- **ir.asset**: Sistema de assets de Odoo 18 para registro de SCSS/JS
+- **Template Inheritance**: Herencia de vistas con `inherit_id` y `xpath`
+
+### Frontend
+- **SCSS/Sass**: Preprocesador CSS para estilos modulares
+- **Tailwind CSS** (CDN): Framework utility-first para páginas de autenticación y bottom sheet móvil
+- **Bootstrap 5**: Framework CSS base de Odoo
+- **publicWidget**: Framework JS de Odoo para widgets frontend interactivos
+
+### Fuentes Externas
+- **Google Fonts**: Inter, Montserrat
+
+## 📦 Assets y Bundles
+
+Los assets se registran en `data/ir_asset.xml` usando `theme.ir.asset` (no `ir.asset`) para que solo se apliquen cuando el tema está activo:
+
+| Asset | Bundle | Descripción |
+|-------|--------|-------------|
+| `primary_variables.scss` | `web._assets_primary_variables` | Variables de diseño (colores claros para reportes) |
+| `bootstrap_overridden.scss` | `web._assets_frontend_helpers` | Overrides Bootstrap (colores base claros) |
+| `m22tc_styles.scss` | `web.assets_frontend` | Estilos personalizados (Split Theme + Glassmorphism) |
+| `m22_sidebar.js` | `web.assets_frontend` | Widget del sidebar interactivo |
+| `m22_bottom_sheet.js` | `web.assets_frontend` | Widget del bottom sheet móvil (iOS style) |
+
+### Estilos Generalizados
+
+El tema incluye estilos generalizados para elementos nativos de Odoo:
+
+#### Badges de Bootstrap
+Soporte completo para todos los badges de Bootstrap con buen contraste:
+- `.badge-primary`, `.text-bg-primary` - Naranja M22
+- `.badge-secondary`, `.text-bg-secondary` - Gris
+- `.badge-success`, `.text-bg-success` - Verde
+- `.badge-warning`, `.text-bg-warning` - Amarillo
+- `.badge-danger`, `.text-bg-danger` - Rojo
+- `.badge-info`, `.text-bg-info` - Cyan
+- `.badge-light`, `.text-bg-light`, `.bg-200` - Gris claro con texto oscuro (crítico para helpdesk)
+
+#### Navegación en Sidebar Nativo
+Estilos para elementos de navegación en sidebars nativos de Odoo:
+- `.nav.flex-column .nav-link` - Links de navegación vertical
+- `[role="complementary"] .nav-link` - Sidebars complementarios
+- `.navspy`, `.bs-sidenav` - Navegación scroll-spy
+- `#ticket-nav`, `#ticket-links` - Sidebar específico de helpdesk
+
+#### Botones en Sidebar Nativo
+- `.btn-light` - Texto oscuro visible sobre fondo claro
+- `.btn-primary` - Gradiente M22 con texto blanco
+- `.btn-outline-primary` - Borde naranja, fondo transparente
+
+## 🔐 Sistema de Autenticación Personalizado
+
+El tema incluye páginas de autenticación completamente personalizadas con estilo glassmorphism:
+
+### Templates (`views/login_custom.xml`)
+
+| Template | Hereda de | Descripción |
+|----------|-----------|-------------|
+| `m22_tailwind_config` | `web.layout` | Configuración Tailwind + estilos globales |
+| `m22_login_layout_override` | `website.login_layout` | Layout contenedor |
+| `m22_login_form_override` | `web.login` | Formulario de login |
+| `m22_signup_form_override` | `auth_signup.signup` | Formulario de registro |
+| `m22_reset_password_override` | `auth_signup.reset_password` | Formulario reset password |
+
+### Características
+- ✅ Logo dinámico del sitio web (`request.website.image_url`)
+- ✅ Link de registro condicional (`signup_enabled`)
+- ✅ Link de reset password condicional (`reset_password_enabled`)
+- ✅ Efecto glassmorphism con backdrop blur
+- ✅ Botones con gradiente naranja-magenta
+- ✅ Inputs con estilo dark mode
+
+### URLs
+- `/web/login` - Iniciar sesión
+- `/web/signup` - Crear cuenta
+- `/web/reset_password` - Restablecer contraseña
+
+## 🎯 Sidebar de Navegación
+
+El tema incluye un sidebar personalizado para usuarios autenticados con **inicialización temprana** para prevenir flash visual:
+
+### Características
+- Ancho fijo de 260px en desktop (72px colapsado)
+- Colapsable con persistencia en localStorage
+- **Inicialización temprana**: Script inline en `<head>` previene flash visual al cargar
+- Responsive: drawer en mobile
+- Animaciones suaves con cubic-bezier
+- **Prevención de layout shift**: Iconos fijos, textos con `position: absolute`
+
+### Sistema de Inicialización
+
+El sidebar usa un sistema de inicialización temprana para evitar efectos visuales desagradables:
+
+1. **Script inline en `<head>`** (`views/frontend_layout.xml`):
+   - Lee `localStorage` antes del render
+   - Aplica clase `m22-sidebar-collapsed-init` al `<html>` si está colapsado
+
+2. **CSS de inicialización** (`m22tc_styles.scss`):
+   - `html.m22-sidebar-collapsed-init`: Aplica estado colapsado sin transiciones
+   - Iconos fijos con `position: relative` y `flex-shrink: 0`
+   - Textos ocultos con `position: absolute` y `left: -9999px` (sin layout shift)
+   - Icono de flecha reemplazado via `::after` para evitar rotación visible
+
+3. **JavaScript sincronizado** (`m22_sidebar.js`):
+   - Reconoce estado previo aplicado por CSS
+   - Sincroniza estado interno sin cambios visuales
+   - Habilita transiciones después de inicializar
+
+### Clases CSS Principales
+```css
+.m22-sidebar                    /* Contenedor del sidebar */
+.m22-sidebar-collapsed          /* Estado colapsado */
+.m22-sidebar-mobile             /* Versión mobile */
+.o_has_m22_sidebar              /* Clase en body cuando hay sidebar */
+.o_main_with_sidebar            /* Main content con offset */
+html.m22-sidebar-collapsed-init /* Estado inicial colapsado (sin transiciones) */
+html.m22-sidebar-initialized    /* Sidebar inicializado (transiciones habilitadas) */
+```
+
+## 📱 Navegación Móvil
+
+El tema incluye una **navegación inferior móvil** (bottom navigation) con un **bottom sheet estilo iOS** para mostrar opciones adicionales:
+
+### Características de la Bottom Navigation
+
+- **Barra fija inferior**: Navegación siempre visible en la parte inferior en dispositivos móviles
+- **Items principales**: Muestra los primeros 3 items del menú del sidebar (Inicio, Cotizaciones, Pedidos)
+- **Item "Cuenta"**: Siempre visible, enlace directo a `/my/account`
+- **Botón "Más"**: Siempre al final, abre el bottom sheet con opciones adicionales
+- **Diseño responsive**: Solo visible en pantallas pequeñas (`d-lg-none`)
+
+### Bottom Sheet Estilo iOS
+
+Modal que se desliza desde la parte inferior mostrando opciones adicionales del menú:
+
+#### Características
+
+- ✅ **Animaciones suaves**: Transiciones con cubic-bezier estilo iOS
+- ✅ **Backdrop blur**: Fondo oscuro difuminado (glassmorphism)
+- ✅ **Handle indicator**: Barrita superior que indica que se puede arrastrar
+- ✅ **Drag to dismiss**: Arrastrar hacia abajo desde el handle o header para cerrar
+- ✅ **Múltiples formas de cierre**:
+  - Botón "Cerrar" en la parte inferior
+  - Tap en el backdrop (fondo oscuro)
+  - Tecla Escape
+  - Drag hacia abajo
+- ✅ **Scroll interno**: El contenido es scrolleable si hay muchos items
+- ✅ **Integración con tema**: Estilos glassmorphism consistentes con el diseño
+
+#### Contenido del Bottom Sheet
+
+Muestra los items adicionales del menú (desde el 4to en adelante):
+- Facturas
+- Proyectos
+- Tareas
+- Tickets
+
+#### Implementación Técnica
+
+**Archivos relacionados**:
+- `views/frontend_layout.xml`: Estructura HTML del bottom nav y bottom sheet
+- `static/src/js/m22_bottom_sheet.js`: Lógica JavaScript con widgets de Odoo
+- `static/src/scss/m22tc_styles.scss`: Estilos de integración con el tema
+
+**Widgets JavaScript**:
+- `M22BottomSheet`: Widget principal que gestiona el bottom sheet
+- `M22BottomSheetTrigger`: Widget del botón "Más" que abre el sheet
+
+**Orden de elementos en bottom nav**:
+1. Items principales (primeros 3 del menú)
+2. Cuenta (siempre visible)
+3. Más (siempre al final)
+
+### Clases CSS Principales
+```css
+.m22-bottom-nav              /* Barra de navegación inferior móvil */
+.bottom-nav-item             /* Item individual de la barra */
+#m22_bottom_sheet            /* Contenedor del bottom sheet */
+.m22-bottom-sheet-container  /* Contenedor principal con backdrop */
+.m22-bottom-sheet-backdrop   /* Fondo oscuro difuminado */
+.m22-bottom-sheet-content    /* Contenido del sheet (scrolleable) */
+.m22-sheet-item              /* Item individual dentro del sheet */
+```
+
+## 🔧 Compatibilidad y Ajustes Técnicos
+
+### Filtros del Portal con Tailwind CSS
+
+**Problema**: Los filtros del portal de Odoo (`portal_searchbar`) usan clases de Bootstrap (`.collapse`, `.navbar-collapse`) que dependen del JavaScript de Bootstrap para expandirse/colapsarse. Cuando Tailwind reemplaza Bootstrap, estos elementos permanecen colapsados por defecto.
+
+**Solución**: El tema incluye CSS con alta especificidad en `m22tc_styles.scss` que fuerza la visualización de los filtros en desktop:
+
+```scss
+.o_portal_wrap nav.o_portal_navbar {
+    @media (min-width: 992px) {
+        .collapse, .navbar-collapse, #o_portal_navbar_content {
+            display: flex !important;
+            visibility: visible !important;
+            height: auto !important;
+        }
+    }
+}
+```
+
+**Resultado**: Los controles "Ordenar por", "Filtrar por", "Agrupar por" y la búsqueda son siempre visibles en pantallas de escritorio (≥992px), manteniendo la funcionalidad completa del portal de Odoo.
+
+### Arquitectura No-Invasiva
+
+El tema sigue una **filosofía de no-interferencia** con los templates nativos de Odoo:
+
+- ✅ **NO reemplaza** el contenedor principal del portal
+- ✅ **NO modifica** la estructura de vistas de Odoo
+- ✅ **Inyecta** la sidebar M22 como componente adicional
+- ✅ **Ajusta** el layout exclusivamente con CSS
+- ✅ **Preserva** toda la funcionalidad nativa (filtros, paginación, breadcrumbs)
+
+El archivo `portal_sidebar.xml` solo contiene una función de cleanup de vistas legacy. Todos los ajustes visuales se manejan en `m22tc_styles.scss`.
+
+## 🧩 Snippets Disponibles
+
+### Bento Grid (`s_m22_bento_grid`)
+Grid asimétrico estilo "Bento Box" para mostrar servicios/features:
+- Layout responsive con CSS Grid
+- Tarjetas con efecto hover
+- Iconos con gradiente
+- Compatible con el editor de Odoo
+
+## ⚙️ Configuración del Website Editor
+
+El tema configura automáticamente:
+- **Botones**: Border radius 0.5rem, efecto ripple
+- **Header**: Template "boxed"
+- **Footer**: Template "centered"
+
+## 🚀 Instalación y Actualización
+
+### Instalar el tema
+```bash
+# Desde el directorio workspace/
+source ../venv/bin/activate
+python src/odoo/odoo-bin -c ../odoo.conf -d m22_techconsulting_dev -i theme_m22tc --stop-after-init
+```
+
+### Actualizar el tema
+```bash
+# Desde el directorio workspace/
+source ../venv/bin/activate
+python src/odoo/odoo-bin -c ../odoo.conf -d m22_techconsulting_dev -u theme_m22tc --stop-after-init
+./odoo_dev.sh restart
+```
+
+### Reiniciar Odoo
+```bash
+./odoo_dev.sh restart
+```
+
+## 🔧 Desarrollo
+
+### Modificar estilos
+1. Editar archivos en `static/src/scss/`
+2. Actualizar el tema: `python src/odoo/odoo-bin -c ../odoo.conf -u theme_m22tc --stop-after-init`
+3. Reiniciar: `./odoo_dev.sh restart`
+
+### Modificar templates
+1. Editar archivos en `views/`
+2. Actualizar el tema
+3. Limpiar caché si es necesario:
+```python
+# En Odoo shell
+env.registry.clear_cache()
+env['ir.qweb'].clear_caches()
+```
+
+### Agregar nuevos assets
+1. Crear archivo en `static/src/scss/` o `static/src/js/`
+2. Registrar en `data/ir_asset.xml`
+3. Actualizar el tema
+
+## 📝 Notas Importantes
+
+1. **Tailwind CSS**: La plataforma usa Tailwind CSS para reemplazar Bootstrap en toda la aplicación. El tema se carga vía CDN en `customizations.xml` y es compatible con los componentes nativos de Odoo mediante CSS con alta especificidad que asegura la funcionalidad de elementos que originalmente dependían de Bootstrap (como filtros colapsables).
+
+2. **Assets con `theme.ir.asset`**: Todos los assets usan `model="theme.ir.asset"` en lugar de `ir.asset` para que solo se apliquen cuando el tema está activo. Odoo copia estos assets a `ir.asset` con `website_id` cuando se activa el tema.
+
+3. **Colores base claros**: Las variables `$body-bg` y `$body-color` en `bootstrap_overridden.scss` y `primary_variables.scss` usan valores claros (blanco/gris oscuro) para compatibilidad con reportes HTML (facturas, órdenes) que requieren fondo blanco.
+
+4. **Especificidad CSS**: Los estilos de inicialización usan `#wrapwrap.o_has_m22_sidebar` para igualar la especificidad de los estilos normales y evitar conflictos.
+
+5. **Prioridades de templates**: Los overrides usan `priority="100"` o superior para asegurar que se apliquen después de otros módulos.
+
+6. **Variables condicionales**: Las páginas de auth respetan las configuraciones de Odoo:
+   - `signup_enabled`: Habilitado en Ajustes > Usuarios > Acceso de cliente
+   - `reset_password_enabled`: Habilitado en Ajustes > Usuarios > Restablecer contraseña
+
+7. **Logo del sitio**: El logo se obtiene de la configuración del Website (`request.website.image_url`), no de la compañía.
+
+8. **Prevención de layout shift**: Los textos del sidebar usan `position: absolute` y `left: -9999px` para sacarlos del flujo sin causar movimiento de iconos durante la carga.
+
+9. **Bottom Sheet móvil**: El bottom sheet usa Tailwind CSS vía CDN y está integrado con el sistema de estilos del tema. El botón "Más" siempre aparece al final de la navegación inferior, después del item "Cuenta".
+
+10. **Navegación móvil**: La bottom navigation solo muestra los primeros 3 items principales del menú más "Cuenta" y "Más". Los items adicionales se muestran en el bottom sheet al tocar "Más".
+
+11. **Compatibilidad Tailwind + Bootstrap**: El tema está diseñado para funcionar en plataformas donde Tailwind reemplaza Bootstrap. Los filtros del portal de Odoo (que originalmente usan clases `.collapse` de Bootstrap) se fuerzan a mostrarse en desktop mediante CSS con alta especificidad en `m22tc_styles.scss`. Esto asegura que los controles "Ordenar por", "Filtrar por", "Agrupar por" y la búsqueda siempre sean visibles en pantallas ≥992px.
+
+12. **Arquitectura no-invasiva**: El tema NO reemplaza templates del portal de Odoo. La sidebar M22 es un componente adicional que se inyecta vía `frontend_layout.xml`. Los ajustes de layout se realizan exclusivamente con CSS, preservando toda la funcionalidad nativa de Odoo (filtros, breadcrumbs, paginación, etc.).
+
+## 🎫 Funcionalidades de Helpdesk
+
+El tema incluye extensiones personalizadas para el módulo de Helpdesk que mejoran la experiencia del cliente en el portal:
+
+### Dashboard de Tickets (`/my/tickets-dashboard`)
+
+Dashboard personalizado que muestra información clave para el cliente:
+
+#### Métricas Principales
+
+1. **Tiempo Usado/Disponible**
+   - Calcula horas usadas vs horas disponibles del partner
+   - Basado en líneas de pedido prepagadas (`sale.order.line`)
+   - Compatible con `sale_timesheet` si está instalado
+   - Muestra porcentaje de uso
+
+2. **Resumen de Tickets**
+   - Total de tickets abiertos
+   - Total de tickets cerrados
+   - Tickets por prioridad (Urgente, Alta, Media, Baja)
+   - Tickets por etapa
+
+3. **Cumplimiento SLA**
+   - Porcentaje de tickets cumpliendo SLA
+   - Tickets en riesgo (próximos a vencer)
+   - Tickets con SLA fallido
+
+4. **Tickets Esperando Respuesta**
+   - Tickets que requieren acción del cliente
+   - Lógica de cálculo:
+     - **Prioridad 1**: Tickets en etapas excluidas de SLA (`exclude_stage_ids`)
+     - **Prioridad 2**: Tickets donde el último mensaje es del equipo de helpdesk
+   - Solo incluye tickets asignados (excluye tickets nuevos sin asignar)
+   - Ordenados por tiempo de espera (más antiguos primero)
+
+5. **Tickets Recientes**
+   - Últimos 10 tickets creados
+   - Enlace directo a cada ticket
+
+#### Diseño Visual
+
+- Cards con efecto glassmorphism consistente con el tema
+- Barras de progreso para métricas porcentuales
+- Badges de colores para estados y prioridades
+- Diseño responsive y mobile-friendly
+- Integración con la paleta de colores del tema
+
+### Lista de Tickets (`/my/tickets`)
+
+#### Agrupamiento por Defecto
+
+- Por defecto, los tickets se agrupan por **Etapa** (`stage_id`)
+- El usuario puede cambiar el agrupamiento usando los controles nativos de Odoo
+- No interfiere con la funcionalidad nativa del portal
+
+### Aprobación/Rechazo de Tickets
+
+Sistema de aprobación para tickets en etapas de espera del cliente:
+
+#### Funcionalidad
+
+- **Botones de Aprobación**: Aparecen en tickets que están en etapas excluidas de SLA
+- **Aprobar Solución**: Mueve el ticket a la siguiente etapa (mayor secuencia)
+- **Rechazar / Necesito más ayuda**: Mueve el ticket a la etapa anterior no-excluida (menor secuencia)
+- **Mensajes automáticos**: Cada acción publica un mensaje en el chatter del ticket
+
+#### Lógica de Etapas Excluidas
+
+- Las etapas excluidas se definen en los SLAs del equipo (`helpdesk.sla.exclude_stage_ids`)
+- Cuando un ticket está en una etapa excluida, el SLA se pausa
+- El cliente puede aprobar o rechazar desde el portal
+- Al aprobar, el ticket avanza y el SLA se reanuda
+
+#### Template
+
+- Banner de aprobación visible en la página de seguimiento del ticket (`/my/ticket/<id>`)
+- Estilos glassmorphism consistentes con el tema
+- Botones con gradientes y efectos hover
+- Alertas de confirmación después de cada acción
+
+### Mejoras Visuales en Portal
+
+#### Buscador y Dropdowns
+
+- Fondo claro con texto oscuro (consistente con glassmorphism)
+- Acentos naranja en elementos seleccionados y focus
+- Posicionamiento corregido de elementos
+- Compatible con el diseño "Futurismo Cálido"
+
+#### Estilos Específicos
+
+- `.o_portal_navbar`: Buscador y controles de filtrado
+- `.dropdown-menu`: Menús desplegables con estilos personalizados
+- `.form-control`: Inputs con estilo glassmorphism
+
+### Implementación Técnica
+
+**Archivos relacionados**:
+- `controllers/helpdesk_portal.py`: Lógica del dashboard y aprobación
+- `views/helpdesk_dashboard.xml`: Template del dashboard
+- `views/helpdesk_portal_approval.xml`: Template de botones de aprobación
+- `static/src/scss/m22tc_styles.scss`: Estilos del dashboard y aprobación
+
+**Dependencias**:
+- `helpdesk`: Módulo base de helpdesk de Odoo Enterprise
+- `helpdesk_extras`: Módulo custom con funcionalidades extendidas (colaboradores, SLAs)
+
+**Rutas**:
+- `/my/tickets-dashboard`: Dashboard personalizado
+- `/my/tickets`: Lista de tickets (nativa, con agrupamiento por defecto)
+- `/my/ticket/<id>/<access_token>`: Página de seguimiento (con banner de aprobación)
+- `/my/ticket/approve/<id>/<access_token>`: Endpoint para aprobar ticket
+- `/my/ticket/reject/<id>/<access_token>`: Endpoint para rechazar ticket
+
+**Notas importantes**:
+- El dashboard muestra métricas agregadas de todos los equipos donde el usuario es colaborador
+- Las reglas de seguridad (`ir.rule`) de `helpdesk_extras` filtran automáticamente los tickets según el rol del usuario en cada equipo
+- Los tickets en etapa "Nuevo" sin asignar NO aparecen en "Esperando Respuesta" (están esperando respuesta del equipo, no del cliente)
+
+## 📄 Licencia
+
+LGPL-3 - Ver archivo LICENSE para más detalles.
+
+---
+
+**Desarrollado por**: M22 Tech Consulting  
+**Versión de Odoo**: 18.0

+ 3 - 0
__init__.py

@@ -0,0 +1,3 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import models
+from . import controllers

+ 44 - 0
__manifest__.py

@@ -0,0 +1,44 @@
+{
+    "name": "M22 Tech Theme",
+    "description": """
+        Theme for M22 Tech Consulting with Futurismo Cálido style.
+        
+        Features:
+        - Split Theme: Dark sidebar + Light content with glassmorphism
+        - Tailwind CSS compatible (preserves portal filters functionality)
+        - Custom authentication pages (login, signup, reset password)
+        - Responsive mobile navigation with iOS-style bottom sheet
+        - Non-invasive architecture (doesn't replace Odoo templates)
+    """,
+    "category": "Theme/Corporate",
+    "summary": "Tech, Consulting, Dark Mode, Glassmorphism, Tailwind Compatible",
+    "sequence": 120,
+    "version": "1.0.3",
+    "depends": ["website", "auth_signup", "helpdesk", "helpdesk_extras"],
+    "data": [
+        "data/generate_primary_template.xml",
+        "data/ir_asset.xml",
+        "data/menu_data.xml",
+        "views/website_menu_view.xml",
+        "views/snippets.xml",
+        "views/customizations.xml",
+        "views/frontend_layout.xml",
+        "views/portal_sidebar.xml",
+        "views/login_custom.xml",
+        "views/helpdesk_dashboard.xml",
+        "views/helpdesk_portal_approval.xml",
+    ],
+    "images": [
+        # Placeholder for screenshot
+        # 'static/description/screenshot.jpg',
+    ],
+    "configurator_snippets": {
+        "homepage": [
+            "s_banner",
+            "s_m22_bento_grid",
+            "s_text_image",
+            "s_call_to_action",
+        ],
+    },
+    "license": "LGPL-3",
+}

+ 4 - 0
controllers/__init__.py

@@ -0,0 +1,4 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import main
+from . import helpdesk_portal
+

+ 499 - 0
controllers/helpdesk_portal.py

@@ -0,0 +1,499 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import http, fields, _
+from odoo.http import request
+from odoo.exceptions import AccessError, MissingError, UserError
+from odoo.osv import expression
+
+# Try to import from helpdesk_extras first, fallback to helpdesk base
+try:
+    from odoo.addons.helpdesk_extras.controllers.helpdesk_portal import (
+        CustomerPortal as HelpdeskCustomerPortal,
+    )
+except ImportError:
+    from odoo.addons.helpdesk.controllers.portal import (
+        CustomerPortal as HelpdeskCustomerPortal,
+    )
+
+
+class CustomerPortal(HelpdeskCustomerPortal):
+    """
+    Extend helpdesk portal controller to:
+    - Add dashboard route at /my/tickets-dashboard
+    - Set default grouping by stage for list view
+    - Calculate dashboard metrics
+    """
+
+    def _prepare_tickets_dashboard_values(self):
+        """
+        Calculate dashboard metrics for tickets.
+        Returns dict with all metrics needed for the dashboard.
+        """
+        partner = request.env.user.partner_id.commercial_partner_id
+        HelpdeskTicket = request.env['helpdesk.ticket']
+        
+        # Base domain for partner's tickets
+        base_domain = self._prepare_helpdesk_tickets_domain()
+        
+        # Get all tickets for the partner
+        all_tickets = HelpdeskTicket.search(base_domain)
+        
+        # 1. TIME STATS (Tiempo usado/disponible)
+        time_stats = self._calculate_time_stats(partner)
+        
+        # 2. TICKET SUMMARY (Resumen de tickets)
+        ticket_summary = self._calculate_ticket_summary(all_tickets)
+        
+        # 3. SLA COMPLIANCE (Cumplimiento SLA)
+        sla_compliance = self._calculate_sla_compliance(all_tickets)
+        
+        # 4. WAITING RESPONSE (Tickets esperando respuesta)
+        waiting_response = self._get_waiting_response_tickets(all_tickets, partner)
+        
+        # 5. RECENT TICKETS (Tickets recientes - últimos 10)
+        recent_tickets = all_tickets.sorted('create_date', reverse=True)[:10]
+        
+        return {
+            'time_stats': time_stats,
+            'ticket_summary': ticket_summary,
+            'sla_compliance': sla_compliance,
+            'waiting_response': waiting_response,
+            'recent_tickets': recent_tickets,
+        }
+
+    def _calculate_time_stats(self, partner):
+        """
+        Calculate time used and available for the partner.
+        Reuses the logic from helpdesk_extras controller to avoid code duplication.
+        Calls the same method that the widget uses, ensuring identical calculation.
+        """
+        import logging
+        _logger = logging.getLogger(__name__)
+        
+        try:
+            # Try to use helpdesk_extras controller if available
+            if 'helpdesk_extras' in request.env.registry._init_modules:
+                try:
+                    from odoo.addons.helpdesk_extras.controllers.website_helpdesk_hours import (
+                        WebsiteHelpdeskHours,
+                    )
+                    # Create instance and call the method directly
+                    # The method uses request.env which is available in the current context
+                    hours_controller = WebsiteHelpdeskHours()
+                    
+                    # IMPORTANT: Call the method directly - it's a regular Python method
+                    # The @http.route decorator doesn't prevent direct method calls
+                    # This ensures we use the exact same logic as the widget
+                    hours_data = hours_controller.get_available_hours()
+                    
+                    # Log the raw data received for debugging
+                    _logger.info(f"[DASHBOARD] Raw hours data from controller: {hours_data}")
+                    _logger.info(f"[DASHBOARD] User: {request.env.user.name}, Portal: {request.env.user._is_portal()}")
+                    _logger.info(f"[DASHBOARD] Partner: {partner.name}, ID: {partner.id}")
+                    
+                    # Check if there was an error
+                    if hours_data.get('error'):
+                        _logger.warning(f"[DASHBOARD] Error in hours data: {hours_data.get('error')}")
+                        # If error, return defaults
+                        return {
+                            'used': 0.0,
+                            'available': 0.0,
+                            'prepaid_hours': 0.0,
+                            'credit_hours': 0.0,
+                            'credit_available': 0.0,
+                            'highest_price': 0.0,
+                            'percentage': 0.0,
+                        }
+                    
+                    # Map the response to match dashboard format
+                    # Ensure all values are floats to avoid type issues
+                    total_available = float(hours_data.get('total_available', 0.0) or 0.0)
+                    hours_used = float(hours_data.get('hours_used', 0.0) or 0.0)
+                    prepaid_hours = float(hours_data.get('prepaid_hours', 0.0) or 0.0)
+                    credit_hours = float(hours_data.get('credit_hours', 0.0) or 0.0)
+                    credit_available = float(hours_data.get('credit_available', 0.0) or 0.0)
+                    highest_price = float(hours_data.get('highest_price', 0.0) or 0.0)
+                    
+                    # Calculate percentage (used / (used + available)) - same as widget
+                    total_with_used = total_available + hours_used
+                    percentage = (hours_used / total_with_used * 100) if total_with_used > 0 else 0.0
+                    
+                    result = {
+                        'used': round(hours_used, 2),
+                        'available': round(total_available, 2),
+                        'prepaid_hours': round(prepaid_hours, 2),
+                        'credit_hours': round(credit_hours, 2),
+                        'credit_available': round(credit_available, 2),
+                        'highest_price': round(highest_price, 2),
+                        'percentage': round(percentage, 1),
+                    }
+                    
+                    _logger.info(f"[DASHBOARD] Final time stats result: {result}")
+                    return result
+                except ImportError as e:
+                    _logger.warning(f"[DASHBOARD] helpdesk_extras controller not available: {str(e)}")
+                except Exception as e:
+                    _logger.error(f"[DASHBOARD] Error calling helpdesk_extras controller: {str(e)}", exc_info=True)
+        except Exception as e:
+            _logger.error(f"[DASHBOARD] Error calculating time stats: {str(e)}", exc_info=True)
+        
+        # Fallback: return defaults if helpdesk_extras not available
+        _logger.warning("[DASHBOARD] Returning default time stats (helpdesk_extras not available or error occurred)")
+        return {
+            'used': 0.0,
+            'available': 0.0,
+            'prepaid_hours': 0.0,
+            'credit_hours': 0.0,
+            'credit_available': 0.0,
+            'highest_price': 0.0,
+            'percentage': 0.0,
+        }
+
+    def _calculate_ticket_summary(self, tickets):
+        """
+        Calculate ticket summary: total, open, closed, by stage, by priority.
+        Returns by_stage as list of tuples for QWeb template compatibility.
+        """
+        open_tickets = tickets.filtered(lambda t: not t.stage_id.fold)
+        closed_tickets = tickets.filtered(lambda t: t.stage_id.fold)
+        
+        # By stage - convert to list of tuples for QWeb
+        by_stage_dict = {}
+        for ticket in open_tickets:
+            stage_name = ticket.stage_id.name or 'Sin etapa'
+            by_stage_dict[stage_name] = by_stage_dict.get(stage_name, 0) + 1
+        
+        # Convert to list of tuples for QWeb iteration
+        by_stage_list = [(name, count) for name, count in by_stage_dict.items()]
+        
+        # By priority
+        priority_labels = {
+            '0': 'Baja',
+            '1': 'Media',
+            '2': 'Alta',
+            '3': 'Urgente',
+        }
+        by_priority = {}
+        for ticket in open_tickets:
+            priority_label = priority_labels.get(ticket.priority, 'Sin prioridad')
+            by_priority[priority_label] = by_priority.get(priority_label, 0) + 1
+        
+        return {
+            'total': len(tickets),
+            'open': len(open_tickets),
+            'closed': len(closed_tickets),
+            'by_stage': by_stage_list,  # List of tuples for QWeb
+            'by_priority': by_priority,
+        }
+
+    def _calculate_sla_compliance(self, tickets):
+        """
+        Calculate SLA compliance percentage.
+        """
+        tickets_with_sla = tickets.filtered(lambda t: t.sla_ids)
+        
+        if not tickets_with_sla:
+            return {
+                'total_with_sla': 0,
+                'success': 0,
+                'failed': 0,
+                'percentage': 0.0,
+                'is_good': False,  # For icon color
+            }
+        
+        success_count = len(tickets_with_sla.filtered(lambda t: t.sla_reached and not t.sla_reached_late))
+        failed_count = len(tickets_with_sla.filtered(lambda t: t.sla_reached_late))
+        
+        total = len(tickets_with_sla)
+        percentage = (success_count / total * 100) if total > 0 else 0.0
+        
+        return {
+            'total_with_sla': total,
+            'success': success_count,
+            'failed': failed_count,
+            'percentage': round(percentage, 1),
+            'is_good': percentage >= 80,  # For icon color (green if >= 80%)
+        }
+
+    def _get_waiting_response_tickets(self, tickets, partner):
+        """
+        Get tickets waiting for CUSTOMER response (not team response).
+        
+        CORRECTED LOGIC:
+        
+        PRIMARY INDICATOR: Tickets in excluded stages
+        - Any ticket in an SLA excluded stage = explicitly waiting for customer
+        - Examples: "Esperando Información", "En Revisión", etc.
+        - This is the most reliable indicator
+        
+        SECONDARY INDICATOR: Last message is from helpdesk team
+        - If last visible message is from team (not customer), team is waiting
+        - This covers cases where team asked something but ticket is still in active stage
+        - We check the LAST message, not oldest_unanswered_customer_message_date
+          (that field indicates customer waiting for team, which is the opposite)
+        
+        IMPORTANT: 
+        - oldest_unanswered_customer_message_date = customer sent message, team hasn't responded
+          → This means "waiting for TEAM response", NOT customer response
+        - We need the opposite: last message from TEAM = "waiting for CUSTOMER response"
+        """
+        open_tickets = tickets.filtered(lambda t: not t.stage_id.fold)
+        
+        if not open_tickets:
+            return {
+                'count': 0,
+                'tickets': [],
+                'by_stage': {},
+                'by_reason': {
+                    'excluded_stage': 0,
+                    'last_message_from_team': 0,
+                },
+            }
+        
+        waiting_tickets = []
+        waiting_by_stage = {}
+        waiting_by_reason = {
+            'excluded_stage': 0,
+            'last_message_from_team': 0,
+        }
+        
+        # Get all messages for open tickets in one efficient query
+        # Exclude system messages (author_id = False or OdooBot)
+        comment_subtype = request.env.ref('mail.mt_comment')
+        odoobot = request.env.ref('base.partner_root', raise_if_not_found=False)
+        odoobot_id = odoobot.id if odoobot else False
+        
+        all_messages = request.env['mail.message'].search([
+            ('model', '=', 'helpdesk.ticket'),
+            ('res_id', 'in', open_tickets.ids),
+            ('subtype_id', '=', comment_subtype.id),
+            ('message_type', 'in', ['email', 'comment']),
+            ('author_id', '!=', False),  # Exclude messages without author
+        ], order='res_id, date desc')
+        
+        # Filter out OdooBot messages if exists
+        if odoobot_id:
+            all_messages = all_messages.filtered(lambda m: m.author_id.id != odoobot_id)
+        
+        # Group messages by ticket and get last message for each
+        last_message_map = {}
+        current_ticket_id = None
+        for msg in all_messages:
+            if msg.res_id != current_ticket_id:
+                # First message for this ticket (already sorted desc)
+                last_message_map[msg.res_id] = msg
+                current_ticket_id = msg.res_id
+        
+        for ticket in open_tickets:
+            # PRIMARY: Check if in SLA excluded stage (waiting stage)
+            # This is explicit and most reliable - if in excluded stage, definitely waiting
+            excluded_stages = ticket.sla_ids.mapped('exclude_stage_ids')
+            is_in_waiting_stage = ticket.stage_id in excluded_stages
+            
+            if is_in_waiting_stage:
+                # Ticket is in excluded stage = explicitly waiting for customer
+                # BUT: Only if ticket has been assigned (team has interacted)
+                # This prevents new unassigned tickets from appearing
+                if ticket.assign_date or ticket.user_id:
+                    waiting_tickets.append(ticket)
+                    waiting_by_reason['excluded_stage'] += 1
+                    
+                    stage_name = ticket.stage_id.name
+                    if stage_name not in waiting_by_stage:
+                        waiting_by_stage[stage_name] = []
+                    waiting_by_stage[stage_name].append(ticket)
+                continue
+            
+            # SECONDARY: Check if last message is from helpdesk team
+            # Only if ticket is NOT in excluded stage
+            last_msg = last_message_map.get(ticket.id)
+            
+            if last_msg:
+                # Check if last message is from helpdesk team (internal user)
+                is_helpdesk_msg = False
+                if last_msg.author_id:
+                    # Check if author has internal users (not share/portal)
+                    author_users = last_msg.author_id.user_ids
+                    if author_users:
+                        # Message is from helpdesk if any user is internal (not share/portal)
+                        is_helpdesk_msg = any(not user.share for user in author_users)
+                    else:
+                        # No users associated with author = likely external/customer
+                        is_helpdesk_msg = False
+                
+                if is_helpdesk_msg:
+                    # Last message is from team → waiting for customer response
+                    # BUT: Only if ticket has been assigned (team has started working)
+                    # This excludes brand new tickets that are waiting for team, not customer
+                    # Also exclude if ticket was just created (less than 1 hour ago) and not assigned
+                    from datetime import datetime, timedelta
+                    one_hour_ago = datetime.now() - timedelta(hours=1)
+                    
+                    # Only include if:
+                    # 1. Ticket has been assigned (team started working), OR
+                    # 2. Ticket is older than 1 hour (not brand new)
+                    if ticket.assign_date or ticket.user_id or ticket.create_date < one_hour_ago:
+                        waiting_tickets.append(ticket)
+                        waiting_by_reason['last_message_from_team'] += 1
+                    continue
+            
+            # If no messages at all, it's a new ticket
+            # New tickets are NOT waiting for customer response (they're waiting for team)
+            # So we don't include them
+        
+        # Sort by waiting time (oldest first)
+        waiting_tickets_sorted = sorted(
+            waiting_tickets,
+            key=lambda t: (
+                t.date_last_stage_update if t.stage_id in t.sla_ids.mapped('exclude_stage_ids')
+                else last_message_map.get(t.id, request.env['mail.message']).date if last_message_map.get(t.id)
+                else t.date_last_stage_update
+                or t.create_date
+            )
+        )
+        
+        return {
+            'count': len(waiting_tickets),
+            'tickets': waiting_tickets_sorted[:10],
+            'by_stage': {name: len(tickets) for name, tickets in waiting_by_stage.items()},
+            'by_reason': waiting_by_reason,
+        }
+
+    @http.route(['/my/tickets-dashboard'], type='http', auth="user", website=True)
+    def my_helpdesk_tickets_dashboard(self, **kw):
+        """
+        Dashboard view for tickets at /my/tickets-dashboard.
+        Shows metrics and summary instead of full list.
+        """
+        values = self._prepare_portal_layout_values()
+        dashboard_data = self._prepare_tickets_dashboard_values()
+        
+        values.update({
+            'page_name': 'ticket',
+            'default_url': '/my/tickets-dashboard',
+            'dashboard_data': dashboard_data,
+        })
+        
+        return request.render("theme_m22tc.portal_helpdesk_ticket_dashboard", values)
+
+    @http.route(['/my/tickets', '/my/tickets/page/<int:page>'], type='http', auth="user", website=True)
+    def my_helpdesk_tickets(self, page=1, date_begin=None, date_end=None, sortby=None, filterby='all', search=None, groupby=None, search_in='name', **kw):
+        """
+        Override native /my/tickets route to set default groupby='stage_id' when not specified.
+        This preserves native behavior while adding default grouping.
+        """
+        # Set default groupby to 'stage_id' only if not provided (None)
+        if groupby is None:
+            groupby = 'stage_id'
+        
+        # Call parent method with the updated groupby
+        return super().my_helpdesk_tickets(
+            page=page,
+            date_begin=date_begin,
+            date_end=date_end,
+            sortby=sortby,
+            filterby=filterby,
+            search=search,
+            groupby=groupby,
+            search_in=search_in,
+            **kw
+        )
+
+    @http.route([
+        '/my/ticket/approve/<int:ticket_id>',
+        '/my/ticket/approve/<int:ticket_id>/<access_token>',
+    ], type='http', auth="public", website=True)
+    def ticket_approve(self, ticket_id=None, access_token=None, **kw):
+        """
+        Approve ticket when it's in an excluded stage (waiting for approval).
+        Moves ticket to next stage (typically Resolved/Closed).
+        """
+        try:
+            ticket_sudo = self._document_check_access('helpdesk.ticket', ticket_id, access_token)
+        except (AccessError, MissingError):
+            return request.redirect('/my')
+
+        # Check if ticket is in an excluded stage (waiting for customer)
+        excluded_stages = ticket_sudo.sla_ids.mapped('exclude_stage_ids')
+        if ticket_sudo.stage_id not in excluded_stages:
+            raise UserError(_("This ticket is not waiting for your approval."))
+
+        # Find next stage (higher sequence, typically Resolved/Closed)
+        next_stages = ticket_sudo.team_id.stage_ids.filtered(
+            lambda s: s.sequence > ticket_sudo.stage_id.sequence
+        ).sorted('sequence')
+        
+        if not next_stages:
+            # If no next stage, try to find closing stage
+            closing_stage = ticket_sudo.team_id._get_closing_stage()
+            if closing_stage:
+                next_stage = closing_stage[0]
+            else:
+                raise UserError(_("No next stage found for this ticket."))
+        else:
+            next_stage = next_stages[0]
+
+        # Move to next stage
+        ticket_sudo.write({'stage_id': next_stage.id})
+        
+        # Post message
+        body = _('Ticket approved by the customer')
+        ticket_sudo.with_context(mail_create_nosubscribe=True).message_post(
+            body=body, 
+            message_type='comment', 
+            subtype_xmlid='mail.mt_note'
+        )
+
+        return request.redirect('/my/ticket/%s/%s?ticket_approved=1' % (ticket_id, access_token or ''))
+
+    @http.route([
+        '/my/ticket/reject/<int:ticket_id>',
+        '/my/ticket/reject/<int:ticket_id>/<access_token>',
+    ], type='http', auth="public", website=True)
+    def ticket_reject(self, ticket_id=None, access_token=None, **kw):
+        """
+        Reject ticket when it's in an excluded stage (waiting for approval).
+        Moves ticket back to previous non-excluded stage (typically In Progress).
+        """
+        try:
+            ticket_sudo = self._document_check_access('helpdesk.ticket', ticket_id, access_token)
+        except (AccessError, MissingError):
+            return request.redirect('/my')
+
+        # Check if ticket is in an excluded stage (waiting for customer)
+        excluded_stages = ticket_sudo.sla_ids.mapped('exclude_stage_ids')
+        if ticket_sudo.stage_id not in excluded_stages:
+            raise UserError(_("This ticket is not waiting for your approval."))
+
+        # Find previous non-excluded stage (lower sequence, not in excluded stages)
+        prev_stages = ticket_sudo.team_id.stage_ids.filtered(
+            lambda s: s.sequence < ticket_sudo.stage_id.sequence 
+            and s not in excluded_stages
+        ).sorted('sequence', reverse=True)
+        
+        if not prev_stages:
+            # If no previous stage, try to find first non-excluded stage
+            first_stages = ticket_sudo.team_id.stage_ids.filtered(
+                lambda s: s not in excluded_stages
+            ).sorted('sequence')
+            if first_stages:
+                prev_stage = first_stages[0]
+            else:
+                raise UserError(_("No previous stage found for this ticket."))
+        else:
+            prev_stage = prev_stages[0]
+
+        # Move to previous stage
+        ticket_sudo.write({'stage_id': prev_stage.id})
+        
+        # Post message
+        body = _('Ticket rejected by the customer - needs more work')
+        ticket_sudo.with_context(mail_create_nosubscribe=True).message_post(
+            body=body, 
+            message_type='comment', 
+            subtype_xmlid='mail.mt_note'
+        )
+
+        return request.redirect('/my/ticket/%s/%s?ticket_rejected=1' % (ticket_id, access_token or ''))
+

+ 53 - 0
controllers/main.py

@@ -0,0 +1,53 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.addons.website.controllers.main import Website as WebsiteBase
+from odoo.http import request
+
+
+class Website(WebsiteBase):
+    """
+    Extend Website controller to redirect helpdesk collaborators
+    to the tickets dashboard after login.
+    """
+
+    def _login_redirect(self, uid, redirect=None):
+        """
+        Redirect helpdesk collaborators to dashboard after login.
+        For other users, use default behavior.
+        """
+        # If there's an explicit redirect, respect it
+        if redirect:
+            return super()._login_redirect(uid, redirect=redirect)
+        
+        # Check if user is portal and collaborator in helpdesk team
+        # Only check when login_success is True (after successful login)
+        if not redirect and request.params.get('login_success'):
+            try:
+                user = request.env['res.users'].browse(uid)
+                
+                # Only check for portal users (not internal users)
+                if user and user._is_portal():
+                    # Check if helpdesk_extras is available
+                    if 'helpdesk_extras' in request.env.registry._init_modules:
+                        partner = user.partner_id.commercial_partner_id
+                        
+                        # Check if user is a collaborator in any helpdesk team
+                        is_collaborator = (
+                            request.env["helpdesk.team.collaborator"]
+                            .sudo()
+                            .search_count([("partner_id", "=", partner.id)])
+                            > 0
+                        )
+                        
+                        if is_collaborator:
+                            # Set redirect to tickets dashboard
+                            redirect = '/my/tickets-dashboard'
+            except Exception:
+                # If any error occurs, fall back to default behavior
+                # (e.g., helpdesk_extras not installed, model not available, etc.)
+                pass
+        
+        # Call parent with redirect (may be None or '/my/tickets-dashboard')
+        return super()._login_redirect(uid, redirect=redirect)
+

+ 6 - 0
data/generate_primary_template.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <function model="ir.module.module" name="_generate_primary_snippet_templates">
+        <value eval="[ref('base.module_theme_m22tc')]"/>
+    </function>
+</odoo>

+ 45 - 0
data/ir_asset.xml

@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <data>
+        <!-- 
+            Usamos theme.ir.asset en lugar de ir.asset para que los assets
+            se copien al website específico cuando se activa el tema.
+            Esto evita que los estilos se apliquen a todos los sitios web.
+        -->
+        <record id="primary_variables_scss" model="theme.ir.asset">
+            <field name="name">M22 Tech Primary variables SCSS</field>
+            <field name="key">theme_m22tc.primary_variables_scss</field>
+            <field name="bundle">web._assets_primary_variables</field>
+            <field name="path">theme_m22tc/static/src/scss/primary_variables.scss</field>
+        </record>
+
+        <record id="bootstrap_overridden_scss" model="theme.ir.asset">
+            <field name="name">M22 Tech Bootstrap overridden SCSS</field>
+            <field name="key">theme_m22tc.bootstrap_overridden_scss</field>
+            <field name="bundle">web._assets_frontend_helpers</field>
+            <field name="directive">prepend</field>
+            <field name="path">theme_m22tc/static/src/scss/bootstrap_overridden.scss</field>
+        </record>
+
+        <record id="m22tc_styles_scss" model="theme.ir.asset">
+            <field name="name">M22 Tech Custom Styles SCSS</field>
+            <field name="key">theme_m22tc.m22tc_styles_scss</field>
+            <field name="bundle">web.assets_frontend</field>
+            <field name="path">theme_m22tc/static/src/scss/m22tc_styles.scss</field>
+        </record>
+
+        <record id="m22tc_sidebar_js" model="theme.ir.asset">
+            <field name="name">M22 Tech Sidebar JS</field>
+            <field name="key">theme_m22tc.m22tc_sidebar_js</field>
+            <field name="bundle">web.assets_frontend</field>
+            <field name="path">theme_m22tc/static/src/js/m22_sidebar.js</field>
+        </record>
+
+        <record id="m22tc_bottom_sheet_js" model="theme.ir.asset">
+            <field name="name">M22 Tech Bottom Sheet JS</field>
+            <field name="key">theme_m22tc.m22tc_bottom_sheet_js</field>
+            <field name="bundle">web.assets_frontend</field>
+            <field name="path">theme_m22tc/static/src/js/m22_bottom_sheet.js</field>
+        </record>
+    </data>
+</odoo>

+ 69 - 0
data/menu_data.xml

@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <data noupdate="1">
+        <!-- Root Menu for Sidebar (Hidden from Top Menu) -->
+        <record id="menu_portal_sidebar" model="website.menu">
+            <field name="name">Portal Sidebar</field>
+            <field name="url">/my/home</field>
+            <field name="parent_id" eval="False"/>
+            <field name="sequence" eval="10"/>
+        </record>
+
+        <!-- Sidebar Items -->
+        <record id="menu_portal_home" model="website.menu">
+            <field name="name">Inicio</field>
+            <field name="url">/my/home</field>
+            <field name="parent_id" ref="menu_portal_sidebar"/>
+            <field name="sequence" eval="10"/>
+            <field name="m22_icon_class">fa-home</field>
+        </record>
+
+        <record id="menu_portal_quotes" model="website.menu">
+            <field name="name">Cotizaciones</field>
+            <field name="url">/my/quotes</field>
+            <field name="parent_id" ref="menu_portal_sidebar"/>
+            <field name="sequence" eval="20"/>
+            <field name="m22_icon_class">fa-usd</field>
+        </record>
+
+        <record id="menu_portal_orders" model="website.menu">
+            <field name="name">Pedidos</field>
+            <field name="url">/my/orders</field>
+            <field name="parent_id" ref="menu_portal_sidebar"/>
+            <field name="sequence" eval="30"/>
+            <field name="m22_icon_class">fa-shopping-cart</field>
+        </record>
+
+        <record id="menu_portal_invoices" model="website.menu">
+            <field name="name">Facturas</field>
+            <field name="url">/my/invoices</field>
+            <field name="parent_id" ref="menu_portal_sidebar"/>
+            <field name="sequence" eval="40"/>
+            <field name="m22_icon_class">fa-file-text-o</field>
+        </record>
+
+        <record id="menu_portal_projects" model="website.menu">
+            <field name="name">Proyectos</field>
+            <field name="url">/my/projects</field>
+            <field name="parent_id" ref="menu_portal_sidebar"/>
+            <field name="sequence" eval="50"/>
+            <field name="m22_icon_class">fa-rocket</field>
+        </record>
+
+        <record id="menu_portal_tasks" model="website.menu">
+            <field name="name">Tareas</field>
+            <field name="url">/my/tasks</field>
+            <field name="parent_id" ref="menu_portal_sidebar"/>
+            <field name="sequence" eval="60"/>
+            <field name="m22_icon_class">fa-tasks</field>
+        </record>
+
+        <record id="menu_portal_tickets" model="website.menu">
+            <field name="name">Tickets</field>
+            <field name="url">/my/tickets</field>
+            <field name="parent_id" ref="menu_portal_sidebar"/>
+            <field name="sequence" eval="70"/>
+            <field name="m22_icon_class">fa-life-ring</field>
+        </record>
+    </data>
+</odoo>

+ 3 - 0
models/__init__.py

@@ -0,0 +1,3 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import theme_m22tc
+from . import website_menu

+ 61 - 0
models/theme_m22tc.py

@@ -0,0 +1,61 @@
+import logging
+
+from odoo import api, models
+
+_logger = logging.getLogger(__name__)
+
+
+class ThemeM22Tc(models.AbstractModel):
+    _inherit = "theme.utils"
+
+    def _theme_m22tc_post_copy(self, mod):
+        # Enable Ripple effects
+        self.enable_asset("website.ripple_effect_scss")
+        self.enable_asset("website.ripple_effect_js")
+
+        # Configure Header
+        self.enable_view("website.template_header_boxed")
+
+        # Configure Footer
+        self.enable_view("website.template_footer_centered")
+
+        # Disable default font options to force ours
+        # self.disable_view("website.option_font_title_01")
+        # self.disable_view("website.option_font_title_02")
+
+        # Odoo generates SCSS files based on these inputs.
+        # Ideally, we want to reset specific user choices to null so defaults take over
+        # but simpler is just ensuring our assets are loaded last.
+
+    @api.model
+    def cleanup_m22tc_portal_sidebar_views(self):
+        """Remove legacy portal layout overrides that duplicated the sidebar."""
+        legacy_keys = [
+            "theme_m22tc.m22_portal_custom_layout",
+            "theme_m22tc.m22_portal_hide_default_container",
+            "theme_m22tc.m22_portal_hide_original_wrap",
+        ]
+
+        theme_views = (
+            self.env["theme.ir.ui.view"]
+            .with_context(active_test=False)
+            .sudo()
+            .search([("key", "in", legacy_keys)])
+        )
+        if theme_views:
+            _logger.info(
+                "Removing legacy theme.ir.ui.view portal overrides: %s", theme_views.ids
+            )
+            theme_views.unlink()
+
+        ir_views = (
+            self.env["ir.ui.view"]
+            .with_context(active_test=False)
+            .sudo()
+            .search([("key", "in", legacy_keys)])
+        )
+        if ir_views:
+            _logger.info(
+                "Removing legacy ir.ui.view portal overrides: %s", ir_views.ids
+            )
+            ir_views.unlink()

+ 7 - 0
models/website_menu.py

@@ -0,0 +1,7 @@
+from odoo import models, fields
+
+class WebsiteMenu(models.Model):
+    _inherit = "website.menu"
+
+    m22_icon_class = fields.Char(string="M22 Icon Class", help="CSS class for the icon (e.g. fa-home, fa-file-text)")
+

+ 445 - 0
static/src/js/m22_bottom_sheet.js

@@ -0,0 +1,445 @@
+/** @odoo-module **/
+
+import publicWidget from "@web/legacy/js/public/public_widget";
+
+/**
+ * M22 Bottom Sheet Widget
+ * iOS-style bottom sheet that slides up from the bottom
+ * with smooth animations and drag-to-dismiss functionality
+ */
+publicWidget.registry.M22BottomSheet = publicWidget.Widget.extend({
+    selector: '#m22_bottom_sheet',
+    events: {
+        'click #m22_bottom_sheet_close': '_onCloseSheet',
+        'click #m22_bottom_sheet_backdrop': '_onBackdropClick',
+        'click .m22-sheet-item': '_onItemClick',
+    },
+
+    // Animation duration in ms
+    ANIMATION_DURATION: 300,
+    
+    // Drag threshold (px) to trigger dismiss
+    DRAG_THRESHOLD: 100,
+    
+    // Minimum drag distance to start dragging
+    DRAG_START_THRESHOLD: 10,
+
+    /**
+     * @override
+     */
+    start: function () {
+        this.sheet = document.getElementById('m22_bottom_sheet');
+        this.sheetContent = document.getElementById('m22_bottom_sheet_content');
+        this.sheetBackdrop = document.getElementById('m22_bottom_sheet_backdrop');
+        this.moreBtn = document.getElementById('m22_more_btn');
+        
+        // Drag to dismiss setup
+        this.isDragging = false;
+        this.startY = 0;
+        this.currentY = 0;
+        this.isOpen = false;
+        
+        // Bind methods
+        this._boundTouchStart = this._onTouchStart.bind(this);
+        this._boundTouchMove = this._onTouchMove.bind(this);
+        this._boundTouchEnd = this._onTouchEnd.bind(this);
+        
+        // Keyboard support
+        this._boundKeyDown = this._onKeyDown.bind(this);
+        
+        // Prevent body scroll when sheet is open
+        this._boundPreventScroll = this._preventScroll.bind(this);
+        
+        // Store global instance for access from button widget
+        window.M22BottomSheetInstance = this;
+        
+        return this._super.apply(this, arguments);
+    },
+
+    /**
+     * @override
+     */
+    destroy: function () {
+        this._removeEventListeners();
+        this._super.apply(this, arguments);
+    },
+
+    //--------------------------------------------------------------------------
+    // Private
+    //--------------------------------------------------------------------------
+
+    /**
+     * Opens the bottom sheet with animation
+     * @private
+     */
+    _openSheet: function () {
+        if (this.isOpen || !this.sheet) return;
+        
+        this.isOpen = true;
+        
+        // Show sheet using CSS class (fallback if Tailwind hidden doesn't work)
+        this.sheet.classList.add('visible');
+        this.sheet.classList.remove('hidden');
+        this.sheet.style.display = 'block';
+        this.sheet.style.pointerEvents = 'auto';
+        
+        // Force reflow to ensure display is processed
+        this.sheet.offsetHeight;
+        
+        // Animate backdrop
+        requestAnimationFrame(() => {
+            this.sheetBackdrop.classList.add('show');
+            this.sheetBackdrop.classList.remove('opacity-0');
+            this.sheetBackdrop.style.opacity = '1';
+            
+            // Animate sheet slide up
+            this.sheetContent.classList.add('show');
+            this.sheetContent.classList.remove('translate-y-full');
+            this.sheetContent.style.transform = 'translateY(0)';
+        });
+        
+        // Prevent body scroll
+        document.body.style.overflow = 'hidden';
+        
+        // Add event listeners for drag
+        this._addEventListeners();
+        
+        // Focus management
+        this._trapFocus();
+        
+        // Dispatch custom event
+        this.trigger('bottom-sheet-opened');
+    },
+
+    /**
+     * Closes the bottom sheet with animation
+     * @private
+     */
+    _closeSheet: function () {
+        if (!this.isOpen || !this.sheet) return;
+        
+        this.isOpen = false;
+        
+        // Animate backdrop
+        this.sheetBackdrop.classList.remove('show');
+        this.sheetBackdrop.style.opacity = '0';
+        
+        // Animate sheet slide down
+        this.sheetContent.classList.remove('show');
+        this.sheetContent.style.transform = 'translateY(100%)';
+        
+        // Remove event listeners
+        this._removeEventListeners();
+        
+        // Restore body scroll
+        document.body.style.overflow = '';
+        
+        // Hide after animation
+        setTimeout(() => {
+            if (!this.isOpen && this.sheet) {
+                this.sheet.classList.remove('visible');
+                this.sheet.classList.add('hidden');
+                this.sheet.style.display = 'none';
+                this.sheet.style.pointerEvents = 'none';
+            }
+        }, this.ANIMATION_DURATION);
+        
+        // Dispatch custom event
+        this.trigger('bottom-sheet-closed');
+    },
+
+    /**
+     * Adds event listeners for drag and keyboard
+     * @private
+     */
+    _addEventListeners: function () {
+        // Touch events for drag
+        this.sheetContent.addEventListener('touchstart', this._boundTouchStart, { passive: false });
+        this.sheetContent.addEventListener('touchmove', this._boundTouchMove, { passive: false });
+        this.sheetContent.addEventListener('touchend', this._boundTouchEnd, { passive: false });
+        
+        // Keyboard support
+        document.addEventListener('keydown', this._boundKeyDown);
+        
+        // Prevent body scroll
+        document.addEventListener('touchmove', this._boundPreventScroll, { passive: false });
+    },
+
+    /**
+     * Removes event listeners
+     * @private
+     */
+    _removeEventListeners: function () {
+        this.sheetContent.removeEventListener('touchstart', this._boundTouchStart);
+        this.sheetContent.removeEventListener('touchmove', this._boundTouchMove);
+        this.sheetContent.removeEventListener('touchend', this._boundTouchEnd);
+        document.removeEventListener('keydown', this._boundKeyDown);
+        document.removeEventListener('touchmove', this._boundPreventScroll);
+    },
+
+    /**
+     * Prevents scrolling when sheet is open
+     * @private
+     */
+    _preventScroll: function (ev) {
+        if (this.isOpen && !this.isDragging) {
+            // Allow scrolling within the sheet scrollable area
+            const scrollableArea = this.sheetContent.querySelector('.overflow-y-auto');
+            if (scrollableArea && scrollableArea.contains(ev.target)) {
+                return; // Allow scrolling in the content area
+            }
+            // Prevent scrolling elsewhere
+            ev.preventDefault();
+        }
+    },
+
+    /**
+     * Traps focus within the sheet
+     * @private
+     */
+    _trapFocus: function () {
+        const focusableElements = this.sheet.querySelectorAll(
+            'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
+        );
+        
+        if (focusableElements.length > 0) {
+            focusableElements[0].focus();
+        }
+    },
+
+    /**
+     * Handles touch start for drag
+     * @private
+     */
+    _onTouchStart: function (ev) {
+        // Allow dragging from the top area (handle, header, or first 60px of sheet)
+        const target = ev.target;
+        const header = this.sheetContent.querySelector('.px-6.pb-4');
+        const isTopArea = target.closest('.rounded-full') || 
+                         target.closest('#bottom-sheet-title') ||
+                         target.closest('h3') ||
+                         (header && header.contains(target)) ||
+                         ev.touches[0].clientY > (window.innerHeight - this.sheetContent.offsetHeight + 60);
+        
+        // Don't start drag if touching a link or button
+        if (!isTopArea || target.closest('a, button')) return;
+        
+        this.isDragging = true;
+        this.startY = ev.touches[0].clientY;
+        this.currentY = this.startY;
+    },
+
+    /**
+     * Handles touch move for drag
+     * @private
+     */
+    _onTouchMove: function (ev) {
+        if (!this.isDragging) return;
+        
+        this.currentY = ev.touches[0].clientY;
+        const deltaY = this.currentY - this.startY;
+        
+        // Only allow dragging down
+        if (deltaY > 0) {
+            ev.preventDefault();
+            
+            // Calculate translate value (don't go above 0)
+            const translateY = Math.min(deltaY, 0);
+            
+            // Apply transform
+            this.sheetContent.style.transform = `translateY(${Math.max(translateY, -this.sheetContent.offsetHeight)}px)`;
+            
+            // Update backdrop opacity
+            const opacity = 1 - (Math.abs(translateY) / this.sheetContent.offsetHeight);
+            this.sheetBackdrop.style.opacity = Math.max(0, opacity);
+        }
+    },
+
+    /**
+     * Handles touch end for drag
+     * @private
+     */
+    _onTouchEnd: function () {
+        if (!this.isDragging) return;
+        
+        this.isDragging = false;
+        const deltaY = this.currentY - this.startY;
+        
+        // Reset transform
+        this.sheetContent.style.transform = '';
+        this.sheetBackdrop.style.opacity = '';
+        
+        // Close if dragged down enough
+        if (deltaY > this.DRAG_THRESHOLD) {
+            this._closeSheet();
+        } else {
+            // Snap back to open position
+            this.sheetContent.classList.remove('translate-y-full');
+            this.sheetContent.classList.add('translate-y-0');
+        }
+        
+        this.startY = 0;
+        this.currentY = 0;
+    },
+
+    /**
+     * Handles keyboard events
+     * @private
+     */
+    _onKeyDown: function (ev) {
+        if (!this.isOpen) return;
+        
+        // Close on Escape
+        if (ev.key === 'Escape') {
+            this._closeSheet();
+        }
+    },
+
+    //--------------------------------------------------------------------------
+    // Handlers
+    //--------------------------------------------------------------------------
+
+    /**
+     * Opens the sheet when "More" button is clicked
+     * @private
+     */
+    _onOpenSheet: function (ev) {
+        ev.preventDefault();
+        ev.stopPropagation();
+        this._openSheet();
+    },
+
+    /**
+     * Closes the sheet when close button is clicked
+     * @private
+     */
+    _onCloseSheet: function (ev) {
+        ev.preventDefault();
+        ev.stopPropagation();
+        this._closeSheet();
+    },
+
+    /**
+     * Closes the sheet when backdrop is clicked
+     * @private
+     */
+    _onBackdropClick: function (ev) {
+        if (ev.target === this.sheetBackdrop) {
+            this._closeSheet();
+        }
+    },
+
+    /**
+     * Closes the sheet when an item is clicked
+     * @private
+     */
+    _onItemClick: function () {
+        // Small delay to allow navigation to start
+        setTimeout(() => {
+            this._closeSheet();
+        }, 100);
+    },
+});
+
+/**
+ * Global instance to access sheet methods
+ */
+window.M22BottomSheetInstance = null;
+
+/**
+ * Widget for the "More" button that opens the bottom sheet
+ * Separate widget because the button is outside the sheet's selector
+ */
+publicWidget.registry.M22BottomSheetTrigger = publicWidget.Widget.extend({
+    selector: '#m22_more_btn',
+    events: {
+        'click': '_onOpenSheet',
+    },
+
+    /**
+     * Opens the bottom sheet
+     * @private
+     */
+    _onOpenSheet: function (ev) {
+        ev.preventDefault();
+        ev.stopPropagation();
+        
+        // Use global instance if available
+        if (window.M22BottomSheetInstance) {
+            window.M22BottomSheetInstance._openSheet();
+        } else {
+            // Fallback: Direct manipulation
+            this._openSheetDirect();
+        }
+    },
+
+    /**
+     * Direct method to open sheet (fallback)
+     * @private
+     */
+    _openSheetDirect: function () {
+        const sheet = document.getElementById('m22_bottom_sheet');
+        const sheetContent = document.getElementById('m22_bottom_sheet_content');
+        const sheetBackdrop = document.getElementById('m22_bottom_sheet_backdrop');
+        
+        if (!sheet || !sheetContent || !sheetBackdrop) return;
+        
+        // Show sheet
+        sheet.classList.add('visible');
+        sheet.classList.remove('hidden');
+        sheet.style.display = 'block';
+        sheet.style.pointerEvents = 'auto';
+        
+        // Force reflow
+        sheet.offsetHeight;
+        
+        // Animate
+        requestAnimationFrame(() => {
+            sheetBackdrop.classList.add('show');
+            sheetBackdrop.style.opacity = '1';
+            sheetContent.classList.add('show');
+            sheetContent.style.transform = 'translateY(0)';
+        });
+        
+        // Prevent body scroll
+        document.body.style.overflow = 'hidden';
+        
+        // Add close listeners
+        const closeBtn = document.getElementById('m22_bottom_sheet_close');
+        const backdrop = sheetBackdrop;
+        const items = sheet.querySelectorAll('.m22-sheet-item');
+        
+        const closeSheet = () => {
+            sheetBackdrop.classList.remove('show');
+            sheetBackdrop.style.opacity = '0';
+            sheetContent.classList.remove('show');
+            sheetContent.style.transform = 'translateY(100%)';
+            
+            setTimeout(() => {
+                sheet.classList.remove('visible');
+                sheet.classList.add('hidden');
+                sheet.style.display = 'none';
+                sheet.style.pointerEvents = 'none';
+                document.body.style.overflow = '';
+            }, 300);
+            
+            // Remove listeners
+            if (closeBtn) closeBtn.removeEventListener('click', closeSheet);
+            if (backdrop) backdrop.removeEventListener('click', closeSheet);
+            items.forEach(item => item.removeEventListener('click', closeSheet));
+            document.removeEventListener('keydown', escapeHandler);
+        };
+        
+        const escapeHandler = (ev) => {
+            if (ev.key === 'Escape') closeSheet();
+        };
+        
+        // Add listeners
+        if (closeBtn) closeBtn.addEventListener('click', closeSheet);
+        if (backdrop) backdrop.addEventListener('click', (ev) => {
+            if (ev.target === backdrop) closeSheet();
+        });
+        items.forEach(item => item.addEventListener('click', closeSheet));
+        document.addEventListener('keydown', escapeHandler);
+    },
+});

+ 309 - 0
static/src/js/m22_sidebar.js

@@ -0,0 +1,309 @@
+/** @odoo-module **/
+
+import publicWidget from "@web/legacy/js/public/public_widget";
+
+/**
+ * M22 Sidebar Widget
+ * Handles the collapsible sidebar functionality with smooth transitions
+ * and proper state management.
+ */
+publicWidget.registry.M22Sidebar = publicWidget.Widget.extend({
+    selector: '#m22_sidebar',
+    events: {
+        'click .sidebar-collapse-btn': '_onToggleSidebar',
+        'click .nav-link': '_onLinkClick',
+    },
+
+    // Transition duration in ms (should match CSS)
+    TRANSITION_DURATION: 300,
+
+    /**
+     * @override
+     */
+    start: function () {
+        this.backdrop = document.getElementById('m22_sidebar_backdrop');
+        this.collapseBtn = this.el.querySelector('.sidebar-collapse-btn');
+        this.collapseBtnIcon = this.collapseBtn?.querySelector('i');
+        
+        // Cache frequently accessed elements
+        this.sidebarTexts = this.el.querySelectorAll('.sidebar-text, .sidebar-title, .sidebar-logo-text');
+        this.navLinks = this.el.querySelectorAll('.nav-link');
+        
+        // Setup backdrop listener
+        if (this.backdrop) {
+            this._boundCloseMobile = this._onCloseMobileSidebar.bind(this);
+            this.backdrop.addEventListener('click', this._boundCloseMobile);
+        }
+
+        // Restore state from localStorage (without animation on page load)
+        this._restoreState(true);
+
+        // Handle window resize
+        this._boundResize = this._onWindowResize.bind(this);
+        window.addEventListener('resize', this._boundResize);
+
+        return this._super.apply(this, arguments);
+    },
+
+    /**
+     * @override
+     */
+    destroy: function () {
+        if (this.backdrop && this._boundCloseMobile) {
+            this.backdrop.removeEventListener('click', this._boundCloseMobile);
+        }
+        if (this._boundResize) {
+            window.removeEventListener('resize', this._boundResize);
+        }
+        this._super.apply(this, arguments);
+    },
+
+    //--------------------------------------------------------------------------
+    // Private
+    //--------------------------------------------------------------------------
+
+    /**
+     * Restores the sidebar state from localStorage
+     * @param {Boolean} skipAnimation - Whether to skip the collapse animation
+     * @private
+     */
+    _restoreState: function (skipAnimation = false) {
+        const savedState = localStorage.getItem('m22_sidebar_collapsed');
+        const isCollapsed = savedState === 'true';
+        const htmlEl = document.documentElement;
+        const hasEarlyInit = htmlEl.classList.contains('m22-sidebar-collapsed-init');
+        
+        // If early init already applied collapsed state (sidebar is hidden)
+        if (hasEarlyInit && isCollapsed) {
+            // Sync JS state while sidebar is still hidden
+            this.el.setAttribute('data-collapsed', 'true');
+            document.body.classList.add('sidebar-collapsed');
+            
+            // Update icon BEFORE showing sidebar
+            this._updateCollapseIcon(true);
+            
+            // Hide texts
+            this._hideTexts();
+            
+            // Now show sidebar by adding initialized class
+            // Use double RAF to ensure styles are applied before visibility
+            requestAnimationFrame(() => {
+                requestAnimationFrame(() => {
+                    htmlEl.classList.add('m22-sidebar-initialized');
+                });
+            });
+            return;
+        }
+        
+        // Remove early init class if state is expanded (show sidebar immediately)
+        if (hasEarlyInit && !isCollapsed) {
+            // First set expanded state
+            this.el.setAttribute('data-collapsed', 'false');
+            document.body.classList.remove('sidebar-collapsed');
+            this._updateCollapseIcon(false);
+            this._showTexts();
+            
+            // Then show sidebar
+            htmlEl.classList.remove('m22-sidebar-collapsed-init');
+        }
+        
+        if (skipAnimation && !hasEarlyInit) {
+            // Temporarily disable transitions
+            this.el.style.transition = 'none';
+            document.body.style.transition = 'none';
+        }
+
+        if (!hasEarlyInit) {
+            this._setCollapsedState(isCollapsed, skipAnimation);
+        }
+
+        if (skipAnimation && !hasEarlyInit) {
+            // Re-enable transitions after a frame
+            requestAnimationFrame(() => {
+                requestAnimationFrame(() => {
+                    this.el.style.transition = '';
+                    document.body.style.transition = '';
+                });
+            });
+        }
+    },
+
+    /**
+     * Sets the collapsed state of the sidebar
+     * @param {Boolean} isCollapsed - Whether the sidebar should be collapsed
+     * @param {Boolean} skipAnimation - Whether to skip animation
+     * @private
+     */
+    _setCollapsedState: function (isCollapsed, skipAnimation = false) {
+        const htmlEl = document.documentElement;
+        this.el.setAttribute('data-collapsed', isCollapsed);
+
+        if (isCollapsed) {
+            document.body.classList.add('sidebar-collapsed');
+            // Ensure init class is present for collapsed state
+            htmlEl.classList.add('m22-sidebar-collapsed-init');
+            this._updateCollapseIcon(true);
+            
+            // Hide text immediately when collapsing
+            this._hideTexts();
+        } else {
+            document.body.classList.remove('sidebar-collapsed');
+            // Remove init class when expanding to allow normal CSS behavior
+            htmlEl.classList.remove('m22-sidebar-collapsed-init');
+            this._updateCollapseIcon(false);
+            
+            // Show text with delay to match sidebar expansion
+            if (!skipAnimation) {
+                setTimeout(() => {
+                    this._showTexts();
+                }, this.TRANSITION_DURATION / 2);
+            } else {
+                this._showTexts();
+            }
+        }
+    },
+
+    /**
+     * Updates the collapse button icon based on state
+     * @param {Boolean} isCollapsed
+     * @private
+     */
+    _updateCollapseIcon: function (isCollapsed) {
+        if (this.collapseBtnIcon) {
+            this.collapseBtnIcon.classList.remove('fa-chevron-left', 'fa-chevron-right', 'fa-bars');
+            this.collapseBtnIcon.classList.add(isCollapsed ? 'fa-chevron-right' : 'fa-chevron-left');
+        }
+    },
+
+    /**
+     * Hides sidebar text elements
+     * @private
+     */
+    _hideTexts: function () {
+        this.sidebarTexts.forEach(el => {
+            el.style.opacity = '0';
+            el.style.visibility = 'hidden';
+        });
+    },
+
+    /**
+     * Shows sidebar text elements
+     * @private
+     */
+    _showTexts: function () {
+        this.sidebarTexts.forEach(el => {
+            el.style.opacity = '1';
+            el.style.visibility = 'visible';
+        });
+    },
+
+    /**
+     * Checks if we're in mobile view
+     * @returns {Boolean}
+     * @private
+     */
+    _isMobile: function () {
+        return window.innerWidth < 992;
+    },
+
+    //--------------------------------------------------------------------------
+    // Handlers
+    //--------------------------------------------------------------------------
+
+    /**
+     * Toggles the sidebar collapsed state
+     * @param {Event} ev
+     * @private
+     */
+    _onToggleSidebar: function (ev) {
+        ev.preventDefault();
+        ev.stopPropagation();
+
+        if (this._isMobile()) {
+            // On mobile, toggle the overlay sidebar
+            const isOpen = this.el.classList.contains('m22-sidebar-open');
+            if (isOpen) {
+                this._onCloseMobileSidebar();
+            } else {
+                this._onOpenMobileSidebar();
+            }
+        } else {
+            // On desktop, toggle collapse state
+            const isCollapsed = this.el.getAttribute('data-collapsed') === 'true';
+            const newState = !isCollapsed;
+
+            this._setCollapsedState(newState);
+            localStorage.setItem('m22_sidebar_collapsed', newState);
+        }
+    },
+
+    /**
+     * Closes sidebar on mobile when a link is clicked
+     * @private
+     */
+    _onLinkClick: function () {
+        if (this._isMobile() && this.el.classList.contains('m22-sidebar-open')) {
+            this._onCloseMobileSidebar();
+        }
+    },
+
+    /**
+     * Opens the mobile sidebar
+     * @private
+     */
+    _onOpenMobileSidebar: function () {
+        this.el.classList.add('m22-sidebar-open');
+        if (this.backdrop) {
+            this.backdrop.classList.add('visible');
+        }
+        document.body.style.overflow = 'hidden';
+    },
+
+    /**
+     * Closes the mobile sidebar
+     * @private
+     */
+    _onCloseMobileSidebar: function () {
+        this.el.classList.remove('m22-sidebar-open');
+        if (this.backdrop) {
+            this.backdrop.classList.remove('visible');
+        }
+        document.body.style.overflow = '';
+    },
+
+    /**
+     * Handle window resize
+     * @private
+     */
+    _onWindowResize: function () {
+        // Close mobile sidebar if resizing to desktop
+        if (!this._isMobile() && this.el.classList.contains('m22-sidebar-open')) {
+            this._onCloseMobileSidebar();
+        }
+    }
+});
+
+/**
+ * External trigger widget for opening the sidebar on mobile
+ * Use data-action="open-m22-sidebar" on any element
+ */
+publicWidget.registry.M22SidebarTrigger = publicWidget.Widget.extend({
+    selector: '[data-action="open-m22-sidebar"]',
+    events: {
+        'click': '_onTriggerClick',
+    },
+
+    _onTriggerClick: function (ev) {
+        ev.preventDefault();
+        const sidebar = document.getElementById('m22_sidebar');
+        const backdrop = document.getElementById('m22_sidebar_backdrop');
+        
+        if (sidebar) {
+            sidebar.classList.add('m22-sidebar-open');
+            if (backdrop) {
+                backdrop.classList.add('visible');
+            }
+            document.body.style.overflow = 'hidden';
+        }
+    }
+});

+ 29 - 0
static/src/scss/bootstrap_overridden.scss

@@ -0,0 +1,29 @@
+// Bootstrap Overrides for M22 Tech Theme
+
+// Border Radius
+$border-radius: 0.5rem;      // 8px
+$border-radius-lg: 1rem;     // 16px
+$border-radius-sm: 0.25rem;  // 4px
+
+// Box Shadow (Subtle and diffuse)
+$box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.2);
+$box-shadow-sm: 0px 2px 10px rgba(0, 0, 0, 0.1);
+$box-shadow-lg: 0px 10px 30px rgba(0, 0, 0, 0.3);
+
+// Spacing
+$spacer: 1rem;
+
+// Buttons
+$btn-padding-y: 0.75rem;
+$btn-padding-x: 1.5rem;
+$btn-font-weight: 600;
+$btn-transition: all 0.3s ease-in-out;
+
+// Body - Use light colors for reports compatibility
+// Dark backgrounds are applied via specific CSS selectors in m22tc_styles.scss
+$body-bg: #FFFFFF; // White (for reports, emails, etc.)
+$body-color: #1A1A1A; // Dark text for readability
+
+// Headings
+$headings-font-weight: 700;
+$headings-color: #1A1A1A; // Dark headings for reports

+ 1866 - 0
static/src/scss/m22tc_styles.scss

@@ -0,0 +1,1866 @@
+// ============================================================
+// M22 TECH CONSULTING - THEME STYLES
+// Futurismo Cálido + Glassmorphism
+// ============================================================
+// 
+// CONTEXTOS VISUALES:
+// 1. Login/Auth: 100% Dark + Glassmorphism intenso
+// 2. Sidebar: Dark + Glassmorphism sutil
+// 3. Contenido Principal: Light + Glassmorphism elegante
+//
+// ============================================================
+
+// -------------------------------------------------------
+// VARIABLES
+// -------------------------------------------------------
+$m22-midnight: #0F111A;
+$m22-sidebar-dark: #1C1633;
+$m22-orange: #FF7C00;
+$m22-magenta: #E0407B;
+$m22-content-light: #F8F8F8;
+$m22-white: #FFFFFF;
+$m22-text-dark: #1A1A1A;
+$m22-text-muted: #6B7280;
+$m22-text-light: #A0A0A0;
+
+// -------------------------------------------------------
+// 0. EARLY INIT - Prevent sidebar flash on page load
+// -------------------------------------------------------
+// This class is applied to <html> by an inline script in <head>
+// before the page renders, preventing the visual flash when
+// restoring collapsed state from localStorage.
+
+html.m22-sidebar-collapsed-init {
+    // Sidebar VISIBLE but in collapsed state - NO transitions
+    .m22-sidebar {
+        width: 72px !important;
+        
+        // Kill ALL transitions during init
+        &, *, *::before, *::after {
+            transition: none !important;
+            animation: none !important;
+        }
+        
+        // CRITICAL: Fix icon positions to prevent movement
+        .nav-link {
+            position: relative !important;
+            justify-content: center !important;
+            padding: 0.75rem !important;
+            
+            // Apply to both icon selectors to cover all cases
+            i.sidebar-icon,
+            .fa:first-child {
+                position: relative !important;
+                flex-shrink: 0 !important;
+                width: 20px !important;
+                min-width: 20px !important;
+                text-align: center !important;
+                margin: 0 !important;
+            }
+        }
+        
+        // Also fix nav container padding to match collapsed state
+        .sidebar-nav {
+            padding: 1rem 0.5rem !important;
+        }
+        
+        // Fix header padding and alignment
+        .sidebar-header {
+            padding: 0.75rem !important;
+            justify-content: center !important;
+        }
+        
+        // Hide logo when collapsed (same as normal collapsed state)
+        .sidebar-logo {
+            display: none !important;
+        }
+        
+        // Center collapse button
+        .sidebar-collapse-btn {
+            margin: 0 auto !important;
+        }
+        
+        // Fix footer padding
+        .sidebar-footer {
+            padding: 0.5rem !important;
+        }
+        
+        // Fix user info when collapsed
+        .sidebar-user-info {
+            justify-content: center !important;
+            padding: 0.5rem !important;
+        }
+        
+        // Fix logout button when collapsed
+        .sidebar-logout {
+            padding: 0.625rem !important;
+        }
+        
+        // Hide text elements - use absolute to remove from flow
+        .sidebar-text,
+        .sidebar-title,
+        .sidebar-logo-text {
+            opacity: 0 !important;
+            visibility: hidden !important;
+            width: 0 !important;
+            position: absolute !important;
+            left: -9999px !important; // Move off-screen to prevent layout shift
+        }
+        
+        // SMART: Hide the original icon, show correct one via CSS
+        .sidebar-collapse-btn {
+            position: relative !important;
+            
+            i {
+                // Hide original icon content
+                visibility: hidden !important;
+                position: relative;
+                
+                // Show chevron-right via ::after
+                &::after {
+                    content: "\f054"; // fa-chevron-right
+                    font-family: "Font Awesome 5 Free", "Font Awesome 6 Free", FontAwesome;
+                    font-weight: 900;
+                    visibility: visible !important;
+                    position: absolute;
+                    left: 0;
+                    top: 0;
+                }
+            }
+        }
+    }
+    
+    // Main content with correct margin - NO transitions
+    #wrapwrap.o_has_m22_sidebar main.o_main_with_sidebar {
+        margin-left: 72px !important;
+        transition: none !important;
+    }
+    
+    // When expanded (remove init class), allow normal behavior
+    &:not(.m22-sidebar-collapsed-init) {
+        .m22-sidebar {
+            width: 260px !important;
+            
+            .sidebar-text,
+            .sidebar-title,
+            .sidebar-logo-text {
+                opacity: 1 !important;
+                visibility: visible !important;
+                width: auto !important;
+                position: static !important;
+                left: auto !important;
+            }
+        }
+        
+        #wrapwrap.o_has_m22_sidebar main.o_main_with_sidebar {
+            margin-left: 260px !important;
+        }
+    }
+    
+    // After JS initializes, enable transitions
+    &.m22-sidebar-initialized {
+        .m22-sidebar {
+            transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
+            
+            // Show original icon (JS already set the correct class)
+            .sidebar-collapse-btn i {
+                visibility: visible !important;
+                
+                &::after {
+                    display: none;
+                }
+            }
+            
+            // Enable transitions for children
+            .sidebar-text,
+            .sidebar-title,
+            .sidebar-logo-text,
+            .nav-link,
+            .sidebar-footer {
+                transition: opacity 0.2s ease, visibility 0.2s ease !important;
+            }
+        }
+        
+        #wrapwrap.o_has_m22_sidebar main.o_main_with_sidebar {
+            transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
+        }
+    }
+}
+
+// -------------------------------------------------------
+// 1. BASE STYLES (When M22 Theme is Active)
+// -------------------------------------------------------
+
+// Body base - Light background for content area
+// Note: Login pages have their own dark background via login_custom.xml
+#wrapwrap.o_has_m22_sidebar {
+    background-color: $m22-content-light;
+    // Subtle gradient texture for glassmorphism effect
+    background-image: 
+        radial-gradient(ellipse 80% 50% at 10% 20%, rgba($m22-orange, 0.04), transparent 50%),
+        radial-gradient(ellipse 80% 50% at 90% 80%, rgba($m22-magenta, 0.04), transparent 50%);
+    background-attachment: fixed;
+    min-height: 100vh;
+}
+
+// -------------------------------------------------------
+// 2. SIDEBAR - DARK GLASSMORPHISM
+// -------------------------------------------------------
+$sidebar-width-expanded: 260px;
+$sidebar-width-collapsed: 72px;
+$sidebar-transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+
+.m22-sidebar {
+    position: fixed;
+    top: 0;
+    left: 0;
+    bottom: 0;
+    width: $sidebar-width-expanded;
+    height: 100vh;
+    z-index: 100;
+    
+    // Dark Glassmorphism
+    background: rgba($m22-midnight, 0.95);
+    backdrop-filter: blur(20px);
+    -webkit-backdrop-filter: blur(20px);
+    border-right: 1px solid rgba(255, 255, 255, 0.08);
+    box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
+    
+    // Smooth width transition
+    transition: width $sidebar-transition;
+
+    // Prevent horizontal scroll - critical fix
+    overflow: hidden;
+
+    // Sidebar Header
+    .sidebar-header {
+        flex-shrink: 0;
+        border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+        min-height: 64px;
+        display: flex;
+        align-items: center;
+        overflow: hidden;
+    }
+
+    // Logo area
+    .sidebar-logo {
+        display: flex;
+        align-items: center;
+        overflow: hidden;
+        flex-shrink: 0;
+        
+        img {
+            max-height: 32px;
+            width: auto;
+            flex-shrink: 0;
+            transition: opacity $sidebar-transition;
+        }
+    }
+
+    // Navigation container
+    .sidebar-nav {
+        flex: 1;
+        overflow-y: auto;
+        overflow-x: hidden;
+        padding: 1rem 0.75rem;
+        
+        // Hide scrollbar when collapsed
+        &::-webkit-scrollbar {
+            width: 4px;
+        }
+    }
+
+    // Nav Links - Base state
+    .nav-link {
+        display: flex;
+        align-items: center;
+        color: $m22-text-light;
+        border-radius: 8px;
+        padding: 0.75rem 1rem;
+        margin-bottom: 4px;
+        text-decoration: none;
+        position: relative;
+        overflow: hidden;
+        transition: 
+            background $sidebar-transition,
+            color 0.2s ease,
+            padding $sidebar-transition;
+
+        // Icon styling
+        i.sidebar-icon,
+        .fa:first-child {
+            width: 20px;
+            min-width: 20px;
+            text-align: center;
+            font-size: 1.1rem;
+            flex-shrink: 0;
+            transition: margin $sidebar-transition;
+        }
+
+        // Text styling
+        .sidebar-text {
+            margin-left: 12px;
+            white-space: nowrap;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            transition: 
+                opacity 0.15s ease,
+                visibility 0.15s ease,
+                margin $sidebar-transition;
+        }
+
+        &:hover {
+            color: $m22-white;
+            background: rgba(255, 255, 255, 0.08);
+        }
+
+        &.active {
+            background: linear-gradient(90deg, rgba($m22-orange, 0.15), rgba($m22-magenta, 0.1));
+            color: $m22-orange;
+            
+            // Active indicator bar
+            &::before {
+                content: '';
+                position: absolute;
+                left: 0;
+                top: 50%;
+                transform: translateY(-50%);
+                width: 3px;
+                height: 60%;
+                background: $m22-orange;
+                border-radius: 0 2px 2px 0;
+            }
+        }
+    }
+
+    // Sidebar Footer
+    .sidebar-footer {
+        flex-shrink: 0;
+        border-top: 1px solid rgba(255, 255, 255, 0.08);
+        padding: 0.75rem;
+        overflow: hidden;
+    }
+
+    // User info section
+    .sidebar-user-info {
+        display: flex;
+        align-items: center;
+        padding: 0.5rem;
+        border-radius: 8px;
+        margin-bottom: 0.5rem;
+        overflow: hidden;
+        transition: background 0.2s ease;
+
+        &:hover {
+            background: rgba(255, 255, 255, 0.05);
+        }
+    }
+
+    // User Avatar
+    .user-avatar {
+        width: 36px;
+        height: 36px;
+        min-width: 36px;
+        background: linear-gradient(135deg, $m22-orange, $m22-magenta);
+        border-radius: 50%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        color: $m22-white;
+        font-size: 0.875rem;
+        flex-shrink: 0;
+    }
+
+    // Collapse Button - Modern design
+    .sidebar-collapse-btn {
+        width: 32px;
+        height: 32px;
+        min-width: 32px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        border-radius: 8px;
+        background: rgba(255, 255, 255, 0.05);
+        border: 1px solid rgba(255, 255, 255, 0.1);
+        color: $m22-text-light;
+        cursor: pointer;
+        transition: all 0.2s ease;
+        padding: 0;
+        flex-shrink: 0;
+
+        i {
+            font-size: 0.875rem;
+            transition: transform 0.3s ease;
+        }
+
+        &:hover {
+            background: rgba(255, 255, 255, 0.1);
+            border-color: rgba(255, 255, 255, 0.2);
+            color: $m22-white;
+        }
+
+        &:active {
+            transform: scale(0.95);
+        }
+    }
+
+    // Logout Button
+    .sidebar-logout {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 100%;
+        padding: 0.625rem 1rem;
+        color: $m22-text-light;
+        border: 1px solid rgba(255, 255, 255, 0.15);
+        border-radius: 8px;
+        background: transparent;
+        text-decoration: none;
+        font-size: 0.875rem;
+        transition: all 0.2s ease;
+        overflow: hidden;
+
+        i {
+            flex-shrink: 0;
+        }
+        
+        &:hover {
+            background: rgba(239, 68, 68, 0.15);
+            border-color: rgba(239, 68, 68, 0.4);
+            color: #EF4444;
+        }
+    }
+
+    // =========================================
+    // COLLAPSED STATE - Desktop Only
+    // =========================================
+    &[data-collapsed="true"] {
+        width: $sidebar-width-collapsed;
+
+        // Header adjustments
+        .sidebar-header {
+            padding: 0.75rem !important;
+            justify-content: center;
+        }
+
+        // Hide logo when collapsed
+        .sidebar-logo {
+            display: none;
+        }
+
+        // Center collapse button
+        .sidebar-collapse-btn {
+            margin: 0 auto;
+        }
+
+        // Nav adjustments
+        .sidebar-nav {
+            padding: 1rem 0.5rem;
+        }
+
+        // Nav link adjustments
+        .nav-link {
+            justify-content: center;
+            padding: 0.75rem;
+            
+            // Hide text
+        .sidebar-text {
+            opacity: 0;
+            visibility: hidden;
+                width: 0;
+                margin-left: 0;
+                position: absolute;
+        }
+
+            // Center icon
+            i.sidebar-icon,
+            .fa:first-child {
+                margin: 0;
+            }
+
+            // Adjust active indicator for collapsed
+            &.active::before {
+                height: 40%;
+            }
+
+            // Tooltip on hover when collapsed
+            &::after {
+                content: attr(title);
+                position: absolute;
+                left: calc(100% + 12px);
+                top: 50%;
+                transform: translateY(-50%);
+                background: rgba($m22-midnight, 0.95);
+                backdrop-filter: blur(10px);
+                color: $m22-white;
+                padding: 0.5rem 0.75rem;
+                border-radius: 6px;
+                font-size: 0.8125rem;
+                white-space: nowrap;
+                opacity: 0;
+                visibility: hidden;
+                pointer-events: none;
+                transition: opacity 0.2s ease, visibility 0.2s ease;
+                z-index: 1000;
+                box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+                border: 1px solid rgba(255, 255, 255, 0.1);
+            }
+
+            &:hover::after {
+                opacity: 1;
+                visibility: visible;
+            }
+        }
+
+        // Footer adjustments
+        .sidebar-footer {
+            padding: 0.5rem;
+        }
+
+        // User info when collapsed
+        .sidebar-user-info {
+            justify-content: center;
+            padding: 0.5rem;
+            
+            .sidebar-text {
+                display: none;
+            }
+        }
+
+        // Logout button when collapsed
+        .sidebar-logout {
+            padding: 0.625rem;
+            
+            .sidebar-text {
+                display: none;
+            }
+        }
+    }
+}
+
+// -------------------------------------------------------
+// 3. MAIN CONTENT AREA - LIGHT GLASSMORPHISM
+// -------------------------------------------------------
+#wrapwrap.o_has_m22_sidebar {
+    main.o_main_with_sidebar {
+        margin-left: $sidebar-width-expanded !important;
+        min-height: 100vh;
+        padding: 1.5rem 2rem;
+        transition: margin-left $sidebar-transition;
+        
+        @media (max-width: 991px) {
+            margin-left: 0 !important;
+            padding: 1rem;
+            padding-bottom: 80px; // Space for bottom nav
+        }
+    }
+    }
+
+// Glassmorphism Cards (Light Mode)
+.m22-card,
+.card,
+#wrapwrap.o_has_m22_sidebar .card {
+    background: rgba($m22-white, 0.75);
+    backdrop-filter: blur(12px);
+    -webkit-backdrop-filter: blur(12px);
+    border: 1px solid rgba(0, 0, 0, 0.06);
+    border-radius: 12px;
+    box-shadow: 0 4px 24px rgba(0, 0, 0, 0.04);
+    transition: all 0.3s ease;
+
+    &:hover {
+        box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
+        border-color: rgba($m22-orange, 0.15);
+    }
+}
+
+// -------------------------------------------------------
+// 4. TYPOGRAPHY - CONTEXT AWARE
+// -------------------------------------------------------
+
+// Light Context (Content Area)
+#wrapwrap.o_has_m22_sidebar {
+    color: $m22-text-dark;
+
+    h1, h2, h3, h4, h5, h6,
+    .h1, .h2, .h3, .h4, .h5, .h6 {
+        color: $m22-text-dark !important;
+        font-family: 'Inter', sans-serif;
+        font-weight: 700;
+    }
+
+    p, .text-muted {
+        color: $m22-text-muted;
+    }
+
+    // Links
+    a:not(.nav-link):not(.btn) {
+        color: $m22-orange;
+        text-decoration: none;
+        transition: color 0.2s ease;
+
+        &:hover {
+            color: darken($m22-orange, 10%);
+        }
+    }
+}
+
+// Dark Context (Sidebar) - Already handled in .m22-sidebar
+
+// -------------------------------------------------------
+// 5. TABLES - LIGHT MODE GLASSMORPHISM
+// -------------------------------------------------------
+#wrapwrap.o_has_m22_sidebar {
+    .table,
+    .o_portal_my_doc_table,
+    table {
+        background: rgba($m22-white, 0.8);
+        backdrop-filter: blur(8px);
+        border-radius: 12px;
+        overflow: hidden;
+        border-collapse: separate;
+        border-spacing: 0;
+
+        thead {
+            background: rgba($m22-content-light, 0.9);
+            
+            th {
+                color: $m22-text-dark;
+                font-weight: 600;
+                font-size: 0.75rem;
+                text-transform: uppercase;
+                letter-spacing: 0.05em;
+                padding: 1rem 1.5rem;
+                border-bottom: 1px solid rgba(0, 0, 0, 0.08);
+            }
+        }
+
+        tbody {
+            tr {
+                transition: background 0.2s ease;
+
+                &:hover {
+                    background: rgba($m22-orange, 0.04);
+                }
+
+                td {
+                    padding: 1rem 1.5rem;
+                    color: $m22-text-dark;
+                    border-bottom: 1px solid rgba(0, 0, 0, 0.05);
+                    vertical-align: middle;
+        }
+
+                &:last-child td {
+                    border-bottom: none;
+                }
+            }
+        }
+
+        // Links in tables
+        a {
+            color: $m22-orange;
+            font-weight: 500;
+
+        &:hover {
+                text-decoration: underline;
+            }
+        }
+        }
+    }
+
+// -------------------------------------------------------
+// 6. BADGES & STATUS INDICATORS
+// -------------------------------------------------------
+// Universal badge styling for light content backgrounds
+// Ensures all Bootstrap badges are visible with proper contrast
+
+.badge {
+    font-weight: 500;
+    padding: 0.35em 0.75em;
+    border-radius: 9999px;
+    font-size: 0.75rem;
+    letter-spacing: 0.01em;
+}
+
+// Primary - Orange M22 style
+.badge-primary,
+.text-bg-primary {
+    background: rgba($m22-orange, 0.15) !important;
+    color: darken($m22-orange, 5%) !important;
+}
+
+// Secondary - Gray
+.badge-secondary,
+.text-bg-secondary {
+    background: rgba(107, 114, 128, 0.15) !important;
+    color: #4B5563 !important;
+}
+
+// Success - Green
+.badge-paid,
+.badge-success,
+.text-bg-success {
+    background: rgba(34, 197, 94, 0.15) !important;
+    color: #16A34A !important;
+}
+
+// Warning - Yellow/Amber
+.badge-pending,
+.badge-warning,
+.text-bg-warning {
+    background: rgba(234, 179, 8, 0.15) !important;
+    color: #CA8A04 !important;
+    }
+
+// Danger - Red
+.badge-overdue,
+.badge-danger,
+.text-bg-danger {
+    background: rgba(239, 68, 68, 0.15) !important;
+    color: #DC2626 !important;
+}
+
+// Info - Cyan/Blue
+.badge-info,
+.text-bg-info {
+    background: rgba(59, 130, 246, 0.15) !important;
+    color: #2563EB !important;
+}
+
+// Light - Gray with dark text (CRITICAL for helpdesk stages)
+.badge-light,
+.text-bg-light,
+.bg-200 {
+    background: rgba(0, 0, 0, 0.06) !important;
+    color: $m22-text-dark !important;
+}
+
+// Dark - Dark bg with light text
+.badge-dark,
+.text-bg-dark {
+    background: rgba($m22-midnight, 0.85) !important;
+    color: $m22-white !important;
+}
+
+// -------------------------------------------------------
+// 7. BUTTONS - M22 STYLE
+// -------------------------------------------------------
+.btn-m22-primary,
+.btn-primary {
+    background: linear-gradient(90deg, $m22-orange, $m22-magenta) !important;
+    border: none !important;
+    color: $m22-white !important;
+    font-weight: 600;
+    padding: 0.625rem 1.25rem;
+    border-radius: 8px;
+    box-shadow: 0 4px 12px rgba($m22-orange, 0.25);
+    transition: all 0.3s ease;
+
+    &:hover {
+        transform: translateY(-1px);
+        box-shadow: 0 6px 20px rgba($m22-orange, 0.35);
+        opacity: 0.95;
+    }
+
+    &:active {
+        transform: translateY(0);
+    }
+}
+
+.btn-outline-primary {
+    color: $m22-orange !important;
+    border-color: $m22-orange !important;
+    background: transparent !important;
+
+    &:hover {
+        background: rgba($m22-orange, 0.1) !important;
+    }
+}
+
+// -------------------------------------------------------
+// 8. FORM INPUTS - LIGHT GLASSMORPHISM
+// -------------------------------------------------------
+#wrapwrap.o_has_m22_sidebar {
+    .form-control,
+    .form-select,
+    input[type="text"],
+    input[type="email"],
+    input[type="password"],
+    input[type="number"],
+    textarea,
+    select {
+        background: rgba($m22-white, 0.9);
+        backdrop-filter: blur(4px);
+        border: 1px solid rgba(0, 0, 0, 0.1);
+        border-radius: 8px;
+        color: $m22-text-dark;
+        padding: 0.625rem 1rem;
+        transition: all 0.2s ease;
+
+        &:focus {
+            border-color: $m22-orange;
+            box-shadow: 0 0 0 3px rgba($m22-orange, 0.15);
+            outline: none;
+        }
+
+        &::placeholder {
+            color: $m22-text-muted;
+        }
+    }
+
+    label {
+        color: $m22-text-dark;
+        font-weight: 500;
+        font-size: 0.875rem;
+    }
+}
+
+// -------------------------------------------------------
+// 9. BREADCRUMBS
+// -------------------------------------------------------
+#wrapwrap.o_has_m22_sidebar {
+    .breadcrumb {
+        background: transparent;
+        padding: 0;
+        margin-bottom: 1.5rem;
+
+        .breadcrumb-item {
+            color: $m22-text-muted;
+            font-size: 0.875rem;
+
+            a {
+                color: $m22-text-muted;
+
+                &:hover {
+                    color: $m22-orange;
+                }
+            }
+
+            &.active {
+                color: $m22-text-dark;
+                font-weight: 500;
+            }
+
+            + .breadcrumb-item::before {
+                color: $m22-text-muted;
+            }
+        }
+    }
+}
+
+// -------------------------------------------------------
+// 10. MOBILE SIDEBAR & BOTTOM NAV
+// -------------------------------------------------------
+@media (max-width: 991px) {
+    .m22-sidebar {
+        width: 280px;
+        transform: translateX(-100%);
+        transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+        z-index: 1050;
+
+        &.m22-sidebar-open {
+            transform: translateX(0);
+        }
+    }
+}
+
+// Sidebar Backdrop (Mobile)
+.m22-sidebar-backdrop {
+    position: fixed;
+    inset: 0;
+    background: rgba(0, 0, 0, 0.5);
+    backdrop-filter: blur(4px);
+    z-index: 1040;
+    opacity: 0;
+    visibility: hidden;
+    transition: all 0.3s ease;
+
+    &.visible {
+        opacity: 1;
+        visibility: visible;
+    }
+}
+
+// Bottom Navigation (Mobile)
+.m22-bottom-nav {
+    position: fixed;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    background: rgba($m22-midnight, 0.95);
+    backdrop-filter: blur(20px);
+    -webkit-backdrop-filter: blur(20px);
+    border-top: 1px solid rgba(255, 255, 255, 0.1);
+    z-index: 1030;
+    padding: 0.5rem 0;
+
+    .bottom-nav-item {
+        color: $m22-text-light;
+        text-decoration: none;
+        transition: all 0.2s ease;
+        padding: 0.5rem;
+        flex: 1;
+        text-align: center;
+
+        i, .fa {
+            font-size: 1.25rem;
+            display: block;
+            margin-bottom: 0.25rem;
+        }
+
+        .bottom-nav-label {
+            font-size: 0.7rem;
+            display: block;
+        }
+
+        &:hover,
+        &.active {
+            color: $m22-orange;
+        }
+        
+        // More button styling
+        &#m22_more_btn {
+            color: $m22-text-light;
+            
+            &:hover,
+            &:focus {
+                color: $m22-orange;
+                outline: none;
+            }
+        }
+    }
+}
+
+// -------------------------------------------------------
+// 10.1. BOTTOM SHEET (iOS Style with Tailwind)
+// -------------------------------------------------------
+// Override Tailwind classes to match theme colors
+#m22_bottom_sheet {
+    position: fixed;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    z-index: 1050; // Above bottom nav (1030)
+    
+    // Hidden by default (CSS fallback if Tailwind doesn't work)
+    display: none;
+    pointer-events: none;
+    
+    &.visible {
+        display: block;
+        pointer-events: auto;
+    }
+    
+    // Backdrop with theme colors
+    #m22_bottom_sheet_backdrop {
+        position: absolute;
+        top: 0;
+        left: 0;
+        right: 0;
+        bottom: 0;
+        background: rgba($m22-midnight, 0.75);
+        backdrop-filter: blur(8px);
+        -webkit-backdrop-filter: blur(8px);
+        opacity: 0;
+        transition: opacity 0.3s ease;
+        
+        &.show {
+            opacity: 1;
+        }
+    }
+    
+    // Sheet content - Theme integration
+    #m22_bottom_sheet_content {
+        position: absolute;
+        bottom: 0;
+        left: 0;
+        right: 0;
+        background: rgba($m22-white, 0.98);
+        backdrop-filter: blur(20px);
+        -webkit-backdrop-filter: blur(20px);
+        box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.15);
+        border-radius: 24px 24px 0 0;
+        max-height: 85vh;
+        display: flex;
+        flex-direction: column;
+        transform: translateY(100%);
+        transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+        
+        &.show {
+            transform: translateY(0);
+        }
+        
+        // Handle indicator
+        .rounded-full {
+            background: rgba($m22-text-muted, 0.3);
+        }
+        
+        // Header
+        h3 {
+            color: $m22-text-dark;
+        }
+        
+        // Sheet items with theme colors
+        .m22-sheet-item {
+            color: $m22-text-dark;
+            
+            &:hover {
+                background: rgba($m22-orange, 0.08);
+                
+                i {
+                    color: $m22-orange;
+                }
+            }
+            
+            i.fa:first-child {
+                color: $m22-text-muted;
+                transition: color 0.2s ease;
+            }
+        }
+        
+        // Close button
+        button#m22_bottom_sheet_close {
+            background: rgba($m22-content-light, 0.9);
+            color: $m22-text-dark;
+            
+            &:hover {
+                background: rgba($m22-content-light, 1);
+                color: $m22-orange;
+            }
+        }
+        
+        // Scrollbar styling for sheet content
+        .overflow-y-auto {
+            &::-webkit-scrollbar {
+                width: 4px;
+            }
+            
+            &::-webkit-scrollbar-track {
+                background: transparent;
+            }
+            
+            &::-webkit-scrollbar-thumb {
+                background: rgba($m22-orange, 0.3);
+                border-radius: 2px;
+                
+                &:hover {
+                    background: rgba($m22-orange, 0.5);
+                }
+            }
+        }
+    }
+}
+
+// Dark mode support (if implemented later)
+@media (prefers-color-scheme: dark) {
+    #m22_bottom_sheet {
+        #m22_bottom_sheet_content {
+            background: rgba($m22-midnight, 0.98);
+            
+            h3 {
+                color: $m22-white;
+            }
+            
+            .m22-sheet-item {
+                color: $m22-white;
+                
+                &:hover {
+                    background: rgba($m22-orange, 0.15);
+                }
+            }
+            
+            button#m22_bottom_sheet_close {
+                background: rgba($m22-sidebar-dark, 0.9);
+                color: $m22-white;
+                
+                &:hover {
+                    background: rgba($m22-sidebar-dark, 1);
+                }
+            }
+        }
+    }
+}
+
+// -------------------------------------------------------
+// 11. GRADIENT TEXT UTILITY
+// -------------------------------------------------------
+.text-gradient {
+    background: linear-gradient(90deg, $m22-orange, $m22-magenta);
+    -webkit-background-clip: text;
+    -webkit-text-fill-color: transparent;
+    background-clip: text;
+    }
+
+// -------------------------------------------------------
+// 12. SCROLLBAR STYLING
+// -------------------------------------------------------
+.m22-sidebar .sidebar-nav,
+#wrapwrap.o_has_m22_sidebar main {
+    scroll-behavior: smooth;
+    
+    &::-webkit-scrollbar {
+        width: 6px;
+    }
+
+    &::-webkit-scrollbar-track {
+        background: transparent;
+    }
+
+    &::-webkit-scrollbar-thumb {
+        background: rgba($m22-orange, 0.3);
+        border-radius: 3px;
+
+        &:hover {
+            background: rgba($m22-orange, 0.5);
+        }
+    }
+}
+
+// Dark scrollbar for sidebar
+.m22-sidebar .sidebar-nav {
+    &::-webkit-scrollbar-track {
+        background: rgba(255, 255, 255, 0.05);
+    }
+}
+
+// -------------------------------------------------------
+// 13. PORTAL-SPECIFIC OVERRIDES
+// -------------------------------------------------------
+#wrapwrap.o_has_m22_sidebar {
+    // Hide duplicate sidebar elements in "My Account" page only
+    .o_portal_wrap {
+        // Hide the offcanvas sidebar (mobile duplicate)
+        #accountOffCanvas {
+            display: none !important;
+        }
+    }
+
+    // Portal container
+    .o_portal {
+        background: transparent;
+    }
+
+    // Card styling in portal - Glassmorphism Light
+    .o_portal_wrap .card,
+    .o_portal_sidebar .card {
+        background: rgba($m22-white, 0.85);
+        backdrop-filter: blur(12px);
+        -webkit-backdrop-filter: blur(12px);
+        border: 1px solid rgba(0, 0, 0, 0.06);
+        border-radius: 12px;
+        }
+
+    // Ensure main portal sidebar container is visible
+    .o_portal_sidebar {
+        display: block !important;
+    }
+
+    // =========================================
+    // Native Odoo Record Sidebar Styling
+    // (Actions panel: total, buttons, contact)
+    // =========================================
+    .o_portal_sidebar_content {
+        // Glassmorphism container
+        background: rgba($m22-white, 0.9);
+        backdrop-filter: blur(16px);
+        -webkit-backdrop-filter: blur(16px);
+        border: 1px solid rgba(0, 0, 0, 0.08);
+        border-radius: 16px;
+        padding: 1.5rem;
+        box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
+
+        // Total amount - Prominent display
+        > .position-relative {
+            h2, .h2 {
+                color: $m22-orange !important;
+                font-weight: 700;
+                font-size: 2rem;
+            }
+        }
+
+        // All headings in sidebar
+        h2, .h2, h4, .h4, h5, .h5, h6, .h6 {
+            color: $m22-text-dark !important;
+        }
+
+        // Muted text - Make it visible
+        .text-muted, small {
+            color: $m22-text-muted !important;
+        }
+
+        // ---- BUTTONS ----
+        // Primary buttons (Accept, Sign, Pay)
+        .btn-primary {
+            background: linear-gradient(90deg, $m22-orange, $m22-magenta) !important;
+            border: none !important;
+            color: $m22-white !important;
+            font-weight: 600;
+            padding: 0.75rem 1.25rem;
+            border-radius: 8px;
+            box-shadow: 0 4px 12px rgba($m22-orange, 0.25);
+            
+            &:hover {
+                opacity: 0.9;
+                transform: translateY(-1px);
+            }
+        }
+
+        // Light buttons (View Details, Download PDF)
+        .btn-light {
+            background: rgba($m22-content-light, 0.9) !important;
+            border: 1px solid rgba(0, 0, 0, 0.1) !important;
+            color: $m22-text-dark !important;
+            font-weight: 500;
+            padding: 0.625rem 1rem;
+            border-radius: 8px;
+            
+            &:hover {
+                background: rgba($m22-content-light, 1) !important;
+                border-color: $m22-orange !important;
+                color: $m22-orange !important;
+        }
+
+            i, .fa {
+                color: $m22-text-muted;
+            }
+        }
+
+        // Outline buttons
+        .btn-outline-primary {
+            color: $m22-orange !important;
+            border-color: $m22-orange !important;
+            background: transparent !important;
+
+            &:hover {
+                background: rgba($m22-orange, 0.1) !important;
+            }
+        }
+
+        // ---- NAVIGATION (navspy and general nav links) ----
+        // Generic nav-link styling for sidebar navigation
+        .nav.flex-column,
+        .navspy, 
+        .bs-sidenav,
+        [role="complementary"] {
+            .nav-link {
+                color: $m22-text-muted !important;
+                padding: 0.5rem 0;
+                font-size: 0.875rem;
+                border-left: 2px solid transparent;
+                padding-left: 0.75rem;
+                transition: all 0.2s ease;
+                
+                &:hover {
+                    color: $m22-orange !important;
+                    border-left-color: rgba($m22-orange, 0.3);
+                }
+
+                &.active {
+                    color: $m22-orange !important;
+                    font-weight: 600;
+                    border-left-color: $m22-orange;
+                }
+            }
+
+            // Nav items without explicit nav-link class
+            .nav-item > a:not(.btn) {
+                color: $m22-text-muted !important;
+                text-decoration: none;
+                display: block;
+                padding: 0.5rem 0;
+                font-size: 0.875rem;
+                transition: color 0.2s ease;
+
+                &:hover {
+                    color: $m22-orange !important;
+        }
+            }
+        }
+
+        // Specific fix for helpdesk ticket sidebar links
+        #ticket-nav,
+        #ticket-links {
+            .nav-link {
+                color: $m22-text-dark !important;
+                font-weight: 500;
+                
+                &:hover {
+                    color: $m22-orange !important;
+                }
+            }
+        }
+
+        // ---- CONTACT SECTION ----
+        .o_portal_contact_details,
+        [class*="contact"] {
+            h5, h6, .h5, .h6 {
+                color: $m22-text-muted !important;
+                font-size: 0.75rem;
+                text-transform: uppercase;
+                letter-spacing: 0.05em;
+                margin-bottom: 0.75rem;
+            }
+
+            // Contact name
+            .fw-bold, strong, b {
+                color: $m22-text-dark !important;
+            }
+
+            // Contact link
+            a {
+                color: $m22-orange !important;
+                text-decoration: none;
+                font-weight: 500;
+
+                &:hover {
+                    text-decoration: underline;
+                }
+            }
+        }
+
+        // ---- LIST GROUP ITEMS ----
+        .list-group-item {
+            background: rgba($m22-white, 0.5);
+            border: 1px solid rgba(0, 0, 0, 0.06);
+            border-radius: 8px;
+            padding: 1rem;
+            
+            .text-success {
+                color: #16A34A !important;
+        }
+    }
+
+        // ---- DIVIDER ----
+        hr {
+            border-color: rgba(0, 0, 0, 0.08);
+}
+
+        // Hide "Powered by Odoo" in our theme
+        > .d-none.d-lg-block.mt-5.small.text-center.text-muted {
+            display: none !important;
+        }
+    }
+
+    // Vertical divider between sidebar and content
+    .o_portal_sale_sidebar > .vr,
+    .o_portal_invoice_sidebar > .vr {
+        background-color: rgba(0, 0, 0, 0.08) !important;
+    }
+
+    // =========================================
+    // ADAPTIVE CONTENT WIDTH
+    // Different widths for list vs detail pages
+    // =========================================
+    main.o_main_with_sidebar {
+        // Default: Centered content for detail pages
+        .container,
+        .o_m22_portal_container {
+            @media (min-width: 992px) {
+                max-width: 1200px;
+                margin-left: auto;
+                margin-right: auto;
+            }
+        }
+    }
+
+    // LISTADO PAGES: Full width for list tables
+    // Target containers that contain portal document tables
+    #wrapwrap.o_has_m22_sidebar {
+        // When container has list table, make it full width
+        .container:has(.o_portal_my_doc_table),
+        .o_m22_portal_container:has(.o_portal_my_doc_table) {
+            max-width: 100% !important;
+            padding-left: 2rem;
+            padding-right: 2rem;
+            
+            @media (max-width: 991px) {
+                padding-left: 1rem;
+                padding-right: 1rem;
+            }
+        }
+
+        // DETAIL WITH NATIVE SIDEBAR: Wider layout for 2 columns  
+        // Pages with native Odoo sidebar (tickets detail, invoices detail)
+        .container:has(.o_portal_sidebar):not(:has(.o_portal_my_doc_table)),
+        .o_m22_portal_container:has(.o_portal_sidebar):not(:has(.o_portal_my_doc_table)),
+        .container:has(#ticket-nav),
+        .o_m22_portal_container:has(#ticket-nav) {
+            max-width: 1400px !important;
+            margin-left: auto;
+            margin-right: auto;
+        }
+    }
+
+    // Utility classes for manual control (if needed)
+    .m22-full-width {
+        max-width: 100% !important;
+        padding-left: 2rem;
+        padding-right: 2rem;
+        
+        @media (max-width: 991px) {
+            padding-left: 1rem;
+            padding-right: 1rem;
+        }
+    }
+
+    .m22-content-centered {
+        max-width: 1200px;
+        margin-left: auto;
+        margin-right: auto;
+    }
+
+    .m22-content-wide {
+        max-width: 1400px;
+        margin-left: auto;
+        margin-right: auto;
+    }
+}
+
+// -------------------------------------------------------
+// 14. ANIMATIONS & TRANSITIONS
+// -------------------------------------------------------
+@keyframes fadeIn {
+    from {
+        opacity: 0;
+        transform: translateY(10px);
+    }
+    to {
+        opacity: 1;
+        transform: translateY(0);
+    }
+}
+
+.m22-card,
+#wrapwrap.o_has_m22_sidebar .card {
+    animation: fadeIn 0.3s ease-out;
+}
+
+// -------------------------------------------------------
+// 15. COLLAPSED SIDEBAR STATE - Main Content Adjustment
+// -------------------------------------------------------
+body.sidebar-collapsed {
+    #wrapwrap.o_has_m22_sidebar main.o_main_with_sidebar {
+        @media (min-width: 992px) {
+            margin-left: $sidebar-width-collapsed !important;
+        }
+    }
+}
+
+// -------------------------------------------------------
+// 16. HIDE HEADER & FOOTER (Theme-specific)
+// Note: This is conditional via templates, but CSS backup
+// -------------------------------------------------------
+#wrapwrap.o_has_m22_sidebar {
+    header#top,
+    #top_menu_container,
+    .o_header_standard,
+    footer.o_footer,
+    #footer,
+    .o_footer_copyright {
+        display: none !important;
+    }
+}
+
+// -------------------------------------------------------
+// 17. PORTAL LAYOUT ADJUSTMENTS
+// Ocultar sidebar nativo del portal (solo en my/account)
+// Expandir contenido a ancho completo
+// Asegurar que filtros del portal se muestren en desktop
+// -------------------------------------------------------
+#wrapwrap.o_has_m22_sidebar {
+    // Ocultar el sidebar nativo de "My Account" (col-lg-4)
+    .o_portal_wrap .container {
+        .row.justify-content-between {
+            .d-none.d-lg-flex.justify-content-end.col-lg-4 {
+                display: none !important;
+            }
+            
+            // Ocultar offcanvas también
+            #accountOffCanvas {
+                display: none !important;
+            }
+            
+            // Expandir contenido a ancho completo
+            .o_portal_content {
+                width: 100% !important;
+                max-width: 100% !important;
+                flex: 0 0 100% !important;
+            }
+        }
+    }
+    
+    // Asegurar que el navbar de filtros del portal siempre se muestre en desktop
+    // IMPORTANTE: Los filtros usan clases de Bootstrap (.collapse) que pueden estar rotas si Tailwind reemplaza Bootstrap
+    // Forzamos display con máxima especificidad
+    .o_portal_wrap {
+        nav.o_portal_navbar {
+            // En desktop, siempre mostrar los filtros
+            @media (min-width: 992px) {
+                .collapse {
+                    display: flex !important;
+                    visibility: visible !important;
+                    height: auto !important;
+                }
+                
+                .navbar-collapse {
+                    display: flex !important;
+                    flex-basis: auto !important;
+                    visibility: visible !important;
+                }
+                
+                #o_portal_navbar_content {
+                    display: flex !important;
+                    visibility: visible !important;
+                }
+            }
+            
+            // =========================================
+            // PORTAL NAVBAR - Estilos específicos
+            // Corrección de inputs, dropdowns y posicionamiento
+            // =========================================
+            
+            // Contenedor principal del navbar - Ajuste de alineación
+            .navbar-nav,
+            #o_portal_navbar_content {
+                align-items: center;
+                gap: 0.75rem;
+            }
+            
+            // Input de búsqueda - Fondo claro glassmorphism
+            input[type="text"],
+            input[type="search"],
+            .form-control,
+            input.form-control {
+                background: rgba($m22-white, 0.9) !important;
+                backdrop-filter: blur(4px);
+                -webkit-backdrop-filter: blur(4px);
+                border: 1px solid rgba(0, 0, 0, 0.1) !important;
+                border-radius: 8px !important;
+                color: $m22-text-dark !important;
+                padding: 0.5rem 1rem !important;
+                transition: all 0.2s ease;
+                
+                &::placeholder {
+                    color: $m22-text-muted !important;
+                }
+                
+                &:focus {
+                    background: rgba($m22-white, 0.95) !important;
+                    border-color: $m22-orange !important;
+                    box-shadow: 0 0 0 3px rgba($m22-orange, 0.15) !important;
+                    outline: none !important;
+                }
+            }
+            
+            // Botones dropdown - Estilo claro glassmorphism
+            .btn,
+            .btn-group > .btn,
+            button[data-bs-toggle="dropdown"],
+            button.dropdown-toggle {
+                background: rgba($m22-white, 0.9) !important;
+                backdrop-filter: blur(4px);
+                -webkit-backdrop-filter: blur(4px);
+                border: 1px solid rgba(0, 0, 0, 0.1) !important;
+                border-radius: 8px !important;
+                color: $m22-text-dark !important;
+                padding: 0.5rem 1rem !important;
+                font-weight: 500;
+                transition: all 0.2s ease;
+                
+                &:hover,
+                &:focus {
+                    background: rgba($m22-white, 0.95) !important;
+                    border-color: rgba($m22-orange, 0.3) !important;
+                    color: $m22-text-dark !important;
+                }
+                
+                &:active,
+                &.active,
+                &[aria-expanded="true"] {
+                    background: rgba($m22-content-light, 0.95) !important;
+                    border-color: rgba($m22-orange, 0.2) !important;
+                    color: $m22-text-dark !important;
+                    box-shadow: 0 0 0 2px rgba($m22-orange, 0.1) !important;
+                }
+            }
+            
+            // Menú dropdown - Estilo claro
+            .dropdown-menu {
+                background: rgba($m22-white, 0.98) !important;
+                backdrop-filter: blur(12px);
+                -webkit-backdrop-filter: blur(12px);
+                border: 1px solid rgba(0, 0, 0, 0.08) !important;
+                border-radius: 12px !important;
+                box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1) !important;
+                padding: 0.5rem 0 !important;
+                margin-top: 0.5rem !important;
+                
+                .dropdown-item {
+                    color: $m22-text-dark !important;
+                    padding: 0.625rem 1.25rem !important;
+                    transition: all 0.15s ease;
+                    
+                    &:hover {
+                        background: rgba($m22-orange, 0.08) !important;
+                        color: $m22-text-dark !important;
+                    }
+                    
+                    &:active,
+                    &.active {
+                        background: rgba($m22-orange, 0.12) !important;
+                        color: $m22-text-dark !important;
+                        font-weight: 500;
+                    }
+                }
+            }
+            
+            // Labels de los dropdowns (Ordenar por, Filtrar por, etc.)
+            label,
+            .form-label {
+                color: $m22-text-dark !important;
+                font-weight: 500;
+                font-size: 0.875rem;
+                margin-bottom: 0.25rem;
+                margin-right: 0.5rem;
+            }
+            
+            // Botón de búsqueda (icono)
+            button[type="submit"],
+            .btn-search,
+            button:has(.fa-search) {
+                background: rgba($m22-white, 0.9) !important;
+                backdrop-filter: blur(4px);
+                -webkit-backdrop-filter: blur(4px);
+                border: 1px solid rgba(0, 0, 0, 0.1) !important;
+                border-radius: 8px !important;
+                color: $m22-text-muted !important;
+                padding: 0.5rem 0.75rem !important;
+                transition: all 0.2s ease;
+                
+                &:hover {
+                    background: rgba($m22-white, 0.95) !important;
+                    border-color: $m22-orange !important;
+                    color: $m22-orange !important;
+                }
+            }
+            
+            // Ajuste de espaciado y alineación vertical
+            .form-group,
+            .input-group {
+                display: flex;
+                align-items: center;
+                gap: 0.5rem;
+                margin-bottom: 0;
+            }
+            
+            // Asegurar que los elementos estén alineados verticalmente
+            .d-flex,
+            .navbar-nav {
+                align-items: center;
+            }
+        }
+    }
+
+    // -------------------------------------------------------
+    // 18. HELPDESK TICKETS DASHBOARD
+    // -------------------------------------------------------
+    // Dashboard Cards - Glassmorphism Light
+    .m22-dashboard-card {
+        background: rgba($m22-white, 0.85);
+        backdrop-filter: blur(12px);
+        -webkit-backdrop-filter: blur(12px);
+        border: 1px solid rgba(0, 0, 0, 0.06);
+        border-radius: 16px;
+        box-shadow: 0 4px 24px rgba(0, 0, 0, 0.04);
+        transition: all 0.3s ease;
+
+        &:hover {
+            box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
+            border-color: rgba($m22-orange, 0.15);
+            transform: translateY(-2px);
+        }
+
+        .card-body {
+            padding: 1.5rem;
+        }
+
+        .card-title {
+            color: $m22-text-dark;
+            font-weight: 600;
+            font-size: 1.125rem;
+        }
+
+        h3 {
+            color: $m22-text-dark;
+            font-weight: 700;
+            font-size: 2rem;
+        }
+
+        h6 {
+            color: $m22-text-muted;
+            font-weight: 500;
+            font-size: 0.75rem;
+            letter-spacing: 0.05em;
+        }
+    }
+
+    // Dashboard Icon Container
+    .m22-dashboard-icon {
+        width: 60px;
+        height: 60px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        border-radius: 12px;
+        background: rgba($m22-content-light, 0.8);
+    }
+
+    // Stage Cards (mini cards in summary)
+    .m22-stage-card {
+        background: rgba($m22-white, 0.6);
+        backdrop-filter: blur(8px);
+        -webkit-backdrop-filter: blur(8px);
+        border: 1px solid rgba(0, 0, 0, 0.05);
+        transition: all 0.2s ease;
+
+        &:hover {
+            background: rgba($m22-white, 0.8);
+            border-color: rgba($m22-orange, 0.2);
+            transform: translateY(-2px);
+        }
+    }
+
+    // Progress Bar Customization
+    .progress {
+        background: rgba($m22-content-light, 0.8);
+        border-radius: 4px;
+        overflow: hidden;
+    }
+
+    // Table in Dashboard
+    .m22-dashboard-card {
+        .table {
+            margin-bottom: 0;
+
+            thead th {
+                background: transparent;
+                border-bottom: 1px solid rgba(0, 0, 0, 0.08);
+                color: $m22-text-muted;
+                font-weight: 600;
+                font-size: 0.75rem;
+                text-transform: uppercase;
+                letter-spacing: 0.05em;
+                padding: 0.75rem 1rem;
+            }
+
+            tbody tr {
+                transition: background 0.2s ease;
+
+                &:hover {
+                    background: rgba($m22-orange, 0.04);
+                }
+
+                td {
+                    padding: 1rem;
+                    color: $m22-text-dark;
+                    border-bottom: 1px solid rgba(0, 0, 0, 0.05);
+                    vertical-align: middle;
+
+                    a {
+                        color: $m22-orange;
+                        font-weight: 500;
+                        text-decoration: none;
+
+                        &:hover {
+                            text-decoration: underline;
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    // List Group Items (Waiting Response)
+    .list-group-item {
+        background: rgba($m22-white, 0.7);
+        border: 1px solid rgba(0, 0, 0, 0.08);
+        border-radius: 8px;
+        margin-bottom: 0.5rem;
+        transition: all 0.2s ease;
+
+        &:hover {
+            background: rgba($m22-orange, 0.08);
+            border-color: rgba($m22-orange, 0.2);
+            transform: translateX(4px);
+        }
+
+        h6 {
+            color: $m22-text-dark;
+            font-weight: 600;
+        }
+
+        .text-muted {
+            color: $m22-text-muted;
+        }
+    }
+
+    // Border Warning for Waiting Response Card
+    .border-warning {
+        border-color: rgba(234, 179, 8, 0.3) !important;
+        border-width: 2px;
+    }
+
+    // -------------------------------------------------------
+    // 19. HELPDESK TICKET APPROVAL BANNER
+    // -------------------------------------------------------
+    .m22-approval-banner {
+        background: rgba($m22-white, 0.95);
+        backdrop-filter: blur(12px);
+        -webkit-backdrop-filter: blur(12px);
+        border: 2px solid rgba($m22-orange, 0.3);
+        border-radius: 16px;
+        box-shadow: 0 4px 24px rgba($m22-orange, 0.1);
+        padding: 1.5rem;
+
+        .alert-heading {
+            color: $m22-text-dark;
+            font-weight: 600;
+            font-size: 1.125rem;
+
+            i {
+                color: $m22-orange;
+            }
+        }
+
+        p {
+            color: $m22-text-muted;
+        }
+
+        .btn-success {
+            background: linear-gradient(135deg, #22c55e, #16a34a);
+            border: none;
+            box-shadow: 0 2px 8px rgba(34, 197, 94, 0.3);
+            transition: all 0.3s ease;
+
+            &:hover {
+                transform: translateY(-2px);
+                box-shadow: 0 4px 12px rgba(34, 197, 94, 0.4);
+            }
+        }
+
+        .btn-warning {
+            background: linear-gradient(135deg, #f59e0b, #d97706);
+            border: none;
+            color: white;
+            box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3);
+            transition: all 0.3s ease;
+
+            &:hover {
+                transform: translateY(-2px);
+                box-shadow: 0 4px 12px rgba(245, 158, 11, 0.4);
+                color: white;
+            }
+        }
+    }
+}

+ 68 - 0
static/src/scss/primary_variables.scss

@@ -0,0 +1,68 @@
+
+// ------------------------------------------------------------------
+// M22 Tech Consulting - Design System
+// ------------------------------------------------------------------
+
+// 1. Font Configurations
+// ------------------------------------------------------------------
+$o-theme-font-configs: (
+    'Inter': (
+        'family': ('Inter', sans-serif),
+        'url': 'Inter:300,400,500,600,700,800',
+    ),
+    'Montserrat': (
+        'family': ('Montserrat', sans-serif),
+        'url': 'Montserrat:300,400,500,600,700,800',
+    ),
+);
+
+// 2. Color Palette
+// ------------------------------------------------------------------
+$o-color-palette: (
+    'o-color-1': #FF7C00,  // Primary Accent (Orange M22)
+    'o-color-2': #E0407B,  // Secondary Accent (Magenta)
+    'o-color-3': #0F111A,  // Dark Background (Midnight Blue)
+    'o-color-4': #F8F8F8,  // Light Background (Soft Gray)
+    'o-color-5': #FFFFFF,  // White
+);
+
+// 3. Website Values Palettes (This sets the defaults in the Editor)
+// ------------------------------------------------------------------
+$o-website-values-palettes: (
+    (
+        'color-palettes-name': 'm22-palette',
+        
+        // Fonts
+        'font': 'Inter',
+        'headings-font': 'Inter',
+        
+        // Button Styles
+        'btn-ripple': true,
+        'btn-border-radius': 0.5rem,
+        'btn-padding-y': 0.75rem,
+        'btn-padding-x': 1.5rem,
+        
+        // Header/Footer
+        'header-template': 'boxed',
+        'footer-template': 'centered',
+    ),
+);
+
+// 4. Base Layout Overrides
+// ------------------------------------------------------------------
+// Use light/neutral colors for global defaults (reports, emails need white bg)
+// Dark theme styles are applied via CSS selectors in m22tc_styles.scss
+$body-bg: map-get($o-color-palette, 'o-color-5'); // White for reports
+$body-color: #1A1A1A; // Dark text for readability
+
+// Navbar defaults (for standard Odoo navbar, our sidebar has its own styles)
+$navbar-light-color: #6B7280;
+$navbar-light-hover-color: map-get($o-color-palette, 'o-color-1');
+$navbar-light-active-color: map-get($o-color-palette, 'o-color-1');
+
+// Ensure these values propagate to Bootstrap
+$primary: map-get($o-color-palette, 'o-color-1');
+$secondary: map-get($o-color-palette, 'o-color-2');
+
+// Headings - Dark for reports, our theme overrides for specific areas
+$headings-color: #1A1A1A;

+ 72 - 0
views/customizations.xml

@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+    <!-- 
+        Global Layout Customization for M22 Tech Theme
+        Goal: Remove standard Header and Footer globally to create a pure "App-like" feel.
+        Also enforces Login for all public users.
+        IMPORTANT: Only applies when theme_m22tc is the active theme.
+    -->
+    <template id="layout_custom_m22" inherit_id="website.layout" name="M22 Global Layout" priority="50">
+        
+        <!-- Head Injections: Only when M22 theme is active -->
+        <xpath expr="//head" position="inside">
+            <t t-if="request.website.sudo().theme_id and request.website.sudo().theme_id.name == 'theme_m22tc'">
+                 <!-- 1. Google Fonts: Urbanist -->
+                 <link href="https://fonts.googleapis.com" rel="preconnect"/>
+                 <link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect"/>
+                 <link href="https://fonts.googleapis.com/css2?family=Urbanist:wght@400;600;700;800&amp;display=swap" rel="stylesheet"/>
+                 
+                 <!-- 2. Material Symbols -->
+                 <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet"/>
+                 
+                 <!-- 3. Tailwind CSS -->
+                 <script src="https://cdn.tailwindcss.com?plugins=forms,typography"></script>
+                 
+                 <!-- Tailwind Config: Disable preflight to prevent conflict with Odoo/Bootstrap -->
+                 <script>
+                    tailwind.config = {
+                        corePlugins: {
+                            preflight: false
+                        }
+                    }
+                </script>
+            </t>
+        </xpath>
+
+        <!-- Login enforcement script: Only when M22 theme is active -->
+        <xpath expr="//body" position="inside">
+            <t t-if="request.website.sudo().theme_id and request.website.sudo().theme_id.name == 'theme_m22tc'">
+                <t t-if="request.env.user._is_public() and request.httprequest.path not in ['/web/login', '/web/signup', '/web/reset_password']">
+                    <script type="text/javascript">
+                        if (!window.location.pathname.startsWith('/web/login')) {
+                            window.location.href = '/web/login?redirect=' + encodeURIComponent(window.location.href);
+                        }
+                    </script>
+                </t>
+            </t>
+        </xpath>
+
+        <!-- Remove Header: Only when M22 theme is active -->
+        <xpath expr="//header[@id='top']" position="attributes">
+            <attribute name="t-if">not (request.website.sudo().theme_id and request.website.sudo().theme_id.name == 'theme_m22tc')</attribute>
+        </xpath>
+
+        <!-- Remove Footer: Only when M22 theme is active -->
+        <xpath expr="//footer" position="attributes">
+            <attribute name="t-if">not (request.website.sudo().theme_id and request.website.sudo().theme_id.name == 'theme_m22tc')</attribute>
+        </xpath>
+        
+        <!-- Wrapwrap classes: Only when M22 theme is active -->
+        <xpath expr="//div[@id='wrapwrap']" position="attributes">
+            <attribute name="t-attf-class" add="#{request.website.sudo().theme_id and request.website.sudo().theme_id.name == 'theme_m22tc' and 'd-flex flex-column h-100' or ''}" separator=" "/>
+        </xpath>
+        
+        <!-- Main classes: Only when M22 theme is active -->
+        <xpath expr="//main" position="attributes">
+             <attribute name="t-attf-class" add="#{request.website.sudo().theme_id and request.website.sudo().theme_id.name == 'theme_m22tc' and 'flex-grow-1' or ''}" separator=" "/>
+        </xpath>
+
+    </template>
+
+</odoo>

+ 204 - 0
views/frontend_layout.xml

@@ -0,0 +1,204 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <data noupdate="0">
+        <!--
+            M22 Early Sidebar State Script
+            Inyecta un script en el head que aplica el estado del sidebar
+            ANTES de que la página se renderice, evitando el flash visual.
+        -->
+        <template id="m22_sidebar_early_init" inherit_id="web.layout" priority="1">
+            <xpath expr="//head" position="inside">
+                <t t-if="request.session.uid and request.website.sudo().theme_id and request.website.sudo().theme_id.name == 'theme_m22tc'">
+                    <script>
+                        // Apply sidebar collapsed state immediately before render
+                        (function() {
+                            try {
+                                var isCollapsed = localStorage.getItem('m22_sidebar_collapsed') === 'true';
+                                if (isCollapsed) {
+                                    document.documentElement.classList.add('m22-sidebar-collapsed-init');
+                                }
+                            } catch(e) {}
+                        })();
+                    </script>
+                </t>
+            </xpath>
+        </template>
+
+        <!--
+            M22 Authenticated Layout - Layout Primario para Usuarios Logueados
+            Estrategia: Crear un layout primario que herede de portal.frontend_layout
+            Este layout será la base para todas las páginas que requieran sidebar
+        -->
+        <record id="m22_authenticated_layout_view" model="ir.ui.view">
+            <field name="name">M22 Authenticated Layout</field>
+            <field name="key">theme_m22tc.m22_authenticated_layout_ir</field>
+            <field name="inherit_id" ref="portal.frontend_layout"/>
+            <field name="priority">1000</field>
+            <field name="arch" type="xml">
+                <!-- Solo aplicar cuando el usuario está logueado -->
+                <xpath expr="//div[@id='wrapwrap']" position="attributes">
+                    <attribute name="t-attf-class" add="#{request.session.uid and not request.website.is_public_user() and request.website.sudo().theme_id and request.website.sudo().theme_id.name == 'theme_m22tc' and 'o_has_m22_sidebar' or ''}" separator=" "/>
+                </xpath>
+
+                <!-- Inyectar el sidebar ANTES del main si el usuario está logueado -->
+                <xpath expr="//div[@id='wrapwrap']/main" position="before">
+                    <t t-if="request.session.uid and not request.website.is_public_user() and request.website.sudo().theme_id and request.website.sudo().theme_id.name == 'theme_m22tc'">
+                        <!-- Get Sidebar Menu Items -->
+                        <t t-set="sidebar_menu_root" t-value="request.env.ref('theme_m22tc.menu_portal_sidebar', raise_if_not_found=False)"/>
+                        <t t-set="sidebar_items"
+                           t-value="sidebar_menu_root.child_id.filtered(lambda m: not m.website_id or m.website_id.id == request.website.id) if (sidebar_menu_root and request.website) else []"/>
+                        <t t-set="current_path" t-value="request.httprequest.path"/>
+
+                        <!-- SIDEBAR - Fixed Full Height -->
+                        <aside id="m22_sidebar" class="m22-sidebar d-flex flex-column" data-collapsed="false">
+
+                            <!-- Sidebar Header with Logo -->
+                            <div class="sidebar-header px-3 py-3">
+                                <div class="d-flex align-items-center justify-content-between w-100">
+                                    <a href="/my/home" class="sidebar-logo d-flex align-items-center text-decoration-none">
+                                        <img t-att-src="request.website.image_url(request.website, 'logo')" 
+                                             alt="Logo" 
+                                             class="sidebar-logo-img"
+                                             style="max-height: 28px; width: auto; filter: brightness(0) invert(1);"/>
+                                    </a>
+                                    <button class="sidebar-collapse-btn" type="button" title="Colapsar menú">
+                                        <i class="fa fa-chevron-left"></i>
+                                    </button>
+                                </div>
+                            </div>
+
+                            <!-- Sidebar Navigation -->
+                            <div class="sidebar-nav flex-grow-1">
+                                <ul class="nav nav-pills flex-column m-0 p-0">
+                                    <t t-foreach="sidebar_items" t-as="menu">
+                                        <li class="nav-item">
+                                            <t t-set="is_active" t-value="(menu.url == '/my/home' and current_path == '/my/home') or (menu.url != '/my/home' and menu.url and menu.url in current_path)"/>
+
+                                            <a t-att-href="menu.url"
+                                               t-att-class="'nav-link %s' % ('active' if is_active else '')"
+                                               t-att-title="menu.name">
+                                                <i t-if="menu.m22_icon_class"
+                                                   t-attf-class="fa #{menu.m22_icon_class} sidebar-icon"></i>
+                                                <span class="sidebar-text" t-esc="menu.name"/>
+                                            </a>
+                                        </li>
+                                    </t>
+                                </ul>
+                            </div>
+
+                            <!-- Sidebar Footer (User Info) -->
+                            <div class="sidebar-footer">
+                                <a href="/my/account" class="sidebar-user-info text-decoration-none" title="Mi Cuenta">
+                                    <div class="user-avatar">
+                                        <i class="fa fa-user"></i>
+                                    </div>
+                                    <div class="sidebar-text ms-3 d-flex flex-column overflow-hidden">
+                                        <span class="fw-semibold text-white text-truncate" t-esc="request.env.user.name"/>
+                                        <span class="small text-white-50">Mi Cuenta</span>
+                                    </div>
+                                </a>
+                                <a href="/web/session/logout?redirect=/web/login" class="sidebar-logout" title="Cerrar Sesión">
+                                    <i class="fa fa-sign-out"></i>
+                                    <span class="sidebar-text ms-2">Cerrar Sesión</span>
+                                </a>
+                            </div>
+                        </aside>
+
+                        <!-- Sidebar Backdrop (Mobile) -->
+                        <div id="m22_sidebar_backdrop" class="m22-sidebar-backdrop d-lg-none"></div>
+
+                        <!-- Mobile Bottom Navigation -->
+                        <nav class="m22-bottom-nav d-lg-none fixed-bottom">
+                            <div class="d-flex justify-content-around align-items-center">
+                                <!-- Primary items: First 3 items -->
+                                <t t-set="primary_items" t-value="sidebar_items[:3] if len(sidebar_items) >= 3 else sidebar_items"/>
+                                <t t-foreach="primary_items" t-as="menu">
+                                    <a t-att-href="menu.url" class="bottom-nav-item d-flex flex-column align-items-center text-decoration-none">
+                                        <i t-if="menu.m22_icon_class" t-attf-class="fa #{menu.m22_icon_class}"></i>
+                                        <span class="bottom-nav-label" t-esc="menu.name"/>
+                                    </a>
+                                </t>
+                                
+                                <!-- Account: Always visible -->
+                                <a href="/my/account" class="bottom-nav-item d-flex flex-column align-items-center text-decoration-none">
+                                    <i class="fa fa-user"></i>
+                                    <span class="bottom-nav-label">Cuenta</span>
+                                </a>
+                                
+                                <!-- More Button: Opens bottom sheet - Always last -->
+                                <t t-if="len(sidebar_items) > 3">
+                                    <button type="button" 
+                                            class="bottom-nav-item d-flex flex-column align-items-center text-decoration-none border-0 bg-transparent"
+                                            id="m22_more_btn"
+                                            aria-label="Más opciones">
+                                        <i class="fa fa-ellipsis-h"></i>
+                                        <span class="bottom-nav-label">Más</span>
+                                    </button>
+                                </t>
+                            </div>
+                        </nav>
+                        
+                        <!-- Bottom Sheet: iOS Style with Tailwind -->
+                        <div id="m22_bottom_sheet" 
+                             class="m22-bottom-sheet-container hidden"
+                             style="display: none; position: fixed; inset: 0; z-index: 1050; pointer-events: none;"
+                             role="dialog"
+                             aria-modal="true"
+                             aria-labelledby="bottom-sheet-title">
+                            <!-- Backdrop -->
+                            <div id="m22_bottom_sheet_backdrop" 
+                                 class="m22-bottom-sheet-backdrop"
+                                 style="position: absolute; inset: 0; opacity: 0; transition: opacity 0.3s ease;"></div>
+                            
+                            <!-- Bottom Sheet Container -->
+                            <div id="m22_bottom_sheet_content" 
+                                 class="m22-bottom-sheet-content"
+                                 style="position: absolute; bottom: 0; left: 0; right: 0; transform: translateY(100%); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); max-height: 85vh; display: flex; flex-direction: column; pointer-events: auto;">
+                                
+                                <!-- Handle Indicator -->
+                                <div class="flex justify-center pt-3 pb-2">
+                                    <div class="w-12 h-1 bg-gray-300 dark:bg-gray-600 rounded-full"></div>
+                                </div>
+                                
+                                <!-- Header -->
+                                <div class="px-6 pb-4 border-b border-gray-200 dark:border-gray-700">
+                                    <h3 id="bottom-sheet-title" class="text-lg font-semibold text-gray-900 dark:text-white">Más opciones</h3>
+                                </div>
+                                
+                                <!-- Content: Additional menu items -->
+                                <div class="flex-1 overflow-y-auto px-4 py-4">
+                                    <div class="space-y-1">
+                                        <t t-set="additional_items" t-value="sidebar_items[3:] if len(sidebar_items) > 3 else []"/>
+                                        <t t-foreach="additional_items" t-as="menu">
+                                            <a t-att-href="menu.url" 
+                                               class="m22-sheet-item flex items-center gap-3 px-4 py-3 rounded-xl text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150 text-decoration-none">
+                                                <i t-if="menu.m22_icon_class" 
+                                                   t-attf-class="fa #{menu.m22_icon_class} text-xl text-gray-600 dark:text-gray-400 w-6 text-center"></i>
+                                                <span class="flex-1 font-medium" t-esc="menu.name"/>
+                                                <i class="fa fa-chevron-right text-gray-400 text-sm"></i>
+                                            </a>
+                                        </t>
+                                    </div>
+                                </div>
+                                
+                                <!-- Close Button -->
+                                <div class="px-4 pb-6 pt-2 border-t border-gray-200 dark:border-gray-700">
+                                    <button type="button" 
+                                            id="m22_bottom_sheet_close"
+                                            class="w-full py-3 px-4 bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white rounded-xl font-medium hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors duration-150">
+                                        Cerrar
+                                    </button>
+                                </div>
+                            </div>
+                        </div>
+                    </t>
+                </xpath>
+
+                <!-- Agregar clases al main para compensar el sidebar -->
+                <xpath expr="//div[@id='wrapwrap']/main" position="attributes">
+                    <attribute name="t-attf-class" add="#{request.website.sudo().theme_id and request.website.sudo().theme_id.name == 'theme_m22tc' and not request.env.user._is_public() and 'o_main_with_sidebar' or ''}" separator=" "/>
+                </xpath>
+            </field>
+        </record>
+    </data>
+</odoo>

+ 312 - 0
views/helpdesk_dashboard.xml

@@ -0,0 +1,312 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <data>
+        <!-- Dashboard Template for Tickets -->
+        <template id="portal_helpdesk_ticket_dashboard" name="Tickets Dashboard">
+            <t t-call="portal.portal_layout">
+                <t t-set="breadcrumbs_searchbar" t-value="True"/>
+
+                <t t-call="portal.portal_searchbar">
+                    <t t-set="title">Tickets</t>
+                </t>
+
+                <div class="container-fluid py-4">
+                    <!-- Dashboard Header -->
+                    <div class="row mb-4">
+                        <div class="col-12">
+                            <h1 class="h2 mb-2">Dashboard de Tickets</h1>
+                            <p class="text-muted">Resumen de tus tickets y métricas</p>
+                        </div>
+                    </div>
+
+                    <!-- Main Metrics Cards (4 cards) -->
+                    <div class="row g-4 mb-4">
+                        <!-- Card 1: Tiempo Usado/Disponible -->
+                        <div class="col-12 col-md-6 col-lg-3">
+                            <div class="card m22-dashboard-card h-100">
+                                <div class="card-body">
+                                    <div class="d-flex align-items-center justify-content-between mb-3">
+                                        <div>
+                                            <h6 class="text-muted mb-1 small text-uppercase">Tiempo</h6>
+                                            <h3 class="mb-0">
+                                                <span t-esc="round(dashboard_data['time_stats']['used'], 1)"/>h
+                                                <small class="text-muted">/ <span t-esc="round(dashboard_data['time_stats']['available'], 1)"/>h</small>
+                                            </h3>
+                                        </div>
+                                        <div class="m22-dashboard-icon">
+                                            <i class="fa fa-clock-o fa-2x" style="color: rgba(255, 124, 0, 0.3);"></i>
+                                        </div>
+                                    </div>
+                                    <div class="progress" style="height: 8px; border-radius: 4px;">
+                                        <div class="progress-bar" 
+                                             role="progressbar" 
+                                             t-attf-style="width: #{dashboard_data['time_stats']['percentage']}%; background: linear-gradient(90deg, #FF7C00, #E0407B);"
+                                             t-attf-aria-valuenow="#{dashboard_data['time_stats']['percentage']}"
+                                             aria-valuemin="0"
+                                             aria-valuemax="100">
+                                        </div>
+                                    </div>
+                                    <div class="mt-2">
+                                        <small class="text-muted">
+                                            <span t-esc="round(dashboard_data['time_stats']['percentage'], 1)"/>% utilizado
+                                        </small>
+                                    </div>
+                                    <!-- Desglose: Prepago y Crédito -->
+                                    <t t-if="dashboard_data['time_stats']['prepaid_hours'] > 0 or dashboard_data['time_stats']['credit_hours'] > 0 or dashboard_data['time_stats']['credit_available'] > 0">
+                                        <div class="mt-3 pt-2 border-top">
+                                            <t t-if="dashboard_data['time_stats']['prepaid_hours'] > 0">
+                                                <div class="d-flex justify-content-between small mb-1">
+                                                    <span class="text-muted">Prepago:</span>
+                                                    <span class="fw-semibold"><span t-esc="round(dashboard_data['time_stats']['prepaid_hours'], 1)"/>h</span>
+                                                </div>
+                                            </t>
+                                            <t t-if="dashboard_data['time_stats']['credit_hours'] > 0">
+                                                <div class="d-flex justify-content-between small mb-1">
+                                                    <span class="text-muted">Crédito:</span>
+                                                    <span class="fw-semibold"><span t-esc="round(dashboard_data['time_stats']['credit_hours'], 1)"/>h</span>
+                                                </div>
+                                            </t>
+                                            <t t-if="dashboard_data['time_stats']['credit_available'] > 0">
+                                                <div class="d-flex justify-content-between small">
+                                                    <span class="text-muted">Crédito disponible:</span>
+                                                    <span class="fw-semibold">
+                                                        <span t-esc="request.env.company.currency_id.symbol"/>
+                                                        <span t-esc="round(dashboard_data['time_stats']['credit_available'], 2)"/>
+                                                    </span>
+                                                </div>
+                                            </t>
+                                        </div>
+                                    </t>
+                                </div>
+                            </div>
+                        </div>
+
+                        <!-- Card 2: Total Tickets -->
+                        <div class="col-12 col-md-6 col-lg-3">
+                            <div class="card m22-dashboard-card h-100">
+                                <div class="card-body">
+                                    <div class="d-flex align-items-center justify-content-between mb-3">
+                                        <div>
+                                            <h6 class="text-muted mb-1 small text-uppercase">Tickets</h6>
+                                            <h3 class="mb-0">
+                                                <span t-esc="dashboard_data['ticket_summary']['total']"/>
+                                            </h3>
+                                            <small class="text-muted">
+                                                <span t-esc="dashboard_data['ticket_summary']['open']"/> abiertos
+                                            </small>
+                                        </div>
+                                        <div class="m22-dashboard-icon">
+                                            <i class="fa fa-ticket fa-2x" style="color: rgba(255, 124, 0, 0.3);"></i>
+                                        </div>
+                                    </div>
+                                    <div class="mt-3">
+                                        <div class="d-flex justify-content-between small">
+                                            <span class="text-muted">Abiertos:</span>
+                                            <span class="fw-semibold" t-esc="dashboard_data['ticket_summary']['open']"/>
+                                        </div>
+                                        <div class="d-flex justify-content-between small mt-1">
+                                            <span class="text-muted">Cerrados:</span>
+                                            <span class="fw-semibold" t-esc="dashboard_data['ticket_summary']['closed']"/>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+
+                        <!-- Card 3: SLA Compliance -->
+                        <div class="col-12 col-md-6 col-lg-3">
+                            <div class="card m22-dashboard-card h-100">
+                                <div class="card-body">
+                                    <div class="d-flex align-items-center justify-content-between mb-3">
+                                        <div>
+                                            <h6 class="text-muted mb-1 small text-uppercase">Cumplimiento SLA</h6>
+                                            <h3 class="mb-0">
+                                                <span t-esc="round(dashboard_data['sla_compliance']['percentage'], 1)"/>%
+                                            </h3>
+                                        </div>
+                                        <div class="m22-dashboard-icon">
+                                            <i class="fa fa-check-circle fa-2x" 
+                                               t-attf-style="color: rgba(#{34 if dashboard_data['sla_compliance']['is_good'] else 239}, #{197 if dashboard_data['sla_compliance']['is_good'] else 68}, #{94 if dashboard_data['sla_compliance']['is_good'] else 68}, 0.3);">
+                                            </i>
+                                        </div>
+                                    </div>
+                                    <div class="mt-3">
+                                        <div class="d-flex justify-content-between small">
+                                            <span class="text-muted">Exitosos:</span>
+                                            <span class="fw-semibold text-success" t-esc="dashboard_data['sla_compliance']['success']"/>
+                                        </div>
+                                        <div class="d-flex justify-content-between small mt-1">
+                                            <span class="text-muted">Fallidos:</span>
+                                            <span class="fw-semibold text-danger" t-esc="dashboard_data['sla_compliance']['failed']"/>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+
+                        <!-- Card 4: Esperando Respuesta -->
+                        <div class="col-12 col-md-6 col-lg-3">
+                            <div class="card m22-dashboard-card h-100">
+                                <div class="card-body">
+                                    <div class="d-flex align-items-center justify-content-between mb-3">
+                                        <div>
+                                            <h6 class="text-muted mb-1 small text-uppercase">Esperando Respuesta</h6>
+                                            <h3 class="mb-0">
+                                                <span t-esc="dashboard_data['waiting_response']['count']"/>
+                                            </h3>
+                                        </div>
+                                        <div class="m22-dashboard-icon">
+                                            <i class="fa fa-exclamation-circle fa-2x" style="color: rgba(234, 179, 8, 0.3);"></i>
+                                        </div>
+                                    </div>
+                                    <div class="mt-3">
+                                        <a href="/my/tickets?filterby=open" class="btn btn-sm btn-outline-primary">
+                                            Ver tickets
+                                        </a>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+
+                    <!-- Summary by Stage -->
+                    <div class="row g-4 mb-4">
+                        <div class="col-12">
+                            <div class="card m22-dashboard-card">
+                                <div class="card-body">
+                                    <h5 class="card-title mb-4">Resumen por Etapa</h5>
+                                    <div class="row g-3">
+                                        <t t-foreach="dashboard_data['ticket_summary']['by_stage']" t-as="stage_item">
+                                            <div class="col-6 col-md-4 col-lg-3">
+                                                <div class="m22-stage-card p-3 rounded">
+                                                    <div class="fw-semibold mb-1" t-esc="stage_item[0]"/>
+                                                    <div class="h4 mb-0" style="color: #FF7C00;" t-esc="stage_item[1]"/>
+                                                </div>
+                                            </div>
+                                        </t>
+                                        <t t-if="not dashboard_data['ticket_summary']['by_stage'] or len(dashboard_data['ticket_summary']['by_stage']) == 0">
+                                            <div class="col-12">
+                                                <p class="text-muted mb-0">No hay tickets abiertos</p>
+                                            </div>
+                                        </t>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+
+                    <!-- Recent Tickets -->
+                    <div class="row g-4 mb-4">
+                        <div class="col-12">
+                            <div class="card m22-dashboard-card">
+                                <div class="card-body">
+                                    <div class="d-flex justify-content-between align-items-center mb-4">
+                                        <h5 class="card-title mb-0">Tickets Recientes</h5>
+                                        <a href="/my/tickets" class="btn btn-primary">
+                                            Ver todos los tickets
+                                        </a>
+                                    </div>
+                                    <t t-if="dashboard_data['recent_tickets']">
+                                        <div class="table-responsive">
+                                            <table class="table table-hover">
+                                                <thead>
+                                                    <tr>
+                                                        <th>Ticket</th>
+                                                        <th>Asunto</th>
+                                                        <th>Etapa</th>
+                                                        <th>Prioridad</th>
+                                                        <th>Fecha</th>
+                                                    </tr>
+                                                </thead>
+                                                <tbody>
+                                                    <t t-foreach="dashboard_data['recent_tickets']" t-as="ticket">
+                                                        <tr>
+                                                            <td>
+                                                                <a t-attf-href="/helpdesk/ticket/#{ticket.id}">
+                                                                    #<span t-esc="ticket.ticket_ref"/>
+                                                                </a>
+                                                            </td>
+                                                            <td>
+                                                                <a t-attf-href="/helpdesk/ticket/#{ticket.id}">
+                                                                    <span t-esc="ticket.name"/>
+                                                                </a>
+                                                            </td>
+                                                            <td>
+                                                                <span t-attf-class="badge rounded-pill #{'text-bg-success' if ticket.stage_id.fold else 'text-bg-primary'}" 
+                                                                      t-esc="ticket.stage_id.name"/>
+                                                            </td>
+                                                            <td>
+                                                                <t t-if="ticket.priority == '3'">
+                                                                    <span class="badge bg-danger">Urgente</span>
+                                                                </t>
+                                                                <t t-elif="ticket.priority == '2'">
+                                                                    <span class="badge bg-warning">Alta</span>
+                                                                </t>
+                                                                <t t-elif="ticket.priority == '1'">
+                                                                    <span class="badge bg-info">Media</span>
+                                                                </t>
+                                                                <t t-else="">
+                                                                    <span class="badge bg-secondary">Baja</span>
+                                                                </t>
+                                                            </td>
+                                                            <td>
+                                                                <span t-field="ticket.create_date" t-options='{"widget": "datetime", "hide_seconds": True}'/>
+                                                            </td>
+                                                        </tr>
+                                                    </t>
+                                                </tbody>
+                                            </table>
+                                        </div>
+                                    </t>
+                                    <t t-else="">
+                                        <p class="text-muted mb-0">No hay tickets recientes</p>
+                                    </t>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+
+                    <!-- Waiting Response Tickets (if any) -->
+                    <t t-if="dashboard_data['waiting_response']['count'] > 0">
+                        <div class="row g-4 mb-4">
+                            <div class="col-12">
+                                <div class="card m22-dashboard-card border-warning">
+                                    <div class="card-body">
+                                        <div class="d-flex justify-content-between align-items-center mb-3">
+                                            <h5 class="card-title mb-0">
+                                                <i class="fa fa-exclamation-circle text-warning me-2"></i>
+                                                Tickets Esperando tu Respuesta
+                                            </h5>
+                                            <span class="badge bg-warning" t-esc="dashboard_data['waiting_response']['count']"/>
+                                        </div>
+                                        <div class="list-group">
+                                            <t t-foreach="dashboard_data['waiting_response']['tickets']" t-as="ticket">
+                                                <a t-attf-href="/helpdesk/ticket/#{ticket.id}" 
+                                                   class="list-group-item list-group-item-action">
+                                                    <div class="d-flex w-100 justify-content-between">
+                                                        <h6 class="mb-1">
+                                                            #<span t-esc="ticket.ticket_ref"/> - <span t-esc="ticket.name"/>
+                                                        </h6>
+                                                        <small>
+                                                            <span t-field="ticket.create_date" t-options='{"widget": "datetime", "hide_seconds": True}'/>
+                                                        </small>
+                                                    </div>
+                                                    <p class="mb-1 text-muted small">
+                                                        <span t-esc="ticket.stage_id.name"/>
+                                                        <t t-if="ticket.user_id">
+                                                            - Asignado a: <span t-esc="ticket.user_id.name"/>
+                                                        </t>
+                                                    </p>
+                                                </a>
+                                            </t>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                    </t>
+                </div>
+            </t>
+        </template>
+    </data>
+</odoo>

+ 53 - 0
views/helpdesk_portal_approval.xml

@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <data>
+        <!-- Add approval/rejection buttons when ticket is in excluded stage -->
+        <template id="portal_helpdesk_ticket_approval_buttons" inherit_id="helpdesk.tickets_followup" name="Ticket Approval Buttons">
+            <!-- Add banner when ticket is waiting for approval - after ticket_closed alert -->
+            <xpath expr="//div[@id='ticket_content']/div[@t-if='ticket_closed']" position="after">
+                <div t-if="ticket.stage_id in ticket.sla_ids.mapped('exclude_stage_ids')" 
+                     class="alert alert-info mb-4 m22-approval-banner">
+                    <div class="d-flex align-items-center justify-content-between">
+                        <div class="flex-grow-1">
+                            <h5 class="alert-heading mb-2">
+                                <i class="fa fa-clock-o me-2"></i>
+                                Este ticket está esperando tu respuesta
+                            </h5>
+                            <p class="mb-0">
+                                Por favor, revisa la solución propuesta y confirma si es satisfactoria o si necesitas más ayuda.
+                            </p>
+                        </div>
+                    </div>
+                    <div class="mt-3 d-flex gap-2 flex-wrap">
+                        <a t-attf-href="/my/ticket/approve/#{ticket.id}/#{ticket.access_token or ''}" 
+                           class="btn btn-success btn-lg">
+                            <i class="fa fa-check me-2"></i>
+                            Aprobar Solución
+                        </a>
+                        <a t-attf-href="/my/ticket/reject/#{ticket.id}/#{ticket.access_token or ''}" 
+                           class="btn btn-warning btn-lg">
+                            <i class="fa fa-times me-2"></i>
+                            Rechazar / Necesito más ayuda
+                        </a>
+                    </div>
+                </div>
+            </xpath>
+            
+            <!-- Add success message when ticket is approved -->
+            <xpath expr="//div[@t-if='ticket_closed']" position="after">
+                <div t-if="request.params.get('ticket_approved')" class="alert alert-success alert-dismissible d-print-none" role="status">
+                    <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+                    <span>¡Gracias! Has aprobado la solución. El ticket ha sido actualizado.</span>
+                </div>
+            </xpath>
+            
+            <!-- Add warning message when ticket is rejected -->
+            <xpath expr="//div[@t-if='ticket_closed']" position="after">
+                <div t-if="request.params.get('ticket_rejected')" class="alert alert-warning alert-dismissible d-print-none" role="status">
+                    <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+                    <span>Hemos recibido tu solicitud. El equipo continuará trabajando en tu ticket.</span>
+                </div>
+            </xpath>
+        </template>
+    </data>
+</odoo>

+ 469 - 0
views/login_custom.xml

@@ -0,0 +1,469 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <data>
+
+        <!-- 1. Configuración de Tailwind (Inyectar en el Head) - Solo para tema M22 -->
+        <template id="m22_tailwind_config" inherit_id="web.layout" name="M22 Tailwind Config">
+            <xpath expr="//head" position="inside">
+                <t t-if="request.website and request.website.sudo().theme_id and request.website.sudo().theme_id.name == 'theme_m22tc'">
+                    <!-- Google Fonts (Inter) -->
+                    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;display=swap" rel="stylesheet"/>
+                    
+                    <!-- Tailwind CSS CDN -->
+                    <script src="https://cdn.tailwindcss.com"></script>
+                    
+                    <!-- Tailwind Config Script -->
+                    <script>
+                        tailwind.config = {
+                          darkMode: "class",
+                          theme: {
+                            extend: {
+                              colors: {
+                                primary: {
+                                  DEFAULT: '#FF6B00',
+                                  '400': '#FFA756'
+                                },
+                                secondary: '#E1467C',
+                                "background-dark": "#0A051D",
+                              },
+                              fontFamily: {
+                                sans: ["Inter", "sans-serif"],
+                              },
+                              borderRadius: {
+                                DEFAULT: "1rem",
+                                lg: "1.25rem",
+                              },
+                            },
+                          },
+                        };
+                    </script>
+                    
+                    <!-- Custom Styles -->
+                    <style>
+                        .focus-gradient-border:focus {
+                            outline: none;
+                            box-shadow: 0 0 0 2px #0A051D, 0 0 0 4px #FF6B00;
+                        }
+                        .focus-gradient-border-alt:focus {
+                            outline: none;
+                            border-color: transparent;
+                            background-image: linear-gradient(#0A051D, #0A051D), linear-gradient(to right, #FFA756, #E1467C);
+                            background-origin: border-box;
+                            background-clip: padding-box, border-box;
+                        }
+                        body.m22-login-page {
+                            background-color: #0A051D !important;
+                            background-image:
+                                radial-gradient(ellipse 80% 50% at 10% 10%, rgba(255, 107, 0, 0.15), transparent),
+                                radial-gradient(ellipse 80% 50% at 90% 90%, rgba(225, 70, 124, 0.15), transparent);
+                            min-height: 100vh;
+                        }
+                    </style>
+                </t>
+            </xpath>
+        </template>
+
+        <!-- 2. Personalizar el Layout del Login (Estructura General) - Solo para tema M22 -->
+        <template id="m22_login_layout_override" inherit_id="website.login_layout" name="M22 Login Layout Override" priority="50">
+            <!-- Configurar clase del body pasando la variable al layout - Solo para M22 -->
+            <xpath expr="//div[hasclass('oe_website_login_container')]" position="before">
+                <t t-if="request.website and request.website.sudo().theme_id and request.website.sudo().theme_id.name == 'theme_m22tc'">
+                    <t t-set="body_classname" t-value="'m22-login-page font-sans text-gray-300 antialiased'"/>
+                </t>
+            </xpath>
+
+            <!-- Reemplazar el contenedor de Website - Solo para M22 -->
+            <xpath expr="//div[hasclass('oe_website_login_container')]" position="replace">
+                <t t-if="request.website and request.website.sudo().theme_id and request.website.sudo().theme_id.name == 'theme_m22tc'">
+                    <div class="min-h-screen w-full flex items-center justify-center p-6">
+                        <main class="w-full max-w-md">
+                            <!-- Aquí se inyectará el contenido del formulario (template 'web.login') -->
+                            <t t-out="0"/>
+                        </main>
+                    </div>
+                </t>
+                <t t-else="">
+                    <!-- Contenedor original para otros temas -->
+                    <div class="container oe_website_login_container">
+                        <t t-out="0"/>
+                    </div>
+                </t>
+            </xpath>
+        </template>
+
+        <!-- 3. Personalizar el Formulario de Login (Card y Inputs) - Solo para tema M22 -->
+        <template id="m22_login_form_override" inherit_id="web.login" name="M22 Login Form Override" priority="100">
+            <xpath expr="//t[@t-call='web.login_layout']" position="replace">
+                <t t-if="request.website and request.website.sudo().theme_id and request.website.sudo().theme_id.name == 'theme_m22tc'">
+                    <t t-call="web.login_layout">
+                        <!-- Glassmorphism Card -->
+                        <div class="bg-gray-900/40 backdrop-blur-lg p-8 md:p-12 rounded-xl shadow-2xl border border-white/10">
+                            
+                            <!-- Header &amp; Logo -->
+                            <div class="text-center mb-10">
+                                <!-- Logo del Sitio Web -->
+                                <div class="flex items-center justify-center mb-6">
+                                    <img t-att-src="request.website.image_url(request.website, 'logo')" 
+                                         alt="Logo" 
+                                         style="max-height: 80px; max-width: 100%; width: auto;"
+                                         class="object-contain"/>
+                                </div>
+
+                                <h1 class="text-3xl font-bold text-white">Iniciar Sesión</h1>
+                                <p class="text-gray-400 mt-2">Accede a tu cuenta de cliente</p>
+                            </div>
+
+                            <!-- Login Form -->
+                            <form class="space-y-6" role="form" t-attf-action="/web/login" method="post" onsubmit="this.action = '/web/login' + location.hash">
+                                <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
+                                <input type="hidden" name="redirect" t-att-value="redirect"/>
+
+                                <!-- Email Input -->
+                                <div>
+                                    <label class="block text-sm font-medium text-gray-300 mb-2" for="login">Correo Electrónico</label>
+                                    <input class="w-full bg-gray-800/50 border border-gray-700 text-white placeholder-gray-500 px-4 py-3 rounded-md focus:outline-none focus:border-orange-500 transition-all duration-300" 
+                                           id="login" name="login" t-att-value="login" 
+                                           placeholder="tu@email.com" required="required" type="text" autofocus="autofocus" autocapitalize="off"/>
+                                </div>
+
+                                <!-- Password Input -->
+                                <div>
+                                    <div class="flex items-center justify-between mb-2">
+                                        <label class="block text-sm font-medium text-gray-300" for="password">Contraseña</label>
+                                        <a t-if="reset_password_enabled" class="text-sm font-medium text-orange-500 hover:text-orange-400 transition-colors" t-attf-href="/web/reset_password?{{ keep_query() }}">¿Olvidaste tu contraseña?</a>
+                                    </div>
+                                    <input class="w-full bg-gray-800/50 border border-gray-700 text-white placeholder-gray-500 px-4 py-3 rounded-md focus:outline-none focus:border-orange-500 transition-all duration-300" 
+                                           id="password" name="password" 
+                                           placeholder="••••••••" required="required" type="password" autocomplete="current-password" t-att-autofocus="'autofocus' if login else None"/>
+                                </div>
+
+                                <!-- Error Messages -->
+                                <p class="text-sm text-red-500 mt-2 text-center" t-if="error" role="alert">
+                                    <t t-esc="error"/>
+                                </p>
+                                <p class="text-sm text-green-500 mt-2 text-center" t-if="message" role="status">
+                                    <t t-esc="message"/>
+                                </p>
+
+                                <!-- Submit Button -->
+                                <div>
+                                    <button style="background: linear-gradient(90deg, #FF6B00, #E1467C) !important; border: 0 !important; outline: 0 !important; box-shadow: none !important;" class="w-full text-white font-bold py-3 px-4 rounded-md hover:opacity-90 transition-all transform hover:scale-105 focus:outline-none border-0" type="submit">
+                                        Iniciar Sesión
+                                    </button>
+                                </div>
+                            </form>
+
+                            <!-- Footer (Signup) - Condicional como Odoo nativo -->
+                            <div class="text-center mt-8" t-if="signup_enabled">
+                                <p class="text-sm text-gray-400">
+                                    ¿No tienes una cuenta? <a class="font-medium text-orange-500 hover:text-orange-400 transition-colors" t-attf-href="/web/signup?{{ keep_query() }}">Crear una cuenta</a>
+                                </p>
+                            </div>
+                        </div>
+                    </t>
+                </t>
+                <t t-else="">
+                    <!-- Formulario original para otros temas -->
+                    <t t-call="web.login_layout">
+                        <form class="oe_login_form" role="form" t-attf-action="/web/login" method="post" onsubmit="this.action = '/web/login' + location.hash">
+                            <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
+
+                            <div class="mb-3 field-login">
+                                <label for="login">Email</label>
+                                <input type="text" placeholder="Email" name="login" t-att-value="login" id="login" required="required" autofocus="autofocus" autocapitalize="off" class="form-control"/>
+                            </div>
+
+                            <div class="mb-3 field-password">
+                                <label for="password">Password</label>
+                                <input type="password" placeholder="Password" name="password" id="password" required="required" autocomplete="current-password" t-att-autofocus="'autofocus' if login else None" class="form-control"/>
+                            </div>
+
+                            <p class="alert alert-danger" t-if="error" role="alert">
+                                <t t-esc="error"/>
+                            </p>
+                            <p class="alert alert-success" t-if="message" role="status">
+                                <t t-esc="message"/>
+                            </p>
+
+                            <input type="hidden" name="redirect" t-att-value="redirect"/>
+                            <div class="clearfix oe_login_buttons text-center mb-1 pt-3 d-grid">
+                                <button type="submit" class="btn btn-primary">Log in</button>
+                                <div class="o_login_auth"/>
+                            </div>
+                        </form>
+                    </t>
+                </t>
+            </xpath>
+        </template>
+
+        <!-- 4. Personalizar el Formulario de Registro - Solo para tema M22 -->
+        <template id="m22_signup_form_override" inherit_id="auth_signup.signup" name="M22 Signup Form Override" priority="100">
+            <xpath expr="//t[@t-call='web.login_layout']" position="replace">
+                <t t-if="request.website and request.website.sudo().theme_id and request.website.sudo().theme_id.name == 'theme_m22tc'">
+                    <t t-call="web.login_layout">
+                        <!-- Glassmorphism Card -->
+                        <div class="bg-gray-900/40 backdrop-blur-lg p-8 md:p-12 rounded-xl shadow-2xl border border-white/10">
+                            
+                            <!-- Header &amp; Logo -->
+                            <div class="text-center mb-10">
+                                <div class="flex items-center justify-center mb-6">
+                                    <img t-att-src="request.website.image_url(request.website, 'logo')" 
+                                         alt="Logo" 
+                                         style="max-height: 80px; max-width: 100%; width: auto;"
+                                         class="object-contain"/>
+                                </div>
+                                <h1 class="text-3xl font-bold text-white">Crear Cuenta</h1>
+                                <p class="text-gray-400 mt-2">Regístrate para acceder</p>
+                            </div>
+
+                            <!-- Signup Form -->
+                            <form class="oe_signup_form space-y-5" role="form" method="post" t-if="not message">
+                                <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
+                                <input type="hidden" name="redirect" t-att-value="redirect"/>
+                                <input type="hidden" name="token" t-att-value="token"/>
+
+                                <!-- Name Input (solo si no hay token) -->
+                                <div t-if="not (token and not invalid_token)">
+                                    <label class="block text-sm font-medium text-gray-300 mb-2" for="name">Tu Nombre</label>
+                                    <input class="w-full bg-gray-800/50 border border-gray-700 text-white placeholder-gray-500 px-4 py-3 rounded-md focus:outline-none focus:border-orange-500 transition-all duration-300" 
+                                           id="name" name="name" t-att-value="name" 
+                                           placeholder="Ej. Juan Pérez" required="required" type="text"
+                                           t-att-readonly="'readonly' if (token and not invalid_token) else None"
+                                           t-att-autofocus="'autofocus' if login and not (token and not invalid_token) else None"/>
+                                </div>
+
+                                <!-- Email Input (solo si no hay token) -->
+                                <div t-if="not (token and not invalid_token)">
+                                    <label class="block text-sm font-medium text-gray-300 mb-2" for="login">Correo Electrónico</label>
+                                    <input class="w-full bg-gray-800/50 border border-gray-700 text-white placeholder-gray-500 px-4 py-3 rounded-md focus:outline-none focus:border-orange-500 transition-all duration-300" 
+                                           id="login" name="login" t-att-value="login" 
+                                           placeholder="tu@email.com" required="required" type="text" 
+                                           autofocus="autofocus" autocapitalize="off"
+                                           t-att-readonly="'readonly' if (token and not invalid_token) else None"/>
+                                </div>
+
+                                <!-- Password Input -->
+                                <div>
+                                    <label class="block text-sm font-medium text-gray-300 mb-2" for="password">Contraseña</label>
+                                    <input class="w-full bg-gray-800/50 border border-gray-700 text-white placeholder-gray-500 px-4 py-3 rounded-md focus:outline-none focus:border-orange-500 transition-all duration-300" 
+                                           id="password" name="password" 
+                                           placeholder="••••••••" required="required" type="password"
+                                           t-att-autofocus="'autofocus' if (token and not invalid_token) else None"/>
+                                </div>
+
+                                <!-- Confirm Password Input -->
+                                <div>
+                                    <label class="block text-sm font-medium text-gray-300 mb-2" for="confirm_password">Confirmar Contraseña</label>
+                                    <input class="w-full bg-gray-800/50 border border-gray-700 text-white placeholder-gray-500 px-4 py-3 rounded-md focus:outline-none focus:border-orange-500 transition-all duration-300" 
+                                           id="confirm_password" name="confirm_password" 
+                                           placeholder="••••••••" required="required" type="password"/>
+                                </div>
+
+                                <!-- Error Messages -->
+                                <p class="text-sm text-red-500 mt-2 text-center" t-if="error" role="alert">
+                                    <t t-esc="error"/>
+                                </p>
+
+                                <!-- Submit Button -->
+                                <div class="pt-2">
+                                    <button style="background: linear-gradient(90deg, #FF6B00, #E1467C) !important; border: 0 !important; outline: 0 !important; box-shadow: none !important;" class="w-full text-white font-bold py-3 px-4 rounded-md hover:opacity-90 transition-all transform hover:scale-105 focus:outline-none border-0" type="submit">
+                                        Registrarse
+                                    </button>
+                                </div>
+                            </form>
+
+                            <!-- Footer -->
+                            <div class="text-center mt-8">
+                                <p class="text-sm text-gray-400">
+                                    ¿Ya tienes una cuenta? <a class="font-medium text-orange-500 hover:text-orange-400 transition-colors" t-attf-href="/web/login?{{ keep_query() }}">Iniciar Sesión</a>
+                                </p>
+                            </div>
+                        </div>
+                    </t>
+                </t>
+                <t t-else="">
+                    <!-- Formulario original para otros temas -->
+                    <t t-call="web.login_layout">
+                        <form class="oe_signup_form" role="form" method="post" t-if="not message">
+                          <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
+
+                            <t t-call="auth_signup.fields">
+                                <t t-set="only_passwords" t-value="bool(token and not invalid_token)"/>
+                            </t>
+
+                            <p class="alert alert-danger" t-if="error" role="alert">
+                                <t t-esc="error"/>
+                            </p>
+                            <input type="hidden" name="redirect" t-att-value="redirect"/>
+                            <input type="hidden" name="token" t-att-value="token"/>
+                            <div class="text-center oe_login_buttons d-grid pt-3">
+                                <button type="submit" class="btn btn-primary"> Sign up</button>
+                                <a t-attf-href="/web/login?{{ keep_query() }}" class="btn btn-link btn-sm" role="button">Already have an account?</a>
+                                <div class="o_login_auth"/>
+                            </div>
+                        </form>
+                    </t>
+                </t>
+            </xpath>
+        </template>
+
+        <!-- 5. Personalizar el Formulario de Reset Password - Solo para tema M22 -->
+        <template id="m22_reset_password_override" inherit_id="auth_signup.reset_password" name="M22 Reset Password Override" priority="100">
+            <xpath expr="//t[@t-call='web.login_layout']" position="replace">
+                <t t-if="request.website and request.website.sudo().theme_id and request.website.sudo().theme_id.name == 'theme_m22tc'">
+                    <t t-call="web.login_layout">
+                        <!-- Glassmorphism Card -->
+                        <div class="bg-gray-900/40 backdrop-blur-lg p-8 md:p-12 rounded-xl shadow-2xl border border-white/10">
+                            
+                            <!-- Header &amp; Logo -->
+                            <div class="text-center mb-10">
+                                <div class="flex items-center justify-center mb-6">
+                                    <img t-att-src="request.website.image_url(request.website, 'logo')" 
+                                         alt="Logo" 
+                                         style="max-height: 80px; max-width: 100%; width: auto;"
+                                         class="object-contain"/>
+                                </div>
+                                <h1 class="text-3xl font-bold text-white">Restablecer Contraseña</h1>
+                                <p class="text-gray-400 mt-2" t-if="not token">Ingresa tu email para recibir instrucciones</p>
+                                <p class="text-gray-400 mt-2" t-if="token and not invalid_token">Ingresa tu nueva contraseña</p>
+                            </div>
+
+                            <!-- Success Message -->
+                            <div t-if="message" class="text-center">
+                                <p class="text-sm text-green-500 bg-green-500/10 border border-green-500/20 rounded-md p-4 mb-6" role="status">
+                                    <t t-esc="message"/>
+                                </p>
+                                <a href="/web/login" class="font-medium text-orange-500 hover:text-orange-400 transition-colors">Volver al Login</a>
+                            </div>
+
+                            <!-- Reset Password Form -->
+                            <form class="oe_reset_password_form space-y-5" role="form" method="post" t-if="not message">
+                                <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
+                                <input type="hidden" name="redirect" t-att-value="redirect"/>
+                                <input type="hidden" name="token" t-att-value="token"/>
+
+                                <!-- Mode: Request Reset (no token) -->
+                                <t t-if="not token">
+                                    <div>
+                                        <label class="block text-sm font-medium text-gray-300 mb-2" for="login">Correo Electrónico</label>
+                                        <input class="w-full bg-gray-800/50 border border-gray-700 text-white placeholder-gray-500 px-4 py-3 rounded-md focus:outline-none focus:border-orange-500 transition-all duration-300" 
+                                               id="login" name="login" t-att-value="login" 
+                                               placeholder="tu@email.com" required="required" type="text" 
+                                               autofocus="autofocus" autocapitalize="off"/>
+                                    </div>
+                                </t>
+
+                                <!-- Mode: Set New Password (with token) -->
+                                <t t-if="token and not invalid_token">
+                                    <!-- Email (readonly) -->
+                                    <div>
+                                        <label class="block text-sm font-medium text-gray-300 mb-2" for="login">Correo Electrónico</label>
+                                        <input class="w-full bg-gray-800/50 border border-gray-700 text-white placeholder-gray-500 px-4 py-3 rounded-md focus:outline-none focus:border-orange-500 transition-all duration-300 opacity-60" 
+                                               id="login" name="login" t-att-value="login" 
+                                               type="text" readonly="readonly" autocapitalize="off"/>
+                                    </div>
+
+                                    <!-- Name (readonly) -->
+                                    <div>
+                                        <label class="block text-sm font-medium text-gray-300 mb-2" for="name">Tu Nombre</label>
+                                        <input class="w-full bg-gray-800/50 border border-gray-700 text-white placeholder-gray-500 px-4 py-3 rounded-md focus:outline-none focus:border-orange-500 transition-all duration-300 opacity-60" 
+                                               id="name" name="name" t-att-value="name" 
+                                               type="text" readonly="readonly"/>
+                                    </div>
+
+                                    <!-- New Password -->
+                                    <div>
+                                        <label class="block text-sm font-medium text-gray-300 mb-2" for="password">Nueva Contraseña</label>
+                                        <input class="w-full bg-gray-800/50 border border-gray-700 text-white placeholder-gray-500 px-4 py-3 rounded-md focus:outline-none focus:border-orange-500 transition-all duration-300" 
+                                               id="password" name="password" 
+                                               placeholder="••••••••" required="required" type="password" autofocus="autofocus"/>
+                                    </div>
+
+                                    <!-- Confirm Password -->
+                                    <div>
+                                        <label class="block text-sm font-medium text-gray-300 mb-2" for="confirm_password">Confirmar Contraseña</label>
+                                        <input class="w-full bg-gray-800/50 border border-gray-700 text-white placeholder-gray-500 px-4 py-3 rounded-md focus:outline-none focus:border-orange-500 transition-all duration-300" 
+                                               id="confirm_password" name="confirm_password" 
+                                               placeholder="••••••••" required="required" type="password"/>
+                                    </div>
+                                </t>
+
+                                <!-- Invalid Token Message -->
+                                <div t-if="invalid_token" class="text-center">
+                                    <p class="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-md p-4">
+                                        El enlace ha expirado o es inválido. Por favor solicita uno nuevo.
+                                    </p>
+                                </div>
+
+                                <!-- Error Messages -->
+                                <p class="text-sm text-red-500 mt-2 text-center" t-if="error" role="alert">
+                                    <t t-esc="error"/>
+                                </p>
+
+                                <!-- Submit Button -->
+                                <div class="pt-2" t-if="not invalid_token">
+                                    <button style="background: linear-gradient(90deg, #FF6B00, #E1467C) !important; border: 0 !important; outline: 0 !important; box-shadow: none !important;" class="w-full text-white font-bold py-3 px-4 rounded-md hover:opacity-90 transition-all transform hover:scale-105 focus:outline-none border-0" type="submit">
+                                        <t t-if="not token">Enviar Instrucciones</t>
+                                        <t t-if="token and not invalid_token">Cambiar Contraseña</t>
+                                    </button>
+                                </div>
+                            </form>
+
+                            <!-- Footer -->
+                            <div class="text-center mt-8">
+                                <p class="text-sm text-gray-400">
+                                    <a class="font-medium text-orange-500 hover:text-orange-400 transition-colors" t-attf-href="/web/login?{{ keep_query() }}">Volver al Login</a>
+                                </p>
+                            </div>
+                        </div>
+                    </t>
+                </t>
+                <t t-else="">
+                    <!-- Formulario original para otros temas -->
+                    <t t-call="web.login_layout">
+                        <div t-if="message" class="oe_login_form clearfix">
+                            <p class="alert alert-success" t-if="message" role="status">
+                                <t t-esc="message"/>
+                            </p>
+                            <a href="/web/login" class="btn btn-link btn-sm float-start" role="button">Back to Login</a>
+                        </div>
+
+                        <form class="oe_reset_password_form" role="form" method="post" t-if="not message">
+                          <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
+
+                            <t t-if="token and not invalid_token">
+                                <t t-call="auth_signup.fields">
+                                    <t t-set="only_passwords" t-value="1"/>
+                                </t>
+                            </t>
+
+                            <t t-if="not token">
+                                <div class="mb-3 field-login">
+                                    <label for="login" class="col-form-label">Your Email</label>
+                                    <input type="text" name="login" t-att-value="login" id="login" class="form-control"
+                                        autofocus="autofocus" required="required" autocapitalize="off"/>
+                                </div>
+                            </t>
+
+                            <p class="alert alert-danger" t-if="error" role="alert">
+                                <t t-esc="error"/>
+                            </p>
+                            <input type="hidden" name="redirect" t-att-value="redirect"/>
+                            <input type="hidden" name="token" t-att-value="token"/>
+                            <div class="clearfix oe_login_buttons d-grid mt-3">
+                                <button type="submit" class="btn btn-primary">Reset Password</button>
+                                <div class="d-flex justify-content-between align-items-center small mt-2">
+                                    <a t-if="not token" t-attf-href="/web/login?{{ keep_query() }}">Back to Login</a>
+                                    <a t-if="invalid_token" href="/web/login">Back to Login</a>
+                                </div>
+                                <div class="o_login_auth"/>
+                            </div>
+
+                        </form>
+
+                    </t>
+                </t>
+            </xpath>
+        </template>
+    </data>
+</odoo>

+ 11 - 0
views/portal_sidebar.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <!-- Clean up legacy portal overrides that were injected through the website builder -->
+    <function model="theme.utils" name="cleanup_m22tc_portal_sidebar_views"/>
+    
+    <!-- 
+        NOTA: Los ajustes del portal layout se manejan completamente con CSS en m22tc_styles.scss
+        El CSS detecta la clase .o_has_m22_sidebar que se agrega en frontend_layout.xml
+        y ajusta el layout sin modificar los templates de Odoo.
+    -->
+</odoo>

+ 61 - 0
views/snippets.xml

@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    
+    <!-- M22 Bento Grid Snippet -->
+    <template id="s_m22_bento_grid" name="Bento Grid M22">
+        <section class="s_m22_bento_grid pt96 pb96 o_colored_level" data-snippet="s_m22_bento_grid" data-name="Bento Grid">
+            <div class="container">
+                <div class="row mb-5">
+                    <div class="col-12 text-center">
+                        <h2 class="display-4 font-weight-bold">Our Capabilities</h2>
+                        <p class="lead text-muted">Engineered for the future.</p>
+                    </div>
+                </div>
+                
+                <div class="bento-grid-container">
+                    <!-- Card 1: Large (2 cols, 2 rows) -->
+                    <div class="bento-card bento-span-2 bento-row-2">
+                        <h3 class="mb-3 text-gradient">Digital Transformation</h3>
+                        <p>Complete overhaul of your digital infrastructure using cutting-edge tech stacks.</p>
+                        <div class="mt-auto">
+                            <a href="#" class="btn btn-m22-primary">Explore</a>
+                        </div>
+                    </div>
+
+                    <!-- Card 2: Standard -->
+                    <div class="bento-card">
+                        <i class="fa fa-cloud fa-3x mb-3 text-white"></i>
+                        <h4>Cloud Solutions</h4>
+                        <p class="small">Scalable &amp; Secure.</p>
+                    </div>
+
+                    <!-- Card 3: Standard -->
+                    <div class="bento-card">
+                        <i class="fa fa-code fa-3x mb-3 text-white"></i>
+                        <h4>Custom Dev</h4>
+                        <p class="small">Tailored software.</p>
+                    </div>
+
+                    <!-- Card 4: Wide (2 cols) -->
+                    <div class="bento-card bento-span-2">
+                         <div class="d-flex align-items-center justify-content-between">
+                            <div>
+                                <h4>Consulting Services</h4>
+                                <p class="mb-0">Strategic advice for growth.</p>
+                            </div>
+                            <i class="fa fa-arrow-right fa-2x text-gradient"></i>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </section>
+    </template>
+
+    <!-- Register Snippet in Drag & Drop Bar -->
+    <template id="snippets" inherit_id="website.snippets" name="M22 Tech Snippets">
+        <xpath expr="//snippets[@id='snippet_structure']" position="inside">
+            <t t-snippet="theme_m22tc.s_m22_bento_grid" t-thumbnail="/theme_m22tc/static/description/icon.png"/>
+        </xpath>
+    </template>
+
+</odoo>

+ 25 - 0
views/website_menu_view.xml

@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <record id="website_menu_view_form_m22" model="ir.ui.view">
+        <field name="name">website.menu.form.m22</field>
+        <field name="model">website.menu</field>
+        <field name="inherit_id" ref="website.website_menus_form_view"/>
+        <field name="arch" type="xml">
+            <field name="url" position="after">
+                <field name="m22_icon_class"/>
+            </field>
+        </field>
+    </record>
+
+    <!-- También agregarlo a la vista lista para edición rápida -->
+    <record id="website_menu_view_tree_m22" model="ir.ui.view">
+        <field name="name">website.menu.tree.m22</field>
+        <field name="model">website.menu</field>
+        <field name="inherit_id" ref="website.menu_tree"/>
+        <field name="arch" type="xml">
+            <field name="url" position="after">
+                <field name="m22_icon_class"/>
+            </field>
+        </field>
+    </record>
+</odoo>