Parcourir la source

Squashed 'helpdesk_extras/' content from commit c9452bc

git-subtree-dir: helpdesk_extras
git-subtree-split: c9452bc1c94144c538b8ead43ca7cfa8b73f32a6
odoo il y a 2 mois
commit
d0020587d1
50 fichiers modifiés avec 8552 ajouts et 0 suppressions
  1. 32 0
      .gitignore
  2. 760 0
      README.md
  3. 5 0
      __init__.py
  4. 59 0
      __manifest__.py
  5. 6 0
      controllers/__init__.py
  6. 635 0
      controllers/helpdesk_portal.py
  7. 22 0
      controllers/website_form.py
  8. 419 0
      controllers/website_helpdesk_hours.py
  9. 15 0
      data/helpdesk_form_data.xml
  10. 18 0
      data/helpdesk_request_type_data.xml
  11. 263 0
      data/helpdesk_workflow_template_data.xml
  12. 443 0
      i18n/en_US.po
  13. 454 0
      i18n/es_MX.po
  14. 20 0
      migrations/18.0.1.0.1/post-migration.py
  15. 10 0
      models/__init__.py
  16. 36 0
      models/helpdesk_request_type.py
  17. 1249 0
      models/helpdesk_team.py
  18. 54 0
      models/helpdesk_team_collaborator.py
  19. 704 0
      models/helpdesk_template.py
  20. 2 0
      models/helpdesk_template_field.py
  21. 106 0
      models/helpdesk_ticket.py
  22. 94 0
      models/helpdesk_workflow_template.py
  23. 71 0
      models/helpdesk_workflow_template_sla.py
  24. 63 0
      models/helpdesk_workflow_template_stage.py
  25. 107 0
      scripts/check_and_fix_views.py
  26. 94 0
      scripts/fix_label_custom.py
  27. 101 0
      security/helpdesk_security.xml
  28. 25 0
      security/ir.model.access.csv
  29. 32 0
      static/src/js/helpdesk_template_field_list.js
  30. 142 0
      static/src/js/helpdesk_template_field_m2o_widget.js
  31. 165 0
      static/src/js/website_helpdesk_form_block.js
  32. 261 0
      static/src/snippets/s_helpdesk_hours/000.js
  33. 36 0
      static/src/snippets/s_helpdesk_hours/000.scss
  34. 15 0
      static/src/xml/helpdesk_template_field_list.xml
  35. 8 0
      static/src/xml/helpdesk_template_field_m2o_widget.xml
  36. 772 0
      views/helpdesk_portal_templates.xml
  37. 59 0
      views/helpdesk_request_type_views.xml
  38. 97 0
      views/helpdesk_team_views.xml
  39. 108 0
      views/helpdesk_template_views.xml
  40. 65 0
      views/helpdesk_ticket_views.xml
  41. 179 0
      views/helpdesk_workflow_template_views.xml
  42. 98 0
      views/snippets/s_helpdesk_hours.xml
  43. 17 0
      views/snippets/snippets.xml
  44. 17 0
      views/website_helpdesk_form.xml
  45. 5 0
      wizard/__init__.py
  46. 113 0
      wizard/helpdesk_team_share_collaborator_wizard.py
  47. 275 0
      wizard/helpdesk_team_share_wizard.py
  48. 47 0
      wizard/helpdesk_team_share_wizard_views.xml
  49. 112 0
      wizard/helpdesk_workflow_template_apply_wizard.py
  50. 62 0
      wizard/helpdesk_workflow_template_apply_wizard_views.xml

+ 32 - 0
.gitignore

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

+ 760 - 0
README.md

@@ -0,0 +1,760 @@
+# Helpdesk Extras
+
+Módulo de Odoo 18.0 que extiende las funcionalidades del módulo Helpdesk con características adicionales para compartir equipos con partners externos, gestión avanzada de tickets, sistema de templates y control de horas disponibles.
+
+## 📋 Descripción
+
+Este módulo proporciona funcionalidades adicionales para el módulo de Helpdesk de Odoo:
+
+- **Compartir equipos de helpdesk** con partners externos (usuarios portal)
+- **Control de acceso granular** por partner con tres niveles de permisos
+- **Sistema de tipos de solicitud** para categorizar tickets (Incidentes, Mejoras, etc.)
+- **Campos extendidos en tickets** para gestión avanzada (módulo afectado, impacto, autorización, etc.)
+- **Sistema de templates** para formularios web personalizables por equipo
+- **Condiciones de visibilidad dinámicas** en formularios web
+- **Sistema de plantillas de flujo de trabajo** (Workflow Templates) para configurar rápidamente etapas y políticas SLA
+- **Widget de horas disponibles** para el sitio web con barra de progreso
+- **Estadísticas de horas** en las vistas del backend
+- **Bloqueo de formularios** cuando no hay horas disponibles
+
+## 🚀 Características Principales
+
+### 1. Compartir Equipos con Partners Externos
+
+Permite compartir equipos de helpdesk con partners externos (usuarios portal) mediante un asistente intuitivo. Los partners pueden acceder al portal y gestionar tickets según su nivel de acceso.
+
+**Características:**
+- Asistente de compartir equipo (`helpdesk.team.share.wizard`)
+- Gestión de colaboradores desde la vista del equipo
+- Suscripción automática como seguidores del equipo
+- Envío de invitaciones por correo electrónico
+
+### 2. Control de Acceso Granular
+
+Tres niveles de acceso para los colaboradores:
+
+| Modo de Acceso | Descripción |
+|----------------|-------------|
+| **Administrator** | Puede ver todos los tickets y gestionar otros usuarios |
+| **User - All Tickets** | Puede ver todos los tickets y crear sus propios tickets |
+| **User - Own Tickets** | Solo puede crear y ver sus propios tickets |
+
+### 3. Sistema de Tipos de Solicitud
+
+Modelo `helpdesk.request.type` para categorizar tickets con diferentes tipos (por ejemplo, "Incidente", "Mejora").
+
+**Características:**
+- Campo `code` único para uso en lógica condicional
+- Campo `is_billable_default` para sugerir facturación automática
+- Gestión desde **Helpdesk > Configuración > Tipos de Solicitud**
+- Datos iniciales: "Incidente" (code: `incident`) y "Mejora" (code: `improvement`)
+
+**Uso:**
+- Los tickets requieren un tipo de solicitud (por defecto: "Incidente")
+- El código del tipo se almacena en `request_type_code` para lógica condicional
+- Permite mostrar/ocultar campos según el tipo seleccionado
+
+### 4. Campos Extendidos en Tickets
+
+Extensión del modelo `helpdesk.ticket` con campos adicionales para gestión avanzada:
+
+**Campos principales:**
+- `request_type_id` (Many2one, requerido): Tipo de solicitud (Incidente, Mejora, etc.)
+- `request_type_code` (Char, computed): Código del tipo para lógica condicional
+- `affected_module_id` (Many2one): Módulo de Odoo afectado (solo módulos instalados y aplicaciones)
+- `business_impact` (Selection): Impacto del negocio (Critical, High, Normal)
+- `reproduce_steps` (Html): Pasos para reproducir el problema (visible solo para "Incidente")
+- `business_goal` (Html): Objetivo de negocio (visible solo para "Mejora")
+- `client_authorization` (Boolean): Autorización del cliente desde formulario web
+- `estimated_hours` (Float): Horas estimadas después del análisis
+- `approval_status` (Selection): Estado de aprobación (N/A, Waiting, Approved, Rejected)
+- `attachment_ids` (One2many): Archivos adjuntos al ticket (múltiples archivos permitidos)
+
+**Visibilidad condicional:**
+- `reproduce_steps`: Solo visible cuando `request_type_code == 'incident'`
+- `business_goal`: Solo visible cuando `request_type_code == 'improvement'`
+
+**Organización en vista:**
+- Todos los campos custom se muestran en la pestaña **"Extras"** del formulario de tickets
+- Los campos están organizados en grupos:
+  - **Request Information**: Tipo de solicitud, módulo afectado, impacto
+  - **Details**: Pasos de reproducción y objetivo de negocio (con visibilidad condicional)
+  - **Approval & Billing**: Autorización, horas estimadas, estado de aprobación
+  - **Attachments**: Campo para adjuntar múltiples archivos con widget `many2many_binary`
+  - **Template Information**: Información sobre el template activo (si aplica)
+
+### 5. Sistema de Templates para Formularios Web
+
+Sistema flexible de templates que permite personalizar los formularios web de creación de tickets por equipo.
+
+**Modelos:**
+- `helpdesk.template`: Define un template con nombre, descripción y campos
+- `helpdesk.template.field`: Campos incluidos en el template con configuración individual
+
+**Características del template:**
+- **Campos personalizables**: Solo campos del modelo `helpdesk.ticket` disponibles en form builder
+- **Orden configurable**: Campo `sequence` para definir el orden de visualización
+- **Obligatoriedad adicional**: Campo `required` para hacer campos obligatorios además de su configuración base
+- **Protección de campos obligatorios**: Los campos marcados como `required=True` en el modelo no pueden ser eliminados del template (`model_required=True`)
+- **Propiedades editables de campos** (igual que el form builder nativo):
+  - `label_custom`: Etiqueta personalizada del campo
+  - `placeholder`: Texto de marcador de posición
+  - `default_value`: Valor predeterminado
+  - `help_text`: Texto de ayuda (soporta HTML)
+  - `widget`: Widget para campos de selección (radio, checkbox, etc.)
+  - `selection_options`: Opciones personalizadas para campos selection no relacionales
+- **Condiciones de visibilidad dinámicas**: 
+  - Campo de dependencia (`visibility_dependency`): Campo del que depende la visibilidad
+  - Comparador (`visibility_comparator`): Operador de comparación (equal, !equal, contains, !contains, set, !set, greater, less, etc.)
+  - Valor de condición (`visibility_condition`): Valor a comparar
+  - **Soporte dinámico para many2one**: Cuando la dependencia es many2one, muestra un selector dinámico con registros del modelo relacionado
+  - **Soporte dinámico para selection**: Cuando la dependencia es selection, muestra las opciones disponibles del campo
+
+**Integración con equipos:**
+- Campo `template_id` en `helpdesk.team` (opcional)
+- Cuando un equipo tiene template asignado:
+  - Los campos del template se muestran en el formulario web cuando se crea un ticket
+  - El formulario web se regenera automáticamente con solo los campos del template
+  - Los campos hardcodeados que no están en el template se ocultan automáticamente
+
+**Regeneración automática:**
+- Al asignar/quitar template en un equipo
+- Al modificar campos del template (agregar, eliminar, cambiar secuencia, requerido, visibilidad)
+- Al activar/desactivar template
+- Al crear un equipo con template ya asignado
+
+**Gestión:**
+- Menú: **Helpdesk > Configuración > Plantillas**
+- Vistas: Lista y formulario para templates y campos de template
+
+### 6. Widget de Horas Disponibles
+
+Snippet para el sitio web que muestra información sobre horas de soporte disponibles:
+
+- **Horas prepago restantes**: Horas disponibles de pedidos prepago pagados
+- **Crédito disponible**: Horas calculadas a partir del crédito disponible del partner
+- **Barra de progreso**: Visualización del porcentaje de horas usadas vs disponibles
+- **Estados dinámicos**: Muestra mensajes cuando no hay horas disponibles
+- **Enlaces de contacto**: Integración con WhatsApp y correo electrónico
+
+**Ubicación en el editor:** Sección "Content" → "Helpdesk Hours Available"
+
+### 7. Estadísticas de Horas en Backend
+
+Muestra estadísticas agregadas de horas en las vistas del equipo:
+
+- **Vista Kanban**: Barra de progreso con porcentaje de horas usadas
+- **Vista Form**: Campos calculados con totales de horas disponibles y usadas
+- **Cálculo automático**: Agrega horas de todos los colaboradores del equipo
+
+### 8. Sistema de Plantillas de Flujo de Trabajo (Workflow Templates)
+
+Sistema que permite crear plantillas predefinidas de etapas y políticas SLA para aplicar rápidamente a equipos de helpdesk.
+
+**Características:**
+- **Plantillas reutilizables**: Define una vez y aplica a múltiples equipos
+- **Etapas predefinidas**: Configura etapas con nombres, secuencias, y propiedades (fold, leyendas, etc.)
+- **Políticas SLA predefinidas**: Define políticas SLA con tiempos, prioridades, y etapas excluidas
+- **Aplicación con un clic**: Wizard intuitivo para aplicar plantillas a equipos
+- **Reemplazo opcional**: Opción para reemplazar etapas y SLAs existentes
+- **Plantillas predefinidas**: Incluye 3 plantillas listas para usar:
+  - **Soporte Básico**: 3 etapas (Nuevo, En Progreso, Resuelto) con SLAs básicos
+  - **Soporte Premium**: 4 etapas (incluye En Espera) con SLAs más rápidos y etapas excluidas
+  - **Desarrollo**: 5 etapas (Nuevo, Análisis, En Progreso, Pruebas, Completado) con SLAs por prioridad
+
+**Modelos:**
+- `helpdesk.workflow.template`: Plantilla principal con etapas y SLAs
+- `helpdesk.workflow.template.stage`: Etapas dentro de una plantilla
+- `helpdesk.workflow.template.sla`: Políticas SLA dentro de una plantilla
+
+**Gestión:**
+- Menú: **Helpdesk > Configuración > Plantillas de Flujo de Trabajo**
+- Vistas: Kanban, Lista y Formulario para plantillas
+- Vista integrada en equipos: Campo `workflow_template_id` y botón "Aplicar Plantilla"
+
+**Uso:**
+1. Crea o selecciona una plantilla de flujo de trabajo
+2. Configura etapas y políticas SLA en la plantilla
+3. En el equipo, selecciona la plantilla y haz clic en "Aplicar Plantilla"
+4. El sistema crea automáticamente las etapas y SLAs reales basados en la plantilla
+
+**Características avanzadas:**
+- **Etapas excluidas en SLAs**: Configura etapas donde el tiempo NO cuenta hacia el plazo del SLA (útil para "En Espera")
+- **Duplicación de plantillas**: Duplica plantillas completas con todas sus etapas y SLAs
+- **Traducciones**: Plantillas disponibles en español (MX) e inglés (US)
+- **Vista Kanban optimizada**: Vista Kanban simplificada siguiendo mejores prácticas de Odoo 18
+
+### 9. Bloqueo de Formularios
+
+Bloquea automáticamente el formulario de creación de tickets cuando:
+- El equipo tiene colaboradores configurados
+- El usuario no tiene horas disponibles
+
+Muestra un mensaje informativo con opciones de contacto para adquirir más horas.
+
+## 📦 Dependencias
+
+El módulo requiere los siguientes módulos de Odoo:
+
+- `helpdesk` - Módulo base de Helpdesk
+- `website` - Funcionalidades de sitio web
+- `website_helpdesk` - Helpdesk en el sitio web
+- `sale` - Módulo de ventas (para horas prepago)
+- `account` - Módulo de contabilidad (para verificación de pagos)
+
+**Módulos opcionales:**
+- `sale_timesheet` - Si está instalado, mejora el cálculo de horas de servicio
+
+## 🔧 Instalación
+
+1. Asegúrate de tener los módulos dependientes instalados
+2. Copia el módulo en `src/user/helpdesk_extras`
+3. Actualiza la lista de aplicaciones en Odoo
+4. Instala el módulo "Helpdesk Extras"
+
+```bash
+# Desde el directorio workspace
+./odoo_dev.sh restart
+# Luego en Odoo: Apps > Actualizar lista de aplicaciones > Buscar "Helpdesk Extras" > Instalar
+```
+
+## 📖 Uso
+
+### Compartir un Equipo con Partners
+
+1. Ve a **Helpdesk > Configuración > Equipos**
+2. Abre el equipo que deseas compartir
+3. Haz clic en el botón **"Share Team"** (o en el botón de estadísticas "Collaborators")
+4. En el asistente:
+   - Agrega partners desde el campo de búsqueda
+   - Selecciona el modo de acceso para cada colaborador
+   - Opcionalmente, marca "Send Invitation" para enviar correo
+5. Haz clic en **"Share"**
+
+### Configurar Tipos de Solicitud
+
+1. Ve a **Helpdesk > Configuración > Tipos de Solicitud**
+2. Crea o edita tipos según necesites
+3. Configura el código único (por ejemplo, `incident`, `improvement`)
+4. Marca `is_billable_default` si este tipo debe sugerir facturación automática
+
+**Tipos predefinidos:**
+- **Incidente** (code: `incident`): Para reportar problemas
+- **Mejora** (code: `improvement`, billable: True): Para solicitar mejoras
+
+### Crear y Configurar Templates
+
+1. Ve a **Helpdesk > Configuración > Plantillas**
+2. Crea un nuevo template:
+   - Ingresa nombre y descripción
+   - Activa el template
+3. Agrega campos al template:
+   - Selecciona campos del modelo `helpdesk.ticket`
+   - Define el orden (sequence)
+   - Marca como requerido si es necesario
+   - Configura condiciones de visibilidad si aplica:
+     - Campo de dependencia (ej: `request_type_id`)
+     - Comparador (ej: `equal`)
+     - Valor de condición (ej: `incident`)
+
+**Ejemplo de template "Soporte Técnico":**
+- Campo: `request_type_id` (sequence: 10, required: True)
+- Campo: `affected_module_id` (sequence: 20, required: True)
+- Campo: `business_impact` (sequence: 30)
+- Campo: `reproduce_steps` (sequence: 40, visibility: `request_type_id == 'incident'`)
+
+### Asignar Template a un Equipo
+
+1. Ve a **Helpdesk > Configuración > Equipos**
+2. Abre el equipo que deseas configurar
+3. En el campo **"Template"**, selecciona un template
+4. Guarda el equipo
+5. El formulario web se regenerará automáticamente con los campos del template
+
+**Nota:** Si el equipo ya tiene un formulario web activo (`use_website_helpdesk_form = True`), la regeneración ocurre inmediatamente. Si no, se generará cuando se active el formulario web.
+
+### Agregar el Widget de Horas al Sitio Web
+
+1. Ve a **Website > Editar**
+2. En el editor, busca el snippet **"Helpdesk Hours Available"** en la sección "Content"
+3. Arrastra el snippet a la página
+4. El widget mostrará automáticamente las horas del usuario autenticado
+
+**Nota:** El widget solo muestra información para usuarios portal autenticados que sean colaboradores de algún equipo.
+
+### Configurar Parámetros del Sistema
+
+El módulo utiliza los siguientes parámetros de configuración (opcionales):
+
+- `helpdesk_extras.whatsapp_number`: Número de WhatsApp para contacto (formato: sin espacios ni caracteres especiales)
+- `helpdesk_extras.packages_url`: URL de la página de paquetes (por defecto: `/shop`)
+
+**Configuración:**
+1. Ve a **Configuración > Técnico > Parámetros > Parámetros del Sistema**
+2. Crea o edita los parámetros según necesites
+
+## 🏗️ Estructura del Módulo
+
+```
+helpdesk_extras/
+├── __init__.py
+├── __manifest__.py
+├── README.md
+├── controllers/
+│   ├── __init__.py
+│   ├── helpdesk_portal.py          # Extiende portal de helpdesk
+│   ├── website_form.py              # Maneja formularios web
+│   └── website_helpdesk_hours.py    # API JSON para horas disponibles
+├── data/
+│   ├── helpdesk_request_type_data.xml  # Datos iniciales de tipos
+│   ├── helpdesk_form_data.xml         # Whitelist de campos para form builder
+│   └── helpdesk_workflow_template_data.xml # Plantillas predefinidas de flujo de trabajo
+├── models/
+│   ├── __init__.py
+│   ├── helpdesk_team.py             # Extiende helpdesk.team (templates, colaboradores)
+│   ├── helpdesk_team_collaborator.py # Modelo de colaboradores
+│   ├── helpdesk_ticket.py           # Extiende helpdesk.ticket (campos adicionales)
+│   ├── helpdesk_request_type.py     # Modelo de tipos de solicitud
+│   ├── helpdesk_template.py        # Modelos de templates (template y template.field)
+│   ├── helpdesk_workflow_template.py # Modelo de plantillas de flujo de trabajo
+│   ├── helpdesk_workflow_template_stage.py # Modelo de etapas en plantillas
+│   └── helpdesk_workflow_template_sla.py # Modelo de políticas SLA en plantillas
+├── security/
+│   ├── helpdesk_security.xml        # Reglas de acceso
+│   └── ir.model.access.csv          # Permisos CRUD
+├── static/
+│   └── src/
+│       ├── js/
+│       │   ├── website_helpdesk_form_block.js              # Bloqueo de formularios
+│       │   ├── helpdesk_template_field_list.js             # Lista de campos template (oculta botón eliminar para model_required)
+│       │   └── helpdesk_template_field_m2o_widget.js       # Widget dinámico many2one para condiciones de visibilidad
+│       ├── xml/
+│       │   ├── helpdesk_template_field_list.xml            # Template para lista de campos template
+│       │   └── helpdesk_template_field_m2o_widget.xml      # Template para widget many2one dinámico
+│       └── snippets/
+│           └── s_helpdesk_hours/
+│               ├── 000.js            # Lógica del snippet
+│               └── 000.scss          # Estilos del snippet
+├── views/
+│   ├── helpdesk_request_type_views.xml  # Vistas de tipos de solicitud
+│   ├── helpdesk_template_views.xml      # Vistas de templates
+│   ├── helpdesk_workflow_template_views.xml # Vistas de plantillas de flujo de trabajo
+│   ├── helpdesk_team_views.xml          # Vistas del equipo
+│   ├── helpdesk_ticket_views.xml        # Vistas de tickets (pestaña Extras)
+│   ├── helpdesk_portal_templates.xml    # Templates del portal
+│   ├── website_helpdesk_form.xml         # Herencia del formulario web
+│   └── snippets/
+│       ├── s_helpdesk_hours.xml         # Template del snippet
+│       └── snippets.xml
+├── i18n/
+│   ├── es_MX.po                        # Traducciones español (México)
+│   └── en_US.po                        # Traducciones inglés (US)
+└── wizard/
+    ├── __init__.py
+    ├── helpdesk_team_share_wizard.py              # Wizard principal
+    ├── helpdesk_team_share_collaborator_wizard.py # Líneas del wizard
+    ├── helpdesk_team_share_wizard_views.xml       # Vistas del wizard
+    ├── helpdesk_workflow_template_apply_wizard.py # Wizard para aplicar plantillas
+    └── helpdesk_workflow_template_apply_wizard_views.xml # Vistas del wizard de aplicar
+```
+
+## 🔍 Modelos
+
+### `helpdesk.request.type`
+
+Modelo para categorizar tipos de solicitudes de tickets.
+
+**Campos:**
+- `name` (Char, required, translate): Nombre del tipo (ej: "Incidente", "Mejora")
+- `code` (Char, required, unique): Código interno único para lógica (ej: `incident`, `improvement`)
+- `is_billable_default` (Boolean): Si True, sugiere facturación automática al crear tickets
+- `active` (Boolean, default=True): Si está activo y disponible
+
+**Restricciones:**
+- `code` debe ser único (`_sql_constraints`)
+
+### `helpdesk.template`
+
+Modelo para definir templates de formularios web.
+
+**Campos:**
+- `name` (Char, required, translate): Nombre del template
+- `description` (Text, translate): Descripción del template
+- `active` (Boolean, default=True): Si está activo
+- `field_ids` (One2many): Campos incluidos en el template
+
+**Comportamiento:**
+- Al modificar `field_ids` o `active`, regenera automáticamente formularios en todos los equipos que usan el template
+
+### `helpdesk.template.field`
+
+Modelo para campos incluidos en un template.
+
+**Campos:**
+- `template_id` (Many2one, required): Template al que pertenece
+- `field_id` (Many2one, required): Campo de `helpdesk.ticket` (solo campos disponibles en form builder)
+- `field_name` (Char, related): Nombre del campo (readonly)
+- `field_type` (Selection, related): Tipo del campo (readonly)
+- `sequence` (Integer, default=10): Orden de visualización
+- `required` (Boolean, default=False): Hacer obligatorio además de configuración base
+- `model_required` (Boolean, readonly): Indica si el campo es obligatorio a nivel de modelo (no se puede eliminar)
+- `label_custom` (Char): Etiqueta personalizada del campo
+- `placeholder` (Char): Texto de marcador de posición
+- `default_value` (Char): Valor predeterminado
+- `help_text` (Html): Texto de ayuda (soporta HTML)
+- `widget` (Selection): Widget para campos de selección (radio, checkbox, etc.)
+- `selection_options` (Text): Opciones personalizadas para campos selection no relacionales
+- `visibility_dependency` (Many2one): Campo del que depende la visibilidad
+- `visibility_dependency_type` (Char, computed): Tipo del campo de dependencia (many2one, selection, char, etc.)
+- `visibility_comparator` (Selection): Operador de comparación (equal, !equal, contains, !contains, set, !set, greater, less, etc.)
+- `visibility_condition` (Char): Valor a comparar (texto libre o sincronizado desde campos dinámicos)
+- `visibility_condition_m2o_id` (Integer): ID del registro many2one cuando la dependencia es many2one
+- `visibility_condition_m2o_model` (Char, related): Modelo del registro many2one (readonly)
+- `visibility_condition_selection` (Selection, dynamic): Opción seleccionada cuando la dependencia es selection
+
+**Restricciones:**
+- Un campo solo puede estar una vez en un template (`unique(template_id, field_id)`)
+- Los campos con `model_required=True` no pueden ser eliminados del template
+
+**Comportamiento:**
+- Al crear/modificar/eliminar, regenera automáticamente formularios en equipos que usan el template padre
+- Al crear un template, se agregan automáticamente los campos obligatorios del modelo (`name`, `partner_name`, `partner_email`, `description`)
+- Los campos obligatorios del modelo (`required=True` en `ir.model.fields` y no `website_form_blacklisted`) se marcan automáticamente como `model_required=True`
+- Al cambiar `visibility_dependency`, se limpian los valores de condición relacionados
+- Los valores de `visibility_condition_m2o_id` y `visibility_condition_selection` se sincronizan automáticamente con `visibility_condition`
+
+### `helpdesk.team.collaborator`
+
+Modelo que relaciona partners con equipos de helpdesk.
+
+**Campos:**
+- `team_id` (Many2one): Equipo de helpdesk
+- `partner_id` (Many2one): Partner colaborador
+- `partner_email` (Char, related): Email del partner
+- `access_mode` (Selection): Modo de acceso (admin, user_all, user_own)
+
+### `helpdesk.team` (extendido)
+
+Extiende el modelo base con:
+
+**Campos:**
+- `collaborator_ids` (One2many): Lista de colaboradores
+- `template_id` (Many2one): Template asignado al equipo (opcional)
+- `workflow_template_id` (Many2one): Plantilla de flujo de trabajo asignada (opcional)
+- `hours_total_available` (Float, computed): Total de horas disponibles
+- `hours_total_used` (Float, computed): Total de horas usadas
+- `hours_percentage_used` (Float, computed): Porcentaje de horas usadas
+- `has_hours_stats` (Boolean, computed): Indica si hay estadísticas disponibles
+
+**Métodos:**
+- `_compute_hours_stats()`: Calcula estadísticas de horas agregadas
+- `_check_helpdesk_team_sharing_access()`: Verifica acceso del usuario
+- `_get_new_collaborators()`: Obtiene partners que pueden ser agregados
+- `_add_collaborators()`: Agrega colaboradores al equipo
+- `action_open_share_team_wizard()`: Abre el wizard de compartir
+- `_is_order_paid()`: Verifica si un pedido tiene facturas pagadas
+- `_regenerate_form_from_template()`: Regenera el XML del formulario web basado en el template usando `ir.ui.view.save()` para formato nativo
+- `_restore_default_form()`: Restaura el formulario por defecto cuando se quita el template
+- `_build_template_field_html()`: Construye el HTML de un campo del template (replica form builder nativo) con todas las propiedades editables
+- `_ensure_submit_form_view()`: Asegura que el formulario se regenere si hay template después de crear la vista
+- `apply_workflow_template()`: Aplica una plantilla de flujo de trabajo creando etapas y SLAs reales
+
+### `helpdesk.ticket` (extendido)
+
+Extiende el modelo base con:
+
+**Campos:**
+- `request_type_id` (Many2one, required): Tipo de solicitud
+- `request_type_code` (Char, computed, store): Código del tipo para lógica condicional
+- `affected_module_id` (Many2one): Módulo afectado (solo instalados y aplicaciones)
+- `business_impact` (Selection): Impacto (Critical, High, Normal)
+- `reproduce_steps` (Html): Pasos para reproducir (visible solo si `request_type_code == 'incident'`)
+- `business_goal` (Html): Objetivo de negocio (visible solo si `request_type_code == 'improvement'`)
+- `client_authorization` (Boolean): Autorización del cliente
+- `estimated_hours` (Float): Horas estimadas
+- `approval_status` (Selection): Estado de aprobación
+- `attachment_ids` (One2many): Archivos adjuntos al ticket (múltiples archivos permitidos)
+- `has_template` (Boolean, computed): Indica si el equipo tiene template asignado
+
+**Métodos:**
+- `_default_request_type_id()`: Valor por defecto (tipo "Incidente")
+- `_compute_has_template()`: Calcula si el equipo tiene template
+- `_get_template_fields()`: Obtiene campos del template del equipo
+
+## 🌐 Controladores y APIs
+
+### `/helpdesk/hours/available` (JSON, auth="user")
+
+Retorna información de horas disponibles para el usuario autenticado.
+
+**Respuesta:**
+```json
+{
+  "total_available": 25.0,
+  "hours_used": 10.0,
+  "prepaid_hours": 15.0,
+  "credit_hours": 10.0,
+  "credit_available": 500.0,
+  "highest_price": 50.0,
+  "whatsapp_number": "1234567890",
+  "email": "support@example.com",
+  "packages_url": "/shop"
+}
+```
+
+### `/helpdesk/form/check_block` (JSON, auth="public")
+
+Verifica si el formulario de tickets debe ser bloqueado.
+
+**Parámetros:**
+- `team_id` (int): ID del equipo de helpdesk
+
+**Respuesta:**
+```json
+{
+  "should_block": true,
+  "has_collaborators": true,
+  "has_hours": false,
+  "message": "No tienes horas disponibles..."
+}
+```
+
+## 🔐 Seguridad
+
+### Reglas de Acceso
+
+**`helpdesk.request.type`:**
+- **Usuarios Internos**: Lectura (1,0,0,0)
+- **Gestores Helpdesk**: Acceso completo (1,1,1,1)
+- **Usuarios Portal/Público**: Lectura (1,0,0,0) - Necesario para dropdowns en formulario web
+
+**`helpdesk.template` y `helpdesk.template.field`:**
+- **Usuarios Internos**: Lectura (1,0,0,0)
+- **Gestores Helpdesk**: Acceso completo (1,1,1,1)
+
+**`helpdesk.team.collaborator`:**
+- **Usuarios Helpdesk**: Pueden leer, escribir, crear y eliminar colaboradores
+- **Gestores Helpdesk**: Acceso completo a colaboradores
+- **Usuarios Portal**: Solo lectura de colaboradores (para verificar su acceso)
+
+**Nota sobre `ir.module.module`:**
+- Los usuarios Portal NO tienen acceso directo a `ir.module.module` por seguridad
+- El controlador `website_form.py` usa `sudo()` para leer módulos instalados cuando es necesario
+- No se otorgan permisos de lectura a usuarios públicos en el CSV
+
+### Reglas de Registro
+
+El módulo incluye reglas de seguridad en `security/helpdesk_security.xml` que controlan el acceso a tickets basado en el modo de acceso del colaborador.
+
+## 🎨 Personalización
+
+### Personalizar el Widget de Horas
+
+El snippet utiliza clases CSS específicas que pueden ser personalizadas:
+
+- `.s_helpdesk_hours`: Contenedor principal
+- `.s_helpdesk_hours_progress_bar`: Barra de progreso
+- `.s_helpdesk_hours_normal_content`: Contenido normal
+- `.s_helpdesk_hours_no_hours`: Mensaje sin horas
+- `.s_helpdesk_hours_loading`: Estado de carga
+- `.s_helpdesk_hours_error`: Mensaje de error
+
+### Personalizar Mensajes
+
+Los mensajes pueden ser personalizados editando:
+- `controllers/website_helpdesk_hours.py`: Mensajes del API
+- `static/src/js/website_helpdesk_form_block.js`: Mensajes de bloqueo
+- `views/snippets/s_helpdesk_hours.xml`: Template del snippet
+
+### Personalizar Templates de Formularios
+
+Los templates permiten personalizar completamente los formularios web:
+
+1. **Crear template**: Helpdesk > Configuración > Plantillas
+2. **Seleccionar campos**: Solo campos de `helpdesk.ticket` disponibles en form builder
+3. **Configurar orden**: Campo `sequence`
+4. **Configurar visibilidad**: Dependencias y condiciones
+5. **Asignar a equipo**: El formulario se regenera automáticamente
+
+**Nota técnica:** El sistema replica exactamente el comportamiento del form builder nativo de Odoo, generando XML dinámicamente usando `lxml.etree` y guardándolo en `ir.ui.view.arch` del `website_form_view_id` del equipo.
+
+## 🐛 Troubleshooting
+
+### El widget no muestra horas
+
+1. Verifica que el usuario sea un colaborador de algún equipo
+2. Verifica que haya pedidos prepago con facturas pagadas
+3. Revisa los logs del servidor para errores
+4. Verifica que el módulo `sale` esté instalado
+
+### El formulario no se bloquea
+
+1. Verifica que el equipo tenga colaboradores configurados
+2. Verifica que el JavaScript esté cargando correctamente
+3. Revisa la consola del navegador para errores
+4. Verifica que el `team_id` esté disponible en el formulario
+
+### Las estadísticas no se calculan
+
+1. Verifica que haya colaboradores en el equipo
+2. Verifica que los colaboradores tengan pedidos prepago
+3. Verifica que las facturas estén pagadas
+4. Revisa los logs para errores en el cálculo
+
+### Los campos del template no aparecen en el formulario web
+
+1. Verifica que el equipo tenga un template asignado
+2. Verifica que el template esté activo
+3. Verifica que el equipo tenga `use_website_helpdesk_form = True`
+4. Verifica que el equipo tenga `website_form_view_id` configurado
+5. Revisa los logs para errores en `_regenerate_form_from_template()`
+6. Ejecuta regeneración manual desde shell de Odoo:
+
+```python
+team = env['helpdesk.team'].browse(TEAM_ID)
+team._regenerate_form_from_template()
+env.cr.commit()
+```
+
+### Los campos hardcodeados aparecen junto con los del template
+
+1. Verifica que la condición `t-if` en `website_helpdesk_form.xml` esté funcionando
+2. Verifica que `_regenerate_form_from_template()` esté eliminando campos hardcodeados correctamente
+3. Revisa el XML generado en `team.website_form_view_id.arch`
+4. Limpia caché del navegador y recarga la página
+
+### Las condiciones de visibilidad no funcionan
+
+1. Verifica que el campo de dependencia esté correctamente configurado
+2. Verifica que el comparador y valor de condición sean correctos
+3. Revisa que el JavaScript `website_helpdesk_template_fields.js` esté cargando
+4. Verifica que los atributos `data-visibility-*` estén presentes en el HTML generado
+5. Para campos many2one, verifica que `visibility_condition_m2o_id` tenga un valor válido
+6. Para campos selection, verifica que `visibility_condition_selection` tenga un valor válido
+
+### El campo many2one en condiciones de visibilidad muestra "Sin nombre"
+
+1. Verifica que el widget `dynamic_many2one` esté cargando correctamente
+2. Revisa la consola del navegador para errores de JavaScript
+3. Verifica que `visibility_condition_m2o_model` tenga el modelo correcto
+4. Verifica que `visibility_condition_m2o_id` tenga un ID válido del modelo correcto
+5. Limpia la caché del navegador y recarga la página
+
+## 📝 Notas Técnicas
+
+### Cálculo de Horas
+
+El módulo calcula horas disponibles basándose en:
+
+1. **Horas Prepago**: Líneas de pedido de venta con:
+   - Estado: `sale` o `done`
+   - Producto tipo servicio con política `ordered_prepaid`
+   - `remaining_hours > 0`
+   - Facturas del pedido pagadas (`payment_state = 'paid'`)
+
+2. **Horas de Crédito**: Calculadas a partir de:
+   - Crédito disponible del partner (`credit_limit - credit`)
+   - Precio unitario más alto de horas prepago
+   - Solo si `account_use_credit_limit` está habilitado
+
+3. **Horas Usadas**: Calculadas desde:
+   - `qty_delivered` de líneas de pedido prepago
+   - Convertidas a horas usando UoM
+
+### Verificación de Pagos
+
+El método `_is_order_paid()` verifica que un pedido tenga al menos una factura:
+- Con estado `posted`
+- Con `payment_state = 'paid'`
+
+Esto asegura que solo se consideren horas de pedidos realmente pagados.
+
+### Sistema de Templates - Generación de XML
+
+El sistema de templates genera XML dinámicamente replicando el comportamiento del form builder nativo de Odoo:
+
+1. **Base limpia**: Usa el `arch` del template base `website_helpdesk.ticket_submit_form`
+2. **Eliminación de campos hardcodeados**: Remueve todos los campos custom que no están en el template activo
+3. **Inserción de campos del template**: Agrega campos según `sequence`, con todas las propiedades editables
+4. **Estructura nativa**: Replica exactamente la estructura HTML/CSS del form builder (clases, IDs, atributos)
+5. **Guardado nativo**: Usa `ir.ui.view.save()` para guardar el XML con formato nativo de Odoo
+
+**Características técnicas:**
+- Usa `lxml.etree` y `lxml.html` para parsing y construcción de HTML/XML
+- Usa `ir.ui.view.save()` para guardar con formato nativo (igual que el form builder)
+- IDs dinámicos: `helpdesk_{hash(field_name) % 10000}` (similar a form builder)
+- Soporte para todos los tipos de campo: char, text, html, integer, float, boolean, selection, many2one
+- Opciones de selection pobladas desde `model._fields[field_name].selection` o `selection_options` personalizado
+- Propiedades editables: `placeholder`, `default_value`, `help_text`, `widget`, `selection_options`
+- Visibilidad condicional usando clases `s_website_form_field_hidden_if d-none` y atributos `data-visibility-*`
+- Soporte dinámico para condiciones many2one y selection con widgets personalizados
+
+### Regeneración Automática
+
+La regeneración de formularios se dispara automáticamente en:
+
+- `helpdesk.team.create()`: Si template está asignado al crear
+- `helpdesk.team.write()`: Si `template_id` cambia
+- `helpdesk.team._ensure_submit_form_view()`: Después de crear vista inicial
+- `helpdesk.template.write()`: Si `field_ids` o `active` cambian
+- `helpdesk.template.field.create()`: Al agregar campo
+- `helpdesk.template.field.write()`: Al modificar campo (sequence, required, visibilidad)
+- `helpdesk.template.field.unlink()`: Al eliminar campo
+
+Esto asegura que los formularios siempre estén sincronizados con la configuración del template.
+
+## 👥 Autor
+
+**M22 Tech**
+
+- Website: https://www.m22tech.com
+- Versión: 18.0.1.0.0
+- Licencia: LGPL-3
+
+## 📄 Licencia
+
+Este módulo está licenciado bajo LGPL-3.
+
+## 🔄 Changelog
+
+### 18.0.1.0.3
+- **Sistema de Plantillas de Flujo de Trabajo (Workflow Templates)**: Nuevo sistema para crear y aplicar plantillas predefinidas de etapas y políticas SLA
+  - Modelos: `helpdesk.workflow.template`, `helpdesk.workflow.template.stage`, `helpdesk.workflow.template.sla`
+  - Wizard de aplicación con opción de reemplazar etapas/SLAs existentes
+  - 3 plantillas predefinidas: Soporte Básico, Soporte Premium, Desarrollo
+  - Soporte para etapas excluidas en políticas SLA (el tiempo no cuenta hacia el plazo)
+  - Duplicación de plantillas con todas sus etapas y SLAs
+  - Traducciones completas en español (MX) e inglés (US)
+  - Vista Kanban optimizada siguiendo mejores prácticas de Odoo 18
+  - Integración en vista de equipos con campo y botón de aplicación
+
+### 18.0.1.0.2
+- **Campo de archivos múltiples**: Agregado campo `attachment_ids` (One2many) para adjuntar múltiples archivos a tickets
+- **Reorganización de vista**: Todos los campos custom del modelo se muestran en la pestaña **"Extras"** del formulario de tickets
+- **Widget de archivos**: Campo de archivos usa widget `many2many_binary` nativo de Odoo para gestión de adjuntos
+- **Organización mejorada**: Campos organizados en grupos lógicos (Request Information, Details, Approval & Billing, Attachments)
+
+### 18.0.1.0.1
+- **Propiedades editables de campos en templates**: Agregado soporte para `label_custom`, `placeholder`, `default_value`, `help_text`, `widget`, y `selection_options` (igual que form builder nativo)
+- **Protección de campos obligatorios**: Los campos `required=True` a nivel de modelo no pueden ser eliminados del template (`model_required=True`)
+- **Condiciones de visibilidad mejoradas**: 
+  - Soporte dinámico para campos many2one con widget personalizado que cambia el modelo según la dependencia
+  - Soporte dinámico para campos selection con opciones del campo de dependencia
+  - Widget `DynamicMany2OneField` para manejar many2one con modelo dinámico
+- **Mejoras en generación de formularios**: Uso de `ir.ui.view.save()` para formato nativo de Odoo
+- **Campos automáticos en templates**: Los campos obligatorios del modelo se agregan automáticamente al crear un template
+- **Pestaña "Fields" por defecto**: La pestaña de campos es la primera al abrir un template
+- **Widget JavaScript personalizado**: `HelpdeskTemplateFieldListRenderer` para ocultar botón eliminar en campos `model_required`
+
+### 18.0.1.0.0
+- Versión inicial
+- Compartir equipos con partners externos
+- Widget de horas disponibles
+- Estadísticas de horas en backend
+- Bloqueo de formularios
+- **Sistema de tipos de solicitud** (`helpdesk.request.type`)
+- **Campos extendidos en tickets** (módulo afectado, impacto, pasos de reproducción, etc.)
+- **Sistema de templates** para formularios web personalizables
+- **Condiciones de visibilidad dinámicas** en formularios
+- **Regeneración automática de formularios** basada en templates
+- **Integración completa con form builder nativo** de Odoo

+ 5 - 0
__init__.py

@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+
+from . import models
+from . import wizard
+from . import controllers

+ 59 - 0
__manifest__.py

@@ -0,0 +1,59 @@
+{
+    "name": "Helpdesk Extras",
+    "version": "18.0.1.0.2",
+    "category": "Services/Helpdesk",
+    "summary": "Funcionalidades extras para Helpdesk - Compartir equipos y widget de horas",
+    "description": """
+Helpdesk Extras
+================
+Funcionalidades adicionales para el módulo de Helpdesk:
+
+* Compartir equipos de helpdesk con partners externos
+* Control de acceso granular por partner:
+    - Admin: ver todos los tickets, gestionar otros usuarios
+    - Usuario todos: ver todos los tickets y crear propios
+    - Usuario propios: solo crear y ver tickets propios
+* Widget de horas disponibles para sitio web:
+    - Barra de progreso mostrando horas utilizadas vs disponibles
+    - Desglose de horas por prepago y crédito
+    - Disponible como bloque de contenido en el editor de sitio web
+    """,
+    "author": "M22 Tech",
+    "website": "https://www.m22tech.com",
+    "depends": ["helpdesk", "website", "website_helpdesk", "sale", "account"],
+    "data": [
+        "security/ir.model.access.csv",
+        "security/helpdesk_security.xml",
+        "data/helpdesk_request_type_data.xml",
+        "data/helpdesk_form_data.xml",
+        "data/helpdesk_workflow_template_data.xml",
+        "wizard/helpdesk_team_share_wizard_views.xml",
+        "wizard/helpdesk_workflow_template_apply_wizard_views.xml",
+        "views/helpdesk_request_type_views.xml",
+        "views/helpdesk_template_views.xml",
+        "views/helpdesk_workflow_template_views.xml",
+        "views/helpdesk_team_views.xml",
+        "views/helpdesk_ticket_views.xml",
+        "views/helpdesk_portal_templates.xml",
+        "views/website_helpdesk_form.xml",
+        "views/snippets/s_helpdesk_hours.xml",
+        "views/snippets/snippets.xml",
+    ],
+    "assets": {
+        "web.assets_backend": [
+            "helpdesk_extras/static/src/js/helpdesk_template_field_list.js",
+            "helpdesk_extras/static/src/js/helpdesk_template_field_m2o_widget.js",
+            "helpdesk_extras/static/src/xml/helpdesk_template_field_list.xml",
+            "helpdesk_extras/static/src/xml/helpdesk_template_field_m2o_widget.xml",
+        ],
+        "web.assets_frontend": [
+            "helpdesk_extras/static/src/js/website_helpdesk_form_block.js",
+            "helpdesk_extras/static/src/snippets/s_helpdesk_hours/000.js",
+            "helpdesk_extras/static/src/snippets/s_helpdesk_hours/000.scss",
+        ],
+    },
+    "installable": True,
+    "application": False,
+    "auto_install": False,
+    "license": "LGPL-3",
+}

+ 6 - 0
controllers/__init__.py

@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import website_helpdesk_hours
+from . import helpdesk_portal
+from . import website_form

+ 635 - 0
controllers/helpdesk_portal.py

@@ -0,0 +1,635 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import http
+from odoo.http import request
+from odoo.addons.helpdesk.controllers.portal import (
+    CustomerPortal as HelpdeskCustomerPortal,
+)
+from odoo.exceptions import AccessError, MissingError
+
+
+class CustomerPortal(HelpdeskCustomerPortal):
+    """Extend CustomerPortal to show tickets block for collaborators without ticket count condition"""
+
+    def _prepare_home_portal_values(self, counters):
+        values = super()._prepare_home_portal_values(counters)
+        if "ticket_count" in counters:
+            # Check if user is portal and is a collaborator in any team
+            if request.env.user and request.env.user._is_portal():
+                partner = request.env.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:
+                    # If user is collaborator, ensure block is always shown
+                    # by setting ticket_count to at least 1
+                    # This removes the ticket count condition for collaborators
+                    current_count = values.get("ticket_count", 0)
+                    if current_count == 0:
+                        # Force ticket_count to 1 so the block is always shown for collaborators
+                        values["ticket_count"] = 1
+
+            # If not collaborator or not portal, use default behavior
+            # (values already calculated by parent)
+        return values
+
+    def _prepare_my_tickets_values(
+        self,
+        page=1,
+        date_begin=None,
+        date_end=None,
+        sortby=None,
+        filterby="all",
+        search=None,
+        groupby="none",
+        search_in="name",
+    ):
+        values = super()._prepare_my_tickets_values(
+            page, date_begin, date_end, sortby, filterby, search, groupby, search_in
+        )
+
+        # Check if user is portal and is a collaborator in any team
+        if request.env.user and request.env.user._is_portal():
+            partner = request.env.user.partner_id
+            commercial_partner = partner.commercial_partner_id
+
+            # Get teams where user is a collaborator (try both partner_id and commercial_partner_id)
+            collaborator_records = (
+                request.env["helpdesk.team.collaborator"]
+                .sudo()
+                .search(
+                    [
+                        "|",
+                        ("partner_id", "=", partner.id),
+                        ("partner_id", "=", commercial_partner.id),
+                    ]
+                )
+            )
+
+            # Get teams where user is a collaborator and has website form enabled
+            collaborator_teams = collaborator_records.mapped("team_id").filtered(
+                lambda t: t.use_website_helpdesk_form
+            )
+
+            # DEBUG: Log para verificar qué está pasando
+            import logging
+
+            _logger = logging.getLogger(__name__)
+            _logger.info(
+                f"DEBUG: User {request.env.user.name} (partner_id={partner.id}, commercial_partner_id={commercial_partner.id})"
+            )
+            _logger.info(
+                f"DEBUG: Found {len(collaborator_records)} collaborator records"
+            )
+            _logger.info(
+                f"DEBUG: Found {len(collaborator_teams)} teams with website form enabled"
+            )
+
+            # Filter by website published for portal users (managers see all)
+            # TEMPORALMENTE: Comentado para debug - verificar si el problema es el filtro
+            # if not request.env.user.has_group("helpdesk.group_helpdesk_manager"):
+            #     collaborator_teams = collaborator_teams.filtered(
+            #         lambda t: t.website_published and t.website_id == request.website
+            #     )
+
+            # If user is collaborator in teams with website form, get first available team
+            _logger.info(
+                f"DEBUG: collaborator_teams length = {len(collaborator_teams)}"
+            )
+            _logger.info(f"DEBUG: collaborator_teams ids = {collaborator_teams.ids}")
+
+            if collaborator_teams:
+                # Get the first team (can be improved to select based on priority)
+                available_team = collaborator_teams[0]
+                # Build URL to the team form with contact_form parameter
+                team_url = available_team.website_url
+                team_url += "/?contact_form=1"
+
+                values["collaborator_team"] = available_team
+                values["collaborator_team_form_url"] = team_url
+
+                _logger.info(f"DEBUG: Set collaborator_team_form_url = {team_url}")
+                _logger.info(
+                    f"DEBUG: Team name = {available_team.name}, website_url = {available_team.website_url}"
+                )
+            else:
+                # Always set the value, even if False, for template debugging
+                _logger.info(
+                    "DEBUG: No collaborator teams found, setting form_url to False"
+                )
+                values["collaborator_team_form_url"] = False
+            
+            # Get teams where user is admin for "Manage Collaborators" button
+            admin_teams = self._get_teams_where_admin()
+            values["admin_teams"] = admin_teams
+            _logger.info(f"DEBUG: admin_teams count = {len(admin_teams)}")
+            for team in admin_teams:
+                _logger.info(f"DEBUG: admin team = {team.name} (ID: {team.id})")
+        else:
+            # Not portal user
+            values["collaborator_team_form_url"] = False
+            values["admin_teams"] = request.env['helpdesk.team']
+
+        return values
+
+    def _get_teams_where_admin(self):
+        """Get teams where current user is admin collaborator"""
+        if not request.env.user or not request.env.user._is_portal():
+            return request.env['helpdesk.team']
+        
+        partner = request.env.user.partner_id
+        collaborator_records = request.env['helpdesk.team.collaborator'].search([
+            ('partner_id', '=', partner.id),
+            ('access_mode', '=', 'admin'),
+        ])
+        return collaborator_records.mapped('team_id')
+
+    @http.route(['/my/helpdesk/teams/<int:team_id>/collaborators'], type='http', auth="user", website=True)
+    def portal_team_collaborators(self, team_id=None, **kw):
+        """Page to manage collaborators for a team (only for admin users)"""
+        from odoo import _
+        team = request.env['helpdesk.team'].browse([team_id])
+        if not team.exists():
+            raise MissingError(_("This team does not exist."))
+        
+        # Check if user is admin of this team
+        admin_teams = self._get_teams_where_admin()
+        if team not in admin_teams:
+            raise AccessError(_("You don't have permission to manage collaborators for this team."))
+        
+        # Get admin's commercial_partner_id to filter available partners
+        admin_user = request.env.user
+        admin_commercial_partner_id = admin_user.partner_id.commercial_partner_id.id
+        
+        # Get all collaborators of the team (use sudo() to bypass security rules for admin users)
+        # Filter to only show collaborators from the same network
+        all_collaborators = team.sudo().collaborator_ids
+        collaborators = all_collaborators.filtered(
+            lambda c: c.partner_id.commercial_partner_id.id == admin_commercial_partner_id
+        ).sorted(lambda c: c.partner_id.name or '')
+        
+        values = {
+            'team': team,
+            'collaborators': collaborators,
+            'page_name': 'team_collaborators',
+            'admin_commercial_partner_id': admin_commercial_partner_id,
+            'current_partner_id': admin_user.partner_id.id,
+        }
+        
+        return request.render('helpdesk_extras.portal_team_collaborators', values)
+    
+    @http.route(['/my/helpdesk/teams/<int:team_id>/collaborators/new'], type='http', auth="user", website=True)
+    def portal_new_collaborator(self, team_id=None, **kw):
+        """Page to create a new contact and add as collaborator"""
+        from odoo import _
+        team = request.env['helpdesk.team'].browse([team_id])
+        if not team.exists():
+            raise MissingError(_("This team does not exist."))
+        
+        # Check if user is admin of this team
+        admin_teams = self._get_teams_where_admin()
+        if team not in admin_teams:
+            raise AccessError(_("You don't have permission to manage collaborators for this team."))
+        
+        values = {
+            'team': team,
+            'page_name': 'new_collaborator',
+        }
+        
+        return request.render('helpdesk_extras.portal_new_collaborator', values)
+
+    @http.route(['/my/helpdesk/teams/<int:team_id>/collaborators/add'], type='http', auth="user", website=True, methods=['POST'], csrf=True)
+    def portal_add_collaborator(self, team_id=None, partner_id=None, access_mode='user_own', **kw):
+        """Add a new collaborator to the team (only for admin users)"""
+        from odoo import _
+        from odoo.exceptions import ValidationError
+        
+        team = request.env['helpdesk.team'].browse([team_id])
+        if not team.exists():
+            request.session['error'] = _("This team does not exist.")
+            return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+        
+        # Check if user is admin of this team
+        admin_teams = self._get_teams_where_admin()
+        if team not in admin_teams:
+            request.session['error'] = _("You don't have permission to manage collaborators for this team.")
+            return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+        
+        # Get admin's commercial_partner_id
+        admin_user = request.env.user
+        admin_commercial_partner_id = admin_user.partner_id.commercial_partner_id.id
+        
+        # Validate partner_id
+        if not partner_id:
+            request.session['error'] = _("Partner is required.")
+            return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+        
+        try:
+            partner_id = int(partner_id)
+            partner = request.env['res.partner'].sudo().browse([partner_id])
+            
+            if not partner.exists():
+                request.session['error'] = _("Partner does not exist.")
+                return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+            
+            # Validate partner belongs to same network
+            if partner.commercial_partner_id.id != admin_commercial_partner_id:
+                request.session['error'] = _("You can only add contacts from your company network.")
+                return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+            
+            # Validate partner is external
+            if not partner.partner_share:
+                request.session['error'] = _("Only external partners can be added as collaborators.")
+                return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+            
+            # Validate access_mode
+            if access_mode not in ['admin', 'user_all', 'user_own']:
+                access_mode = 'user_own'
+            
+            # Check if collaborator already exists
+            existing = team.sudo().collaborator_ids.filtered(lambda c: c.partner_id.id == partner_id)
+            if existing:
+                request.session['error'] = _("This partner is already a collaborator.")
+                return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+            
+            # Create collaborator
+            team.sudo().write({
+                'collaborator_ids': [(0, 0, {
+                    'partner_id': partner_id,
+                    'access_mode': access_mode,
+                })]
+            })
+            
+            # Subscribe partner to team messages
+            team.sudo().message_subscribe(partner_ids=[partner_id])
+            
+            request.session['success'] = _("Collaborator added successfully.")
+            
+        except (ValueError, TypeError, ValidationError) as e:
+            request.session['error'] = str(e)
+        
+        return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+    
+    @http.route(['/my/helpdesk/teams/<int:team_id>/collaborators/<int:collaborator_id>/update'], type='http', auth="user", website=True, methods=['POST'], csrf=True)
+    def portal_update_collaborator(self, team_id=None, collaborator_id=None, access_mode=None, **kw):
+        """Update a collaborator's access mode (only for admin users)"""
+        from odoo import _
+        
+        team = request.env['helpdesk.team'].browse([team_id])
+        if not team.exists():
+            request.session['error'] = _("This team does not exist.")
+            return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+        
+        # Check if user is admin of this team
+        admin_teams = self._get_teams_where_admin()
+        if team not in admin_teams:
+            request.session['error'] = _("You don't have permission to manage collaborators for this team.")
+            return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+        
+        # Get admin's commercial_partner_id
+        admin_user = request.env.user
+        admin_commercial_partner_id = admin_user.partner_id.commercial_partner_id.id
+        
+        # Find collaborator
+        collaborator = team.sudo().collaborator_ids.filtered(lambda c: c.id == collaborator_id)
+        if not collaborator:
+            request.session['error'] = _("Collaborator not found.")
+            return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+        
+        # Validate collaborator belongs to same network
+        if collaborator.partner_id.commercial_partner_id.id != admin_commercial_partner_id:
+            request.session['error'] = _("You can only manage contacts from your company network.")
+            return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+        
+        # Validate access_mode
+        if access_mode and access_mode in ['admin', 'user_all', 'user_own']:
+            collaborator.sudo().write({'access_mode': access_mode})
+            request.session['success'] = _("Collaborator updated successfully.")
+        else:
+            request.session['error'] = _("Invalid access mode.")
+        
+        return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+    
+    @http.route(['/my/helpdesk/teams/<int:team_id>/collaborators/<int:collaborator_id>/delete'], type='http', auth="user", website=True, methods=['POST'], csrf=True)
+    def portal_delete_collaborator(self, team_id=None, collaborator_id=None, **kw):
+        """Delete a collaborator from the team (only for admin users)"""
+        from odoo import _
+        
+        team = request.env['helpdesk.team'].browse([team_id])
+        if not team.exists():
+            request.session['error'] = _("This team does not exist.")
+            return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+        
+        # Check if user is admin of this team
+        admin_teams = self._get_teams_where_admin()
+        if team not in admin_teams:
+            request.session['error'] = _("You don't have permission to manage collaborators for this team.")
+            return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+        
+        # Get admin's commercial_partner_id
+        admin_user = request.env.user
+        admin_commercial_partner_id = admin_user.partner_id.commercial_partner_id.id
+        
+        # Find collaborator
+        collaborator = team.sudo().collaborator_ids.filtered(lambda c: c.id == collaborator_id)
+        if not collaborator:
+            request.session['error'] = _("Collaborator not found.")
+            return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+        
+        # Validate collaborator belongs to same network
+        if collaborator.partner_id.commercial_partner_id.id != admin_commercial_partner_id:
+            request.session['error'] = _("You can only manage contacts from your company network.")
+            return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+        
+        # Prevent self-deletion
+        current_partner = admin_user.partner_id
+        if collaborator.partner_id.id == current_partner.id:
+            request.session['error'] = _("You cannot remove yourself as a collaborator.")
+            return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+        
+        # Delete collaborator
+        collaborator.sudo().unlink()
+        request.session['success'] = _("Collaborator removed successfully.")
+        
+        return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+    
+    @http.route(['/my/helpdesk/teams/<int:team_id>/collaborators/search-partners'], type='json', auth="user", website=True, methods=['POST'], csrf=False)
+    def portal_search_partners(self, team_id=None, search_term='', limit=20, **kw):
+        """Search partners in the same commercial_partner_id network"""
+        from odoo import _
+        team = request.env['helpdesk.team'].browse([team_id])
+        if not team.exists():
+            return {'error': _("This team does not exist.")}
+        
+        # Check if user is admin of this team
+        admin_teams = self._get_teams_where_admin()
+        if team not in admin_teams:
+            return {'error': _("You don't have permission to manage collaborators for this team.")}
+        
+        # Get admin's commercial_partner_id
+        admin_user = request.env.user
+        admin_partner = admin_user.partner_id
+        admin_commercial_partner = admin_partner.commercial_partner_id
+        
+        # Search partners in the same network
+        # Use 'id' with 'child_of' to find all contacts in the commercial partner network
+        # This recursively finds:
+        # - The commercial partner itself
+        # - All child contacts (using parent_id relationship)
+        # This is the standard Odoo way to find all related contacts
+        domain = [
+            ('id', 'child_of', admin_commercial_partner.id),
+            ('partner_share', '=', True),
+            '|',
+            ('name', 'ilike', search_term),
+            ('email', 'ilike', search_term),
+        ]
+        
+        # Also exclude contacts that are already collaborators in this team
+        existing_collaborator_ids = team.sudo().collaborator_ids.mapped('partner_id').ids
+        if existing_collaborator_ids:
+            domain.append(('id', 'not in', existing_collaborator_ids))
+        
+        partners = request.env['res.partner'].sudo().search(domain, limit=limit)
+        
+        # Debug logging
+        import logging
+        _logger = logging.getLogger(__name__)
+        _logger.info(f"Search partners - term: '{search_term}', found: {len(partners)}")
+        for p in partners:
+            _logger.info(f"  - {p.name} (ID: {p.id}, email: {p.email})")
+        
+        result = {
+            'partners': [
+                {
+                    'id': p.id,
+                    'name': p.name,
+                    'email': p.email or '',
+                    'display_name': p.display_name,
+                }
+                for p in partners
+            ]
+        }
+        
+        _logger.info(f"Returning result: {len(result['partners'])} partners")
+        return result
+    
+    @http.route(['/my/helpdesk/teams/<int:team_id>/collaborators/create'], type='http', auth="user", website=True, methods=['POST'], csrf=True)
+    def portal_create_collaborator(self, team_id=None, **kw):
+        """Create a new contact and add as collaborator in one step"""
+        from odoo import _
+        from odoo.exceptions import ValidationError
+        from odoo.tools import email_normalize
+        
+        team = request.env['helpdesk.team'].browse([team_id])
+        if not team.exists():
+            request.session['error'] = _("This team does not exist.")
+            return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+        
+        # Check if user is admin of this team
+        admin_teams = self._get_teams_where_admin()
+        if team not in admin_teams:
+            request.session['error'] = _("You don't have permission to manage collaborators for this team.")
+            return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+        
+        # Get form data
+        name = kw.get('name', '').strip()
+        email = kw.get('email', '').strip()
+        phone = kw.get('phone', '').strip()
+        function_ = kw.get('function', '').strip()
+        access_mode = kw.get('access_mode', 'user_own')
+        
+        # Validate inputs
+        if not name:
+            request.session['error'] = _("Name is required.")
+            return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators/new')
+        
+        if not email:
+            request.session['error'] = _("Email is required.")
+            return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators/new')
+        
+        # Validate and normalize email
+        email_normalized = email_normalize(email)
+        if not email_normalized:
+            request.session['error'] = _("Invalid email address format.")
+            return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators/new')
+        
+        # Validate access_mode
+        if access_mode not in ['admin', 'user_all', 'user_own']:
+            access_mode = 'user_own'
+        
+        # Check if email already exists
+        existing_partner = request.env['res.partner'].sudo().search([
+            ('email_normalized', '=', email_normalized)
+        ], limit=1)
+        if existing_partner:
+            # Check if already a collaborator
+            existing_collaborator = team.sudo().collaborator_ids.filtered(
+                lambda c: c.partner_id.id == existing_partner.id
+            )
+            if existing_collaborator:
+                request.session['error'] = _("This contact is already a collaborator in this team.")
+                return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+            
+            # Use existing partner
+            partner = existing_partner
+        else:
+            # Get admin's commercial_partner_id
+            admin_user = request.env.user
+            admin_commercial_partner = admin_user.partner_id.commercial_partner_id
+            
+            # Create new partner
+            try:
+                partner_vals = {
+                    'name': name,
+                    'email': email_normalized,
+                    'partner_share': True,
+                    'type': 'contact',
+                    'parent_id': admin_commercial_partner.id,
+                }
+                
+                if phone:
+                    partner_vals['phone'] = phone
+                
+                if function_:
+                    partner_vals['function'] = function_
+                
+                partner = request.env['res.partner'].sudo().create(partner_vals)
+                
+                # Log creation
+                import logging
+                _logger = logging.getLogger(__name__)
+                _logger.info(f"Created new partner: {partner.name} (ID: {partner.id}) by admin {admin_user.name}")
+            except Exception as e:
+                import logging
+                _logger = logging.getLogger(__name__)
+                _logger.error(f"Error creating partner: {str(e)}", exc_info=True)
+                request.session['error'] = _("An error occurred while creating the contact. Please try again.")
+                return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators/new')
+        
+        # Add as collaborator
+        try:
+            # Check if already a collaborator
+            existing_collaborator = team.sudo().collaborator_ids.filtered(
+                lambda c: c.partner_id.id == partner.id
+            )
+            if existing_collaborator:
+                # Update access mode
+                existing_collaborator.sudo().write({'access_mode': access_mode})
+                request.session['success'] = _("Collaborator updated successfully.")
+            else:
+                # Create new collaborator
+                team.sudo().write({
+                    'collaborator_ids': [(0, 0, {
+                        'partner_id': partner.id,
+                        'access_mode': access_mode,
+                    })]
+                })
+                
+                # Subscribe partner to team messages
+                team.sudo().message_subscribe(partner_ids=[partner.id])
+                
+                request.session['success'] = _("Contact created and added as collaborator successfully.")
+        except Exception as e:
+            import logging
+            _logger = logging.getLogger(__name__)
+            _logger.error(f"Error adding collaborator: {str(e)}", exc_info=True)
+            request.session['error'] = _("An error occurred while adding the collaborator. Please try again.")
+            return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators/new')
+        
+        return request.redirect(f'/my/helpdesk/teams/{team_id}/collaborators')
+    
+    @http.route(['/my/helpdesk/teams/<int:team_id>/collaborators/create-partner'], type='json', auth="user", website=True, methods=['POST'], csrf=False)
+    def portal_create_partner(self, team_id=None, name='', email='', phone='', function='', **kw):
+        """Create a new partner in the same commercial_partner_id network"""
+        from odoo import _
+        from odoo.exceptions import ValidationError
+        from odoo.tools import email_normalize
+        
+        team = request.env['helpdesk.team'].browse([team_id])
+        if not team.exists():
+            return {'error': _("This team does not exist.")}
+        
+        # Check if user is admin of this team
+        admin_teams = self._get_teams_where_admin()
+        if team not in admin_teams:
+            return {'error': _("You don't have permission to manage collaborators for this team.")}
+        
+        # Validate inputs
+        if not name or not name.strip():
+            return {'error': _("Name is required.")}
+        
+        name = name.strip()
+        
+        # Validate and normalize email if provided
+        email_normalized = None
+        if email and email.strip():
+            email = email.strip()
+            email_normalized = email_normalize(email)
+            if not email_normalized:
+                return {'error': _("Invalid email address format.")}
+            
+            # Check if email already exists
+            existing_partner = request.env['res.partner'].sudo().search([
+                ('email_normalized', '=', email_normalized)
+            ], limit=1)
+            if existing_partner:
+                return {
+                    'error': _("A contact with email %s already exists.") % email,
+                    'existing_partner_id': existing_partner.id,
+                    'existing_partner_name': existing_partner.name,
+                }
+        
+        # Get admin's commercial_partner_id
+        admin_user = request.env.user
+        admin_commercial_partner = admin_user.partner_id.commercial_partner_id
+        
+        # Create new partner
+        try:
+            partner_vals = {
+                'name': name,
+                'partner_share': True,
+                'type': 'contact',
+            }
+            
+            if email_normalized:
+                partner_vals['email'] = email_normalized
+            
+            if phone and phone.strip():
+                partner_vals['phone'] = phone.strip()
+            
+            if function and function.strip():
+                partner_vals['function'] = function.strip()
+            
+            # Set parent to commercial_partner (creates as child contact)
+            partner_vals['parent_id'] = admin_commercial_partner.id
+            
+            partner = request.env['res.partner'].sudo().create(partner_vals)
+            
+            # Log creation
+            import logging
+            _logger = logging.getLogger(__name__)
+            _logger.info(f"Created new partner: {partner.name} (ID: {partner.id}) by admin {admin_user.name}")
+            
+            return {
+                'partner': {
+                    'id': partner.id,
+                    'name': partner.name,
+                    'email': partner.email or '',
+                    'display_name': partner.display_name,
+                }
+            }
+        except ValidationError as e:
+            return {'error': str(e)}
+        except Exception as e:
+            import logging
+            _logger = logging.getLogger(__name__)
+            _logger.error(f"Error creating partner: {str(e)}", exc_info=True)
+            return {'error': _("An error occurred while creating the contact. Please try again.")}

+ 22 - 0
controllers/website_form.py

@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+import logging
+from odoo.http import request
+from odoo.addons.website_helpdesk.controllers.main import WebsiteForm
+
+_logger = logging.getLogger(__name__)
+
+
+class WebsiteForm(WebsiteForm):
+    def _handle_website_form(self, model_name, **kwargs):
+        if model_name == "helpdesk.ticket":
+            email = kwargs.get("partner_email") or request.params.get("partner_email")
+            # Si no hay email o está vacío, y el usuario está logueado (no es público)
+            if not email and not request.env.user._is_public():
+                partner = request.env.user.partner_id
+                _logger.info(
+                    f"Helpdesk Extras: Assigning partner_id {partner.id} to ticket (User: {request.env.user.name})"
+                )
+                request.params["partner_id"] = partner.id
+                kwargs["partner_id"] = partner.id
+
+        return super(WebsiteForm, self)._handle_website_form(model_name, **kwargs)

+ 419 - 0
controllers/website_helpdesk_hours.py

@@ -0,0 +1,419 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+
+from odoo import http
+from odoo.http import request
+from odoo.osv import expression
+
+_logger = logging.getLogger(__name__)
+
+
+class WebsiteHelpdeskHours(http.Controller):
+    """Controller for helpdesk hours widget"""
+
+    @http.route("/helpdesk/hours/available", type="json", auth="user", website=True)
+    def get_available_hours(self):
+        """
+        Calculate available hours for the authenticated portal user's partner.
+
+        Returns:
+            dict: {
+                'total_available': float,  # Total hours available
+                'hours_used': float,       # Hours already delivered/used
+                'prepaid_hours': float,    # Hours from prepaid orders (not delivered)
+                'credit_hours': float,      # Hours calculated from available credit
+                'credit_available': float, # Available credit amount
+                'highest_price': float,    # Highest price unit for hours
+            }
+        """
+        try:
+            # Get contact information early for use in all return cases
+            company = request.env.company
+            config_param = request.env["ir.config_parameter"].sudo()
+            whatsapp_number = config_param.get_param(
+                "helpdesk_extras.whatsapp_number", ""
+            )
+            company_email = company.email or ""
+            packages_url = config_param.get_param(
+                "helpdesk_extras.packages_url", "/shop"
+            )
+
+            # Check if user is portal
+            if not request.env.user._is_portal():
+                return {
+                    "error": "Access denied: User is not a portal user",
+                    "total_available": 0.0,
+                    "hours_used": 0.0,
+                    "prepaid_hours": 0.0,
+                    "credit_hours": 0.0,
+                    "credit_available": 0.0,
+                    "highest_price": 0.0,
+                    "whatsapp_number": whatsapp_number,
+                    "email": company_email,
+                    "packages_url": packages_url,
+                }
+
+            partner = request.env.user.partner_id.commercial_partner_id
+
+            # Get UoM hour reference (use sudo to access uom.uom)
+            try:
+                uom_hour = request.env.ref("uom.product_uom_hour").sudo()
+            except Exception as e:
+                return {
+                    "error": f"Error getting UoM hour: {str(e)}",
+                    "total_available": 0.0,
+                    "hours_used": 0.0,
+                    "prepaid_hours": 0.0,
+                    "credit_hours": 0.0,
+                    "credit_available": 0.0,
+                    "highest_price": 0.0,
+                    "whatsapp_number": whatsapp_number,
+                    "email": company_email,
+                    "packages_url": packages_url,
+                }
+
+            # Get helpdesk teams where this user is a collaborator
+            collaborator_teams = (
+                request.env["helpdesk.team.collaborator"]
+                .sudo()
+                .search([("partner_id", "=", partner.id)])
+                .mapped("team_id")
+            )
+
+            # If user is not a collaborator in any team, return empty results
+            if not collaborator_teams:
+                return {
+                    "total_available": 0.0,
+                    "hours_used": 0.0,
+                    "prepaid_hours": 0.0,
+                    "credit_hours": 0.0,
+                    "credit_available": 0.0,
+                    "highest_price": 0.0,
+                    "whatsapp_number": whatsapp_number,
+                    "email": company_email,
+                    "packages_url": packages_url,
+                }
+
+            # Get all prepaid sale order lines for the partner
+            # Following Odoo's standard procedure from helpdesk_sale_timesheet
+            SaleOrderLine = request.env["sale.order.line"].sudo()
+
+            # Use the same domain that Odoo uses in _get_last_sol_of_customer
+            # This ensures we follow Odoo's standard procedure
+            domain = [
+                ("company_id", "=", company.id),
+                ("order_partner_id", "child_of", partner.id),
+                ("state", "in", ["sale", "done"]),
+                ("remaining_hours", ">", 0),  # Only lines with remaining hours
+            ]
+
+            # Check if sale_timesheet module is installed
+            has_sale_timesheet = "sale_timesheet" in request.env.registry._init_modules
+            if has_sale_timesheet:
+                # Use _domain_sale_line_service to filter service products correctly
+                # This is the same method Odoo uses internally in _get_last_sol_of_customer
+                try:
+                    service_domain = SaleOrderLine._domain_sale_line_service(
+                        check_state=False
+                    )
+                    # Combine domains using expression.AND() as Odoo does
+                    domain = expression.AND([domain, service_domain])
+                except Exception:
+                    # Fallback if _domain_sale_line_service is not available
+                    domain = expression.AND(
+                        [
+                            domain,
+                            [
+                                ("product_id.type", "=", "service"),
+                                ("product_id.service_policy", "=", "ordered_prepaid"),
+                                ("remaining_hours_available", "=", True),
+                            ],
+                        ]
+                    )
+
+            # Search for prepaid lines following Odoo's standard procedure
+            prepaid_sol_lines = SaleOrderLine.search(domain)
+
+            # Filter lines from orders that have received payment
+            # Only consider hours from orders with paid invoices
+            helpdesk_team_model = request.env["helpdesk.team"]
+
+            # Filter lines from orders that have received payment
+            # Use explicit loop to handle exceptions properly
+            paid_prepaid_lines = request.env["sale.order.line"].sudo()
+            for line in prepaid_sol_lines:
+                try:
+                    if helpdesk_team_model._is_order_paid(line.order_id):
+                        paid_prepaid_lines |= line
+                except Exception as e:
+                    # Log exception only in debug mode
+                    _logger.debug(
+                        "Error checking payment for line %s, order %s: %s",
+                        line.id,
+                        line.order_id.id,
+                        str(e),
+                        exc_info=True
+                    )
+
+            # Calculate prepaid hours using Odoo's remaining_hours field
+            # This is the correct way as it handles UOM conversion automatically
+            prepaid_hours = 0.0
+            highest_price = 0.0
+
+            for line in paid_prepaid_lines:
+                # Use remaining_hours directly (already in hours, handles UOM conversion)
+                # This is the field Odoo uses and calculates correctly
+                remaining = line.remaining_hours or 0.0
+                prepaid_hours += max(0.0, remaining)
+
+                # Track highest price unit
+                if line.price_unit > highest_price:
+                    highest_price = line.price_unit
+
+            # If no paid lines with price, try to get price from all prepaid lines (historical)
+            # This is needed to calculate credit_hours even if there are no paid lines currently
+            if highest_price == 0 and prepaid_sol_lines:
+                for line in prepaid_sol_lines:
+                    if line.price_unit > highest_price:
+                        highest_price = line.price_unit
+
+            # Calculate hours used from ALL prepaid lines (including those fully consumed)
+            # This gives a complete picture of hours used by the customer
+            hours_used_domain = [
+                ("company_id", "=", company.id),
+                ("order_partner_id", "child_of", partner.id),
+                ("state", "in", ["sale", "done"]),
+            ]
+
+            if has_sale_timesheet:
+                try:
+                    service_domain = SaleOrderLine._domain_sale_line_service(
+                        check_state=False
+                    )
+                    hours_used_domain = expression.AND(
+                        [hours_used_domain, service_domain]
+                    )
+                except Exception:
+                    hours_used_domain = expression.AND(
+                        [
+                            hours_used_domain,
+                            [
+                                ("product_id.type", "=", "service"),
+                                ("product_id.service_policy", "=", "ordered_prepaid"),
+                                ("remaining_hours_available", "=", True),
+                            ],
+                        ]
+                    )
+
+            all_prepaid_lines = SaleOrderLine.search(hours_used_domain)
+
+            # Filter lines from orders that have received payment
+            # Only consider hours used from orders with paid invoices
+            # Use explicit loop to handle exceptions properly
+            paid_all_prepaid_lines = request.env["sale.order.line"].sudo()
+            for line in all_prepaid_lines:
+                try:
+                    if helpdesk_team_model._is_order_paid(line.order_id):
+                        paid_all_prepaid_lines |= line
+                except Exception as e:
+                    # Log exception only in debug mode
+                    _logger.debug(
+                        "Error checking payment for line %s, order %s: %s",
+                        line.id,
+                        line.order_id.id,
+                        str(e),
+                        exc_info=True
+                    )
+
+            hours_used = 0.0
+
+            for line in paid_all_prepaid_lines:
+                # Calculate hours used: qty_delivered converted to hours
+                # Use the same UOM conversion that Odoo uses
+                qty_delivered = line.qty_delivered or 0.0
+                if qty_delivered > 0:
+                    qty_delivered_hours = (
+                        line.product_uom._compute_quantity(
+                            qty_delivered, uom_hour, raise_if_failure=False
+                        )
+                        or 0.0
+                    )
+                    hours_used += qty_delivered_hours
+
+            # Calculate credit hours
+            credit_hours = 0.0
+            credit_available = 0.0
+
+            # Check if credit limit is configured
+            # Use sudo to access credit fields which may have restricted access
+            partner_sudo = partner.sudo()
+            if company.account_use_credit_limit and partner_sudo.credit_limit > 0:
+                credit_used = partner_sudo.credit or 0.0
+                credit_available = max(0.0, partner_sudo.credit_limit - credit_used)
+
+                # Convert credit to hours using highest price
+                if highest_price > 0 and credit_available > 0:
+                    credit_hours = credit_available / highest_price
+                elif highest_price == 0 and credit_available > 0:
+                    # If no hours sold yet, we can't calculate credit hours
+                    # But we still show the credit available
+                    credit_hours = 0.0
+
+            total_available = prepaid_hours + credit_hours
+
+            return {
+                "total_available": round(total_available, 2),
+                "hours_used": round(hours_used, 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),
+                "whatsapp_number": whatsapp_number,
+                "email": company_email,
+                "packages_url": packages_url,
+            }
+        except Exception as e:
+            # Log critical errors with full traceback
+            _logger.error(
+                "Error in get_available_hours for partner %s: %s",
+                request.env.user.partner_id.id if request.env.user else "unknown",
+                str(e),
+                exc_info=True
+            )
+            # Get contact information for error case
+            try:
+                company = request.env.company
+                config_param = request.env["ir.config_parameter"].sudo()
+                whatsapp_number = config_param.get_param(
+                    "helpdesk_extras.whatsapp_number", ""
+                )
+                company_email = company.email or ""
+                packages_url = config_param.get_param(
+                    "helpdesk_extras.packages_url", "/shop"
+                )
+            except:
+                whatsapp_number = ""
+                company_email = ""
+                packages_url = "/shop"
+            return {
+                "error": f"Error al calcular horas disponibles: {str(e)}",
+                "total_available": 0.0,
+                "hours_used": 0.0,
+                "prepaid_hours": 0.0,
+                "credit_hours": 0.0,
+                "credit_available": 0.0,
+                "highest_price": 0.0,
+                "whatsapp_number": whatsapp_number,
+                "email": company_email,
+                "packages_url": packages_url,
+            }
+
+    @http.route("/helpdesk/form/check_block", type="json", auth="public", website=True)
+    def check_form_block(self, team_id=None):
+        """
+        Check if the helpdesk ticket form should be blocked.
+        Returns True if form should be blocked (has collaborators and no available hours).
+
+        Args:
+            team_id: ID of the helpdesk team
+
+        Returns:
+            dict: {
+                'should_block': bool,  # True if form should be blocked
+                'has_collaborators': bool,  # True if team has collaborators
+                'has_hours': bool,  # True if user has available hours
+                'message': str,  # Message to show if blocked
+            }
+        """
+        try:
+            # If user is not portal or public, don't block
+            if not request.env.user or not request.env.user._is_portal():
+                return {
+                    "should_block": False,
+                    "has_collaborators": False,
+                    "has_hours": True,
+                    "message": "",
+                }
+
+            if not team_id:
+                return {
+                    "should_block": False,
+                    "has_collaborators": False,
+                    "has_hours": True,
+                    "message": "",
+                }
+
+            # Get the team
+            team = request.env["helpdesk.team"].sudo().browse(team_id)
+            if not team.exists():
+                return {
+                    "should_block": False,
+                    "has_collaborators": False,
+                    "has_hours": True,
+                    "message": "",
+                }
+
+            # Check if team has collaborators
+            has_collaborators = bool(team.collaborator_ids)
+
+            # If no collaborators, don't block
+            if not has_collaborators:
+                return {
+                    "should_block": False,
+                    "has_collaborators": False,
+                    "has_hours": True,
+                    "message": "",
+                }
+
+            # Check if user has available hours
+            hours_data = self.get_available_hours()
+            has_hours = hours_data.get("total_available", 0.0) > 0.0
+
+            # Block only if has collaborators AND no hours
+            should_block = has_collaborators and not has_hours
+
+            # Get contact information for message
+            config_param = request.env["ir.config_parameter"].sudo()
+            whatsapp_number = config_param.get_param(
+                "helpdesk_extras.whatsapp_number", ""
+            )
+            company_email = request.env.company.email or ""
+            packages_url = config_param.get_param(
+                "helpdesk_extras.packages_url", "/shop"
+            )
+
+            message = ""
+            if should_block:
+                message = "No tienes horas disponibles para crear un ticket. Por favor, contacta con nosotros para adquirir más horas."
+                if whatsapp_number or company_email:
+                    contact_info = []
+                    if whatsapp_number:
+                        contact_info.append(f"WhatsApp: {whatsapp_number}")
+                    if company_email:
+                        contact_info.append(f"Email: {company_email}")
+                    if contact_info:
+                        message += " " + " | ".join(contact_info)
+
+            return {
+                "should_block": should_block,
+                "has_collaborators": has_collaborators,
+                "has_hours": has_hours,
+                "message": message,
+            }
+        except Exception as e:
+            # Log critical errors with full traceback
+            _logger.error(
+                "Error in check_form_block for team_id %s: %s",
+                team_id,
+                str(e),
+                exc_info=True
+            )
+            # On error, don't block to avoid breaking the form
+            return {
+                "should_block": False,
+                "has_collaborators": False,
+                "has_hours": True,
+                "message": "",
+            }

+ 15 - 0
data/helpdesk_form_data.xml

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <!-- Extend formbuilder whitelist to include new fields -->
+    <function model="ir.model.fields" name="formbuilder_whitelist">
+        <value>helpdesk.ticket</value>
+        <value eval="[
+            'request_type_id',
+            'affected_module_id',
+            'business_impact',
+            'reproduce_steps',
+            'business_goal',
+            'client_authorization',
+        ]"/>
+    </function>
+</odoo>

+ 18 - 0
data/helpdesk_request_type_data.xml

@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo noupdate="1">
+    <!-- Request Type: Incident -->
+    <record id="type_incident" model="helpdesk.request.type">
+        <field name="name">Incident</field>
+        <field name="code">incident</field>
+        <field name="is_billable_default">False</field>
+        <field name="active">True</field>
+    </record>
+
+    <!-- Request Type: Improvement -->
+    <record id="type_improvement" model="helpdesk.request.type">
+        <field name="name">Improvement</field>
+        <field name="code">improvement</field>
+        <field name="is_billable_default">True</field>
+        <field name="active">True</field>
+    </record>
+</odoo>

+ 263 - 0
data/helpdesk_workflow_template_data.xml

@@ -0,0 +1,263 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo noupdate="1">
+    
+    <!-- Basic Support Workflow Template -->
+    <record id="workflow_template_basic_support" model="helpdesk.workflow.template">
+        <field name="name">Basic Support</field>
+        <field name="description">Simple workflow with 3 stages: New, In Progress, and Solved. Includes basic SLA policies for response and resolution times.</field>
+        <field name="active">True</field>
+        <field name="stage_template_ids" eval="[]"/>
+        <field name="sla_template_ids" eval="[]"/>
+    </record>
+
+    <!-- Premium Support Workflow Template -->
+    <record id="workflow_template_premium_support" model="helpdesk.workflow.template">
+        <field name="name">Premium Support</field>
+        <field name="description">Enhanced workflow with 4 stages including On Hold. Faster SLA policies for premium customers.</field>
+        <field name="active">True</field>
+        <field name="stage_template_ids" eval="[]"/>
+        <field name="sla_template_ids" eval="[]"/>
+    </record>
+
+    <!-- Development Workflow Template -->
+    <record id="workflow_template_development" model="helpdesk.workflow.template">
+        <field name="name">Development</field>
+        <field name="description">Workflow for development teams with stages: New, Analysis, In Progress, Testing, and Done. Includes SLA policies by priority.</field>
+        <field name="active">True</field>
+        <field name="stage_template_ids" eval="[]"/>
+        <field name="sla_template_ids" eval="[]"/>
+    </record>
+
+    <!-- Stage Template References for Basic Support -->
+    <record id="stage_template_new" model="helpdesk.workflow.template.stage">
+        <field name="template_id" ref="workflow_template_basic_support"/>
+        <field name="name">New</field>
+        <field name="sequence">0</field>
+        <field name="fold">False</field>
+        <field name="legend_blocked">Blocked</field>
+        <field name="legend_done">Ready</field>
+        <field name="legend_normal">In Progress</field>
+    </record>
+    <record id="stage_template_in_progress" model="helpdesk.workflow.template.stage">
+        <field name="template_id" ref="workflow_template_basic_support"/>
+        <field name="name">In Progress</field>
+        <field name="sequence">1</field>
+        <field name="fold">False</field>
+        <field name="legend_blocked">Blocked</field>
+        <field name="legend_done">Ready</field>
+        <field name="legend_normal">In Progress</field>
+    </record>
+    <record id="stage_template_solved" model="helpdesk.workflow.template.stage">
+        <field name="template_id" ref="workflow_template_basic_support"/>
+        <field name="name">Solved</field>
+        <field name="sequence">2</field>
+        <field name="fold">True</field>
+        <field name="legend_blocked">Blocked</field>
+        <field name="legend_done">Ready</field>
+        <field name="legend_normal">In Progress</field>
+    </record>
+
+    <!-- Stage Template References for Premium Support -->
+    <record id="stage_template_new_premium" model="helpdesk.workflow.template.stage">
+        <field name="template_id" ref="workflow_template_premium_support"/>
+        <field name="name">New</field>
+        <field name="sequence">0</field>
+        <field name="fold">False</field>
+        <field name="legend_blocked">Blocked</field>
+        <field name="legend_done">Ready</field>
+        <field name="legend_normal">In Progress</field>
+    </record>
+    <record id="stage_template_in_progress_premium" model="helpdesk.workflow.template.stage">
+        <field name="template_id" ref="workflow_template_premium_support"/>
+        <field name="name">In Progress</field>
+        <field name="sequence">1</field>
+        <field name="fold">False</field>
+        <field name="legend_blocked">Blocked</field>
+        <field name="legend_done">Ready</field>
+        <field name="legend_normal">In Progress</field>
+    </record>
+    <record id="stage_template_on_hold_premium" model="helpdesk.workflow.template.stage">
+        <field name="template_id" ref="workflow_template_premium_support"/>
+        <field name="name">On Hold</field>
+        <field name="sequence">2</field>
+        <field name="fold">False</field>
+        <field name="legend_blocked">Blocked</field>
+        <field name="legend_done">Ready</field>
+        <field name="legend_normal">In Progress</field>
+    </record>
+    <record id="stage_template_solved_premium" model="helpdesk.workflow.template.stage">
+        <field name="template_id" ref="workflow_template_premium_support"/>
+        <field name="name">Solved</field>
+        <field name="sequence">3</field>
+        <field name="fold">True</field>
+        <field name="legend_blocked">Blocked</field>
+        <field name="legend_done">Ready</field>
+        <field name="legend_normal">In Progress</field>
+    </record>
+
+    <!-- Stage Template References for Development -->
+    <record id="stage_template_new_dev" model="helpdesk.workflow.template.stage">
+        <field name="template_id" ref="workflow_template_development"/>
+        <field name="name">New</field>
+        <field name="sequence">0</field>
+        <field name="fold">False</field>
+        <field name="legend_blocked">Blocked</field>
+        <field name="legend_done">Ready</field>
+        <field name="legend_normal">In Progress</field>
+    </record>
+    <record id="stage_template_analysis" model="helpdesk.workflow.template.stage">
+        <field name="template_id" ref="workflow_template_development"/>
+        <field name="name">Analysis</field>
+        <field name="sequence">1</field>
+        <field name="fold">False</field>
+        <field name="legend_blocked">Blocked</field>
+        <field name="legend_done">Ready</field>
+        <field name="legend_normal">In Progress</field>
+    </record>
+    <record id="stage_template_in_progress_dev" model="helpdesk.workflow.template.stage">
+        <field name="template_id" ref="workflow_template_development"/>
+        <field name="name">In Progress</field>
+        <field name="sequence">2</field>
+        <field name="fold">False</field>
+        <field name="legend_blocked">Blocked</field>
+        <field name="legend_done">Ready</field>
+        <field name="legend_normal">In Progress</field>
+    </record>
+    <record id="stage_template_testing" model="helpdesk.workflow.template.stage">
+        <field name="template_id" ref="workflow_template_development"/>
+        <field name="name">Testing</field>
+        <field name="sequence">3</field>
+        <field name="fold">False</field>
+        <field name="legend_blocked">Blocked</field>
+        <field name="legend_done">Ready</field>
+        <field name="legend_normal">In Progress</field>
+    </record>
+    <record id="stage_template_done" model="helpdesk.workflow.template.stage">
+        <field name="template_id" ref="workflow_template_development"/>
+        <field name="name">Done</field>
+        <field name="sequence">4</field>
+        <field name="fold">True</field>
+        <field name="legend_blocked">Blocked</field>
+        <field name="legend_done">Ready</field>
+        <field name="legend_normal">In Progress</field>
+    </record>
+
+    <!-- SLA Templates for Basic Support -->
+    <record id="sla_template_basic_response" model="helpdesk.workflow.template.sla">
+        <field name="template_id" ref="workflow_template_basic_support"/>
+        <field name="name">Response Time - Normal Priority</field>
+        <field name="sequence">10</field>
+        <field name="stage_template_id" ref="stage_template_in_progress"/>
+        <field name="time">4.0</field>
+        <field name="priority">1</field>
+        <field name="description">&lt;p&gt;Tickets should be responded to within 4 working hours&lt;/p&gt;</field>
+    </record>
+    <record id="sla_template_basic_resolution" model="helpdesk.workflow.template.sla">
+        <field name="template_id" ref="workflow_template_basic_support"/>
+        <field name="name">Resolution Time - Normal Priority</field>
+        <field name="sequence">20</field>
+        <field name="stage_template_id" ref="stage_template_solved"/>
+        <field name="time">24.0</field>
+        <field name="priority">1</field>
+        <field name="description">&lt;p&gt;Tickets should be resolved within 24 working hours&lt;/p&gt;</field>
+    </record>
+
+    <!-- SLA Templates for Premium Support -->
+    <record id="sla_template_premium_response_normal" model="helpdesk.workflow.template.sla">
+        <field name="template_id" ref="workflow_template_premium_support"/>
+        <field name="name">Response Time - Normal Priority</field>
+        <field name="sequence">10</field>
+        <field name="stage_template_id" ref="stage_template_in_progress_premium"/>
+        <field name="time">2.0</field>
+        <field name="priority">1</field>
+        <field name="description">&lt;p&gt;Premium tickets should be responded to within 2 working hours&lt;/p&gt;</field>
+        <field name="exclude_stage_template_ids" eval="[(4, ref('stage_template_on_hold_premium'))]"/>
+    </record>
+    <record id="sla_template_premium_resolution_normal" model="helpdesk.workflow.template.sla">
+        <field name="template_id" ref="workflow_template_premium_support"/>
+        <field name="name">Resolution Time - Normal Priority</field>
+        <field name="sequence">20</field>
+        <field name="stage_template_id" ref="stage_template_solved_premium"/>
+        <field name="time">8.0</field>
+        <field name="priority">1</field>
+        <field name="description">&lt;p&gt;Premium tickets should be resolved within 8 working hours&lt;/p&gt;</field>
+        <field name="exclude_stage_template_ids" eval="[(4, ref('stage_template_on_hold_premium'))]"/>
+    </record>
+    <record id="sla_template_premium_response_high" model="helpdesk.workflow.template.sla">
+        <field name="template_id" ref="workflow_template_premium_support"/>
+        <field name="name">Response Time - High Priority</field>
+        <field name="sequence">30</field>
+        <field name="stage_template_id" ref="stage_template_in_progress_premium"/>
+        <field name="time">1.0</field>
+        <field name="priority">2</field>
+        <field name="description">&lt;p&gt;High priority premium tickets should be responded to within 1 working hour&lt;/p&gt;</field>
+        <field name="exclude_stage_template_ids" eval="[(4, ref('stage_template_on_hold_premium'))]"/>
+    </record>
+    <record id="sla_template_premium_resolution_high" model="helpdesk.workflow.template.sla">
+        <field name="template_id" ref="workflow_template_premium_support"/>
+        <field name="name">Resolution Time - High Priority</field>
+        <field name="sequence">40</field>
+        <field name="stage_template_id" ref="stage_template_solved_premium"/>
+        <field name="time">4.0</field>
+        <field name="priority">2</field>
+        <field name="description">&lt;p&gt;High priority premium tickets should be resolved within 4 working hours&lt;/p&gt;</field>
+        <field name="exclude_stage_template_ids" eval="[(4, ref('stage_template_on_hold_premium'))]"/>
+    </record>
+
+    <!-- SLA Templates for Development -->
+    <record id="sla_template_dev_analysis_normal" model="helpdesk.workflow.template.sla">
+        <field name="template_id" ref="workflow_template_development"/>
+        <field name="name">Analysis Time - Normal Priority</field>
+        <field name="sequence">10</field>
+        <field name="stage_template_id" ref="stage_template_analysis"/>
+        <field name="time">8.0</field>
+        <field name="priority">1</field>
+        <field name="description">&lt;p&gt;Normal priority tickets should be analyzed within 8 working hours&lt;/p&gt;</field>
+    </record>
+    <record id="sla_template_dev_completion_normal" model="helpdesk.workflow.template.sla">
+        <field name="template_id" ref="workflow_template_development"/>
+        <field name="name">Completion Time - Normal Priority</field>
+        <field name="sequence">20</field>
+        <field name="stage_template_id" ref="stage_template_done"/>
+        <field name="time">40.0</field>
+        <field name="priority">1</field>
+        <field name="description">&lt;p&gt;Normal priority tickets should be completed within 40 working hours&lt;/p&gt;</field>
+    </record>
+    <record id="sla_template_dev_analysis_high" model="helpdesk.workflow.template.sla">
+        <field name="template_id" ref="workflow_template_development"/>
+        <field name="name">Analysis Time - High Priority</field>
+        <field name="sequence">30</field>
+        <field name="stage_template_id" ref="stage_template_analysis"/>
+        <field name="time">4.0</field>
+        <field name="priority">2</field>
+        <field name="description">&lt;p&gt;High priority tickets should be analyzed within 4 working hours&lt;/p&gt;</field>
+    </record>
+    <record id="sla_template_dev_completion_high" model="helpdesk.workflow.template.sla">
+        <field name="template_id" ref="workflow_template_development"/>
+        <field name="name">Completion Time - High Priority</field>
+        <field name="sequence">40</field>
+        <field name="stage_template_id" ref="stage_template_done"/>
+        <field name="time">16.0</field>
+        <field name="priority">2</field>
+        <field name="description">&lt;p&gt;High priority tickets should be completed within 16 working hours&lt;/p&gt;</field>
+    </record>
+    <record id="sla_template_dev_analysis_urgent" model="helpdesk.workflow.template.sla">
+        <field name="template_id" ref="workflow_template_development"/>
+        <field name="name">Analysis Time - Urgent Priority</field>
+        <field name="sequence">50</field>
+        <field name="stage_template_id" ref="stage_template_analysis"/>
+        <field name="time">2.0</field>
+        <field name="priority">3</field>
+        <field name="description">&lt;p&gt;Urgent tickets should be analyzed within 2 working hours&lt;/p&gt;</field>
+    </record>
+    <record id="sla_template_dev_completion_urgent" model="helpdesk.workflow.template.sla">
+        <field name="template_id" ref="workflow_template_development"/>
+        <field name="name">Completion Time - Urgent Priority</field>
+        <field name="sequence">60</field>
+        <field name="stage_template_id" ref="stage_template_done"/>
+        <field name="time">8.0</field>
+        <field name="priority">3</field>
+        <field name="description">&lt;p&gt;Urgent tickets should be completed within 8 working hours&lt;/p&gt;</field>
+    </record>
+
+</odoo>

+ 443 - 0
i18n/en_US.po

@@ -0,0 +1,443 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * helpdesk_extras
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 18.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2025-12-07 01:00:00+0000\n"
+"PO-Revision-Date: 2025-12-07 01:00:00+0000\n"
+"Last-Translator: M22 Tech\n"
+"Language-Team: English (US)\n"
+"Language: en_US\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template,name:helpdesk_extras.workflow_template_basic_support
+msgid "Basic Support"
+msgstr "Basic Support"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template,name:helpdesk_extras.workflow_template_premium_support
+msgid "Premium Support"
+msgstr "Premium Support"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template,name:helpdesk_extras.workflow_template_development
+msgid "Development"
+msgstr "Development"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template,description:helpdesk_extras.workflow_template_basic_support
+msgid "Simple workflow with 3 stages: New, In Progress, and Solved. Includes basic SLA policies for response and resolution times."
+msgstr "Simple workflow with 3 stages: New, In Progress, and Solved. Includes basic SLA policies for response and resolution times."
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template,description:helpdesk_extras.workflow_template_premium_support
+msgid "Enhanced workflow with 4 stages including On Hold. Faster SLA policies for premium customers."
+msgstr "Enhanced workflow with 4 stages including On Hold. Faster SLA policies for premium customers."
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template,description:helpdesk_extras.workflow_template_development
+msgid "Workflow for development teams with stages: New, Analysis, In Progress, Testing, and Done. Includes SLA policies by priority."
+msgstr "Workflow for development teams with stages: New, Analysis, In Progress, Testing, and Done. Includes SLA policies by priority."
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_new
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_new_premium
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_new_dev
+msgid "New"
+msgstr "New"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_in_progress
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_in_progress_premium
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_in_progress_dev
+msgid "In Progress"
+msgstr "In Progress"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_solved
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_solved_premium
+msgid "Solved"
+msgstr "Solved"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_on_hold_premium
+msgid "On Hold"
+msgstr "On Hold"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_analysis
+msgid "Analysis"
+msgstr "Analysis"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_testing
+msgid "Testing"
+msgstr "Testing"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_done
+msgid "Done"
+msgstr "Done"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_new
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_in_progress
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_solved
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_new_premium
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_in_progress_premium
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_on_hold_premium
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_solved_premium
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_new_dev
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_analysis
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_in_progress_dev
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_testing
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_done
+msgid "Blocked"
+msgstr "Blocked"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_new
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_in_progress
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_solved
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_new_premium
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_in_progress_premium
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_on_hold_premium
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_solved_premium
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_new_dev
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_analysis
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_in_progress_dev
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_testing
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_done
+msgid "Ready"
+msgstr "Ready"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_new
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_in_progress
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_solved
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_new_premium
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_in_progress_premium
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_on_hold_premium
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_solved_premium
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_new_dev
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_analysis
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_in_progress_dev
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_testing
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_done
+msgid "In Progress"
+msgstr "In Progress"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_basic_response
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_premium_response_normal
+msgid "Response Time - Normal Priority"
+msgstr "Response Time - Normal Priority"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_premium_response_high
+msgid "Response Time - High Priority"
+msgstr "Response Time - High Priority"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_basic_resolution
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_premium_resolution_normal
+msgid "Resolution Time - Normal Priority"
+msgstr "Resolution Time - Normal Priority"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_premium_resolution_high
+msgid "Resolution Time - High Priority"
+msgstr "Resolution Time - High Priority"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_dev_analysis_normal
+msgid "Analysis Time - Normal Priority"
+msgstr "Analysis Time - Normal Priority"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_dev_completion_normal
+msgid "Completion Time - Normal Priority"
+msgstr "Completion Time - Normal Priority"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_dev_analysis_high
+msgid "Analysis Time - High Priority"
+msgstr "Analysis Time - High Priority"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_dev_completion_high
+msgid "Completion Time - High Priority"
+msgstr "Completion Time - High Priority"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_dev_analysis_urgent
+msgid "Analysis Time - Urgent Priority"
+msgstr "Analysis Time - Urgent Priority"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_dev_completion_urgent
+msgid "Completion Time - Urgent Priority"
+msgstr "Completion Time - Urgent Priority"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_basic_response
+msgid "<p>Tickets should be responded to within 4 working hours</p>"
+msgstr "<p>Tickets should be responded to within 4 working hours</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_basic_resolution
+msgid "<p>Tickets should be resolved within 24 working hours</p>"
+msgstr "<p>Tickets should be resolved within 24 working hours</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_premium_response_normal
+msgid "<p>Premium tickets should be responded to within 2 working hours</p>"
+msgstr "<p>Premium tickets should be responded to within 2 working hours</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_premium_resolution_normal
+msgid "<p>Premium tickets should be resolved within 8 working hours</p>"
+msgstr "<p>Premium tickets should be resolved within 8 working hours</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_premium_response_high
+msgid "<p>High priority premium tickets should be responded to within 1 working hour</p>"
+msgstr "<p>High priority premium tickets should be responded to within 1 working hour</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_premium_resolution_high
+msgid "<p>High priority premium tickets should be resolved within 4 working hours</p>"
+msgstr "<p>High priority premium tickets should be resolved within 4 working hours</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_dev_analysis_normal
+msgid "<p>Normal priority tickets should be analyzed within 8 working hours</p>"
+msgstr "<p>Normal priority tickets should be analyzed within 8 working hours</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_dev_completion_normal
+msgid "<p>Normal priority tickets should be completed within 40 working hours</p>"
+msgstr "<p>Normal priority tickets should be completed within 40 working hours</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_dev_analysis_high
+msgid "<p>High priority tickets should be analyzed within 4 working hours</p>"
+msgstr "<p>High priority tickets should be analyzed within 4 working hours</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_dev_completion_high
+msgid "<p>High priority tickets should be completed within 16 working hours</p>"
+msgstr "<p>High priority tickets should be completed within 16 working hours</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_dev_analysis_urgent
+msgid "<p>Urgent tickets should be analyzed within 2 working hours</p>"
+msgstr "<p>Urgent tickets should be analyzed within 2 working hours</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_dev_completion_urgent
+msgid "<p>Urgent tickets should be completed within 8 working hours</p>"
+msgstr "<p>Urgent tickets should be completed within 8 working hours</p>"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_tree
+msgid "Workflow Templates"
+msgstr "Workflow Templates"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_tree
+msgid "Stages"
+msgstr "Stages"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_tree
+msgid "SLA Policies"
+msgstr "SLA Policies"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_tree
+msgid "Teams Using"
+msgstr "Teams Using"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_tree
+msgid "Total Stages"
+msgstr "Total Stages"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_tree
+msgid "Total SLAs"
+msgstr "Total SLAs"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_form
+msgid "Teams Using This Template"
+msgstr "Teams Using This Template"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_form
+msgid "Workflow Template"
+msgstr "Workflow Template"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_form
+msgid "Excluded Stages"
+msgstr "Excluded Stages"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_form
+msgid "SLA Policy"
+msgstr "SLA Policy"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_form
+msgid "Stages where time spent will NOT count towards the SLA deadline. Useful for 'On Hold' or 'Waiting for Customer' stages."
+msgstr "Stages where time spent will NOT count towards the SLA deadline. Useful for 'On Hold' or 'Waiting for Customer' stages."
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_search
+msgid "Template Name"
+msgstr "Template Name"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_search
+msgid "Active"
+msgstr "Active"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_search
+msgid "Archived"
+msgstr "Archived"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_search
+msgid "Has Stages"
+msgstr "Has Stages"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_search
+msgid "Has SLAs"
+msgstr "Has SLAs"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_search
+msgid "Group By"
+msgstr "Group By"
+
+#. module: helpdesk_extras
+#: model:ir.ui.menu,name:helpdesk_extras.menu_helpdesk_workflow_template
+msgid "Workflow Templates"
+msgstr "Workflow Templates"
+
+#. module: helpdesk_extras
+#: model:ir.actions.act_window,name:helpdesk_extras.helpdesk_workflow_template_action
+msgid "Workflow Templates"
+msgstr "Workflow Templates"
+
+#. module: helpdesk_extras
+#: model:ir.actions.act_window,help:helpdesk_extras.helpdesk_workflow_template_action
+msgid "Create your first workflow template!"
+msgstr "Create your first workflow template!"
+
+#. module: helpdesk_extras
+#: model:ir.actions.act_window,help:helpdesk_extras.helpdesk_workflow_template_action
+msgid "Workflow templates allow you to quickly set up stages and SLA policies for helpdesk teams. Create a template with predefined stages and SLAs, then apply it to any team with one click."
+msgstr "Workflow templates allow you to quickly set up stages and SLA policies for helpdesk teams. Create a template with predefined stages and SLAs, then apply it to any team with one click."
+
+#. module: helpdesk_extras
+#: model:ir.actions.act_window,name:helpdesk_extras.action_helpdesk_workflow_template_apply_wizard
+msgid "Apply Workflow Template"
+msgstr "Apply Workflow Template"
+
+#. module: helpdesk_extras
+#: model:ir.model.fields,field_description:helpdesk_extras.field_helpdesk_workflow_template_apply_wizard__team_id
+msgid "Team"
+msgstr "Team"
+
+#. module: helpdesk_extras
+#: model:ir.model.fields,field_description:helpdesk_extras.field_helpdesk_workflow_template_apply_wizard__workflow_template_id
+msgid "Workflow Template"
+msgstr "Workflow Template"
+
+#. module: helpdesk_extras
+#: model:ir.model.fields,field_description:helpdesk_extras.field_helpdesk_workflow_template_apply_wizard__replace_existing
+msgid "Replace Existing Stages and SLAs"
+msgstr "Replace Existing Stages and SLAs"
+
+#. module: helpdesk_extras
+#: model:ir.model.fields,help:helpdesk_extras.field_helpdesk_workflow_template_apply_wizard__replace_existing
+msgid "If checked, existing stages and SLAs will be removed before applying the template"
+msgstr "If checked, existing stages and SLAs will be removed before applying the template"
+
+#. module: helpdesk_extras
+#: model:ir.model.fields,field_description:helpdesk_extras.field_helpdesk_workflow_template_apply_wizard__stage_count
+msgid "Stages to Create"
+msgstr "Stages to Create"
+
+#. module: helpdesk_extras
+#: model:ir.model.fields,field_description:helpdesk_extras.field_helpdesk_workflow_template_apply_wizard__sla_count
+msgid "SLA Policies to Create"
+msgstr "SLA Policies to Create"
+
+#. module: helpdesk_extras
+#: model:ir.model.fields,field_description:helpdesk_extras.field_helpdesk_workflow_template_apply_wizard__existing_stage_count
+msgid "Existing Stages"
+msgstr "Existing Stages"
+
+#. module: helpdesk_extras
+#: model:ir.model.fields,field_description:helpdesk_extras.field_helpdesk_workflow_template_apply_wizard__existing_sla_count
+msgid "Existing SLA Policies"
+msgstr "Existing SLA Policies"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_apply_wizard_form
+msgid "Apply Template"
+msgstr "Apply Template"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_apply_wizard_form
+msgid "Summary"
+msgstr "Summary"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_team_view_form_inherit_helpdesk_extras
+msgid "Select a workflow template to quickly set up stages and SLA policies"
+msgstr "Select a workflow template to quickly set up stages and SLA policies"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_kanban
+msgid "Stages"
+msgstr "Stages"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_kanban
+msgid "SLA Policies"
+msgstr "SLA Policies"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_kanban
+msgid "team(s)"
+msgstr "team(s)"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_form
+msgid "Teams"
+msgstr "Teams"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_form
+msgid "e.g., Basic Support, Premium Support"
+msgstr "e.g., Basic Support, Premium Support"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_form
+msgid "Describe this workflow template..."
+msgstr "Describe this workflow template..."
+

+ 454 - 0
i18n/es_MX.po

@@ -0,0 +1,454 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * helpdesk_extras
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 18.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2025-12-07 01:00:00+0000\n"
+"PO-Revision-Date: 2025-12-07 01:00:00+0000\n"
+"Last-Translator: M22 Tech\n"
+"Language-Team: Spanish (Mexico)\n"
+"Language: es_MX\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template,name:helpdesk_extras.workflow_template_basic_support
+msgid "Basic Support"
+msgstr "Soporte Básico"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template,name:helpdesk_extras.workflow_template_premium_support
+msgid "Premium Support"
+msgstr "Soporte Premium"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template,name:helpdesk_extras.workflow_template_development
+msgid "Development"
+msgstr "Desarrollo"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template,description:helpdesk_extras.workflow_template_basic_support
+msgid "Simple workflow with 3 stages: New, In Progress, and Solved. Includes basic SLA policies for response and resolution times."
+msgstr "Flujo de trabajo simple con 3 etapas: Nuevo, En Progreso y Resuelto. Incluye políticas SLA básicas para tiempos de respuesta y resolución."
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template,description:helpdesk_extras.workflow_template_premium_support
+msgid "Enhanced workflow with 4 stages including On Hold. Faster SLA policies for premium customers."
+msgstr "Flujo de trabajo mejorado con 4 etapas incluyendo En Espera. Políticas SLA más rápidas para clientes premium."
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template,description:helpdesk_extras.workflow_template_development
+msgid "Workflow for development teams with stages: New, Analysis, In Progress, Testing, and Done. Includes SLA policies by priority."
+msgstr "Flujo de trabajo para equipos de desarrollo con etapas: Nuevo, Análisis, En Progreso, Pruebas y Completado. Incluye políticas SLA por prioridad."
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_new
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_new_premium
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_new_dev
+msgid "New"
+msgstr "Nuevo"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_in_progress
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_in_progress_premium
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_in_progress_dev
+msgid "In Progress"
+msgstr "En Progreso"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_solved
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_solved_premium
+msgid "Solved"
+msgstr "Resuelto"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_on_hold_premium
+msgid "On Hold"
+msgstr "En Espera"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_analysis
+msgid "Analysis"
+msgstr "Análisis"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_testing
+msgid "Testing"
+msgstr "Pruebas"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.stage,name:helpdesk_extras.stage_template_done
+msgid "Done"
+msgstr "Completado"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_new
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_in_progress
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_solved
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_new_premium
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_in_progress_premium
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_on_hold_premium
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_solved_premium
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_new_dev
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_analysis
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_in_progress_dev
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_testing
+#: model:helpdesk.workflow.template.stage,legend_blocked:helpdesk_extras.stage_template_done
+msgid "Blocked"
+msgstr "Bloqueado"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_new
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_in_progress
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_solved
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_new_premium
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_in_progress_premium
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_on_hold_premium
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_solved_premium
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_new_dev
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_analysis
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_in_progress_dev
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_testing
+#: model:helpdesk.workflow.template.stage,legend_done:helpdesk_extras.stage_template_done
+msgid "Ready"
+msgstr "Listo"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_new
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_in_progress
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_solved
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_new_premium
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_in_progress_premium
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_on_hold_premium
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_solved_premium
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_new_dev
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_analysis
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_in_progress_dev
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_testing
+#: model:helpdesk.workflow.template.stage,legend_normal:helpdesk_extras.stage_template_done
+msgid "In Progress"
+msgstr "En Progreso"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_basic_response
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_premium_response_normal
+msgid "Response Time - Normal Priority"
+msgstr "Tiempo de Respuesta - Prioridad Normal"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_basic_resolution
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_premium_resolution_normal
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_premium_resolution_high
+msgid "Resolution Time - Normal Priority"
+msgstr "Tiempo de Resolución - Prioridad Normal"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_premium_response_high
+msgid "Response Time - High Priority"
+msgstr "Tiempo de Respuesta - Prioridad Alta"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_basic_resolution
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_premium_resolution_normal
+msgid "Resolution Time - Normal Priority"
+msgstr "Tiempo de Resolución - Prioridad Normal"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_premium_resolution_high
+msgid "Resolution Time - High Priority"
+msgstr "Tiempo de Resolución - Prioridad Alta"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_dev_analysis_normal
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_dev_analysis_high
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_dev_analysis_urgent
+msgid "Analysis Time - Normal Priority"
+msgstr "Tiempo de Análisis - Prioridad Normal"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_dev_completion_normal
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_dev_completion_high
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_dev_completion_urgent
+msgid "Completion Time - Normal Priority"
+msgstr "Tiempo de Finalización - Prioridad Normal"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_dev_analysis_high
+msgid "Analysis Time - High Priority"
+msgstr "Tiempo de Análisis - Prioridad Alta"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_dev_completion_high
+msgid "Completion Time - High Priority"
+msgstr "Tiempo de Finalización - Prioridad Alta"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_dev_analysis_urgent
+msgid "Analysis Time - Urgent Priority"
+msgstr "Tiempo de Análisis - Prioridad Urgente"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,name:helpdesk_extras.sla_template_dev_completion_urgent
+msgid "Completion Time - Urgent Priority"
+msgstr "Tiempo de Finalización - Prioridad Urgente"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_basic_response
+msgid "<p>Tickets should be responded to within 4 working hours</p>"
+msgstr "<p>Los tickets deben ser respondidos dentro de 4 horas laborables</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_basic_resolution
+msgid "<p>Tickets should be resolved within 24 working hours</p>"
+msgstr "<p>Los tickets deben ser resueltos dentro de 24 horas laborables</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_premium_response_normal
+msgid "<p>Premium tickets should be responded to within 2 working hours</p>"
+msgstr "<p>Los tickets premium deben ser respondidos dentro de 2 horas laborables</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_premium_resolution_normal
+msgid "<p>Premium tickets should be resolved within 8 working hours</p>"
+msgstr "<p>Los tickets premium deben ser resueltos dentro de 8 horas laborables</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_premium_response_high
+msgid "<p>High priority premium tickets should be responded to within 1 working hour</p>"
+msgstr "<p>Los tickets premium de alta prioridad deben ser respondidos dentro de 1 hora laborable</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_premium_resolution_high
+msgid "<p>High priority premium tickets should be resolved within 4 working hours</p>"
+msgstr "<p>Los tickets premium de alta prioridad deben ser resueltos dentro de 4 horas laborables</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_dev_analysis_normal
+msgid "<p>Normal priority tickets should be analyzed within 8 working hours</p>"
+msgstr "<p>Los tickets de prioridad normal deben ser analizados dentro de 8 horas laborables</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_dev_completion_normal
+msgid "<p>Normal priority tickets should be completed within 40 working hours</p>"
+msgstr "<p>Los tickets de prioridad normal deben ser completados dentro de 40 horas laborables</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_dev_analysis_high
+msgid "<p>High priority tickets should be analyzed within 4 working hours</p>"
+msgstr "<p>Los tickets de alta prioridad deben ser analizados dentro de 4 horas laborables</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_dev_completion_high
+msgid "<p>High priority tickets should be completed within 16 working hours</p>"
+msgstr "<p>Los tickets de alta prioridad deben ser completados dentro de 16 horas laborables</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_dev_analysis_urgent
+msgid "<p>Urgent tickets should be analyzed within 2 working hours</p>"
+msgstr "<p>Los tickets urgentes deben ser analizados dentro de 2 horas laborables</p>"
+
+#. module: helpdesk_extras
+#: model:helpdesk.workflow.template.sla,description:helpdesk_extras.sla_template_dev_completion_urgent
+msgid "<p>Urgent tickets should be completed within 8 working hours</p>"
+msgstr "<p>Los tickets urgentes deben ser completados dentro de 8 horas laborables</p>"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_tree
+msgid "Workflow Templates"
+msgstr "Plantillas de Flujo de Trabajo"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_tree
+msgid "Stages"
+msgstr "Etapas"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_tree
+msgid "SLA Policies"
+msgstr "Políticas SLA"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_tree
+msgid "Teams Using"
+msgstr "Equipos Usando"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_tree
+msgid "Total Stages"
+msgstr "Total de Etapas"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_tree
+msgid "Total SLAs"
+msgstr "Total de SLAs"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_form
+msgid "Teams Using This Template"
+msgstr "Equipos Usando Esta Plantilla"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_form
+msgid "Workflow Template"
+msgstr "Plantilla de Flujo de Trabajo"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_form
+msgid "Excluded Stages"
+msgstr "Etapas Excluidas"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_form
+msgid "SLA Policy"
+msgstr "Política SLA"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_form
+msgid "Stages where time spent will NOT count towards the SLA deadline. Useful for 'On Hold' or 'Waiting for Customer' stages."
+msgstr "Etapas donde el tiempo transcurrido NO contará hacia el plazo del SLA. Útil para etapas como 'En Espera' o 'Esperando Cliente'."
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_search
+msgid "Template Name"
+msgstr "Nombre de Plantilla"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_search
+msgid "Active"
+msgstr "Activo"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_search
+msgid "Archived"
+msgstr "Archivado"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_search
+msgid "Has Stages"
+msgstr "Tiene Etapas"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_search
+msgid "Has SLAs"
+msgstr "Tiene SLAs"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_search
+msgid "Group By"
+msgstr "Agrupar Por"
+
+#. module: helpdesk_extras
+#: model:ir.ui.menu,name:helpdesk_extras.menu_helpdesk_workflow_template
+msgid "Workflow Templates"
+msgstr "Plantillas de Flujo de Trabajo"
+
+#. module: helpdesk_extras
+#: model:ir.actions.act_window,name:helpdesk_extras.helpdesk_workflow_template_action
+msgid "Workflow Templates"
+msgstr "Plantillas de Flujo de Trabajo"
+
+#. module: helpdesk_extras
+#: model:ir.actions.act_window,help:helpdesk_extras.helpdesk_workflow_template_action
+msgid "Create your first workflow template!"
+msgstr "¡Crea tu primera plantilla de flujo de trabajo!"
+
+#. module: helpdesk_extras
+#: model:ir.actions.act_window,help:helpdesk_extras.helpdesk_workflow_template_action
+msgid "Workflow templates allow you to quickly set up stages and SLA policies for helpdesk teams. Create a template with predefined stages and SLAs, then apply it to any team with one click."
+msgstr "Las plantillas de flujo de trabajo te permiten configurar rápidamente etapas y políticas SLA para equipos de helpdesk. Crea una plantilla con etapas y SLAs predefinidos, luego aplícala a cualquier equipo con un clic."
+
+#. module: helpdesk_extras
+#: model:ir.actions.act_window,name:helpdesk_extras.action_helpdesk_workflow_template_apply_wizard
+msgid "Apply Workflow Template"
+msgstr "Aplicar Plantilla de Flujo de Trabajo"
+
+#. module: helpdesk_extras
+#: model:ir.model.fields,field_description:helpdesk_extras.field_helpdesk_workflow_template_apply_wizard__team_id
+msgid "Team"
+msgstr "Equipo"
+
+#. module: helpdesk_extras
+#: model:ir.model.fields,field_description:helpdesk_extras.field_helpdesk_workflow_template_apply_wizard__workflow_template_id
+msgid "Workflow Template"
+msgstr "Plantilla de Flujo de Trabajo"
+
+#. module: helpdesk_extras
+#: model:ir.model.fields,field_description:helpdesk_extras.field_helpdesk_workflow_template_apply_wizard__replace_existing
+msgid "Replace Existing Stages and SLAs"
+msgstr "Reemplazar Etapas y SLAs Existentes"
+
+#. module: helpdesk_extras
+#: model:ir.model.fields,help:helpdesk_extras.field_helpdesk_workflow_template_apply_wizard__replace_existing
+msgid "If checked, existing stages and SLAs will be removed before applying the template"
+msgstr "Si está marcado, las etapas y SLAs existentes se eliminarán antes de aplicar la plantilla"
+
+#. module: helpdesk_extras
+#: model:ir.model.fields,field_description:helpdesk_extras.field_helpdesk_workflow_template_apply_wizard__stage_count
+msgid "Stages to Create"
+msgstr "Etapas a Crear"
+
+#. module: helpdesk_extras
+#: model:ir.model.fields,field_description:helpdesk_extras.field_helpdesk_workflow_template_apply_wizard__sla_count
+msgid "SLA Policies to Create"
+msgstr "Políticas SLA a Crear"
+
+#. module: helpdesk_extras
+#: model:ir.model.fields,field_description:helpdesk_extras.field_helpdesk_workflow_template_apply_wizard__existing_stage_count
+msgid "Existing Stages"
+msgstr "Etapas Existentes"
+
+#. module: helpdesk_extras
+#: model:ir.model.fields,field_description:helpdesk_extras.field_helpdesk_workflow_template_apply_wizard__existing_sla_count
+msgid "Existing SLA Policies"
+msgstr "Políticas SLA Existentes"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_apply_wizard_form
+msgid "Apply Template"
+msgstr "Aplicar Plantilla"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_apply_wizard_form
+msgid "Summary"
+msgstr "Resumen"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_team_view_form_inherit_helpdesk_extras
+msgid "Select a workflow template to quickly set up stages and SLA policies"
+msgstr "Selecciona una plantilla de flujo de trabajo para configurar rápidamente etapas y políticas SLA"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_kanban
+msgid "Stages"
+msgstr "Etapas"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_kanban
+msgid "SLA Policies"
+msgstr "Políticas SLA"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_kanban
+msgid "team(s)"
+msgstr "equipo(s)"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_form
+msgid "Teams"
+msgstr "Equipos"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_form
+msgid "e.g., Basic Support, Premium Support"
+msgstr "ej., Soporte Básico, Soporte Premium"
+
+#. module: helpdesk_extras
+#: model_terms:ir.ui.view,arch_db:helpdesk_extras.helpdesk_workflow_template_view_form
+msgid "Describe this workflow template..."
+msgstr "Describe esta plantilla de flujo de trabajo..."
+

+ 20 - 0
migrations/18.0.1.0.1/post-migration.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+"""
+Post-migration script for helpdesk_extras 18.0.1.0.1
+Ensures label_custom field is properly registered in database
+"""
+
+def migrate(cr, version):
+    """Migrate existing records to ensure label_custom field exists"""
+    try:
+        import odoo
+        from odoo.api import Environment
+        env = Environment(cr, odoo.SUPERUSER_ID, {})
+        # Call migration method on template field model
+        env['helpdesk.template.field']._migrate_label_custom_field()
+        cr.commit()
+    except Exception as e:
+        import logging
+        _logger = logging.getLogger(__name__)
+        _logger.error(f"Error in migration: {str(e)}", exc_info=True)
+

+ 10 - 0
models/__init__.py

@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+
+from . import helpdesk_team_collaborator
+from . import helpdesk_team
+from . import helpdesk_request_type
+from . import helpdesk_ticket
+from . import helpdesk_template
+from . import helpdesk_workflow_template
+from . import helpdesk_workflow_template_stage
+from . import helpdesk_workflow_template_sla

+ 36 - 0
models/helpdesk_request_type.py

@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class HelpdeskRequestType(models.Model):
+    _name = 'helpdesk.request.type'
+    _description = 'Helpdesk Request Type'
+    _order = 'name'
+
+    name = fields.Char(
+        string='Name',
+        required=True,
+        translate=True,
+        help="Name of the request type (e.g., Incident, Improvement)"
+    )
+    code = fields.Char(
+        string='Code',
+        required=True,
+        help="Internal code for logic usage (e.g., 'incident', 'improvement')"
+    )
+    is_billable_default = fields.Boolean(
+        string='Billable by Default',
+        default=False,
+        help="If True, suggests billing when creating tickets of this type"
+    )
+    active = fields.Boolean(
+        string='Active',
+        default=True,
+        help="If unchecked, this type will be hidden and won't be available for new tickets"
+    )
+
+    _sql_constraints = [
+        ('code_uniq', 'unique (code)', 'The code must be unique')
+    ]

+ 1249 - 0
models/helpdesk_team.py

@@ -0,0 +1,1249 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import json
+import logging
+from lxml import etree, html
+from odoo import api, fields, models, Command, _
+from odoo.osv import expression
+
+_logger = logging.getLogger(__name__)
+
+
+class HelpdeskTeamExtras(models.Model):
+    _inherit = "helpdesk.team"
+
+    collaborator_ids = fields.One2many(
+        "helpdesk.team.collaborator",
+        "team_id",
+        string="Collaborators",
+        copy=False,
+        export_string_translation=False,
+        help="Partners with access to this helpdesk team",
+    )
+    template_id = fields.Many2one(
+        'helpdesk.template',
+        string='Template',
+        help="Template to use for tickets in this team. If set, template fields will be shown in ticket form."
+    )
+    workflow_template_id = fields.Many2one(
+        'helpdesk.workflow.template',
+        string='Workflow Template',
+        help="Workflow template with stages and SLA policies. Use 'Apply Template' button to create stages and SLAs."
+    )
+
+    @api.model_create_multi
+    def create(self, vals_list):
+        """Override create to regenerate form XML if template is set"""
+        teams = super().create(vals_list)
+        # After create, if template is set and form view exists, regenerate
+        # This handles the case when team is created with template_id already set
+        for team in teams.filtered(lambda t: t.use_website_helpdesk_form and t.template_id and t.website_form_view_id):
+            team._regenerate_form_from_template()
+        return teams
+
+    def _ensure_submit_form_view(self):
+        """Override to regenerate form from template after creating view"""
+        result = super()._ensure_submit_form_view()
+        # After view is created, if template is set, regenerate form
+        # Note: super() may have created views, so we need to refresh to get updated website_form_view_id
+        for team in self.filtered(lambda t: t.use_website_helpdesk_form and t.template_id):
+            # Refresh to get updated website_form_view_id after super() created it
+            team.invalidate_recordset(['website_form_view_id'])
+            if team.website_form_view_id:
+                team._regenerate_form_from_template()
+        return result
+
+    def write(self, vals):
+        """Override write to regenerate form XML when template changes"""
+        result = super().write(vals)
+        if 'template_id' in vals:
+            # Regenerate form XML when template is assigned/changed
+            # After super().write(), refresh teams to get updated values
+            teams_to_process = self.browse(self.ids).filtered('use_website_helpdesk_form')
+            for team in teams_to_process:
+                # Ensure website_form_view_id exists before regenerating
+                # This handles the case when template is assigned but view doesn't exist yet
+                if not team.website_form_view_id:
+                    # Call _ensure_submit_form_view which will create the view if needed
+                    # This method already handles template regeneration if template_id is set
+                    team._ensure_submit_form_view()
+                else:
+                    # View exists, regenerate or restore form based on template
+                    if team.template_id:
+                        team._regenerate_form_from_template()
+                    else:
+                        # If template is removed, restore default form
+                        team._restore_default_form()
+        return result
+
+    # New computed fields for hours stats in backend view
+    hours_total_available = fields.Float(
+        compute="_compute_hours_stats",
+        string="Total Available Hours",
+        store=False
+    )
+    hours_total_used = fields.Float(
+        compute="_compute_hours_stats",
+        string="Total Used Hours",
+        store=False
+    )
+    hours_percentage_used = fields.Float(
+        compute="_compute_hours_stats",
+        string="Percentage Used Hours",
+        store=False
+    )
+    has_hours_stats = fields.Boolean(
+        compute="_compute_hours_stats",
+        string="Has Hours Stats",
+        store=False
+    )
+
+    def _compute_hours_stats(self):
+        """Compute hours stats for the team based on collaborators"""
+        # Check if sale_timesheet is installed
+        has_sale_timesheet = "sale_timesheet" in self.env.registry._init_modules
+
+        # Get UoM hour reference once for all teams
+        try:
+            uom_hour = self.env.ref("uom.product_uom_hour")
+        except Exception:
+            uom_hour = False
+
+        if not uom_hour:
+            for team in self:
+                team.hours_total_available = 0.0
+                team.hours_total_used = 0.0
+                team.hours_percentage_used = 0.0
+                team.has_hours_stats = False
+            return
+
+        SaleOrderLine = self.env["sale.order.line"].sudo()
+
+        for team in self:
+            # Default values
+            total_available = 0.0
+            total_used = 0.0
+            has_stats = False
+
+            # If team has collaborators, calculate their hours
+            if not team.collaborator_ids:
+                team.hours_total_available = 0.0
+                team.hours_total_used = 0.0
+                team.hours_percentage_used = 0.0
+                team.has_hours_stats = False
+                continue
+
+            # Get unique commercial partners (optimize: avoid duplicates)
+            partners = team.collaborator_ids.partner_id.commercial_partner_id
+            unique_partners = partners.filtered(lambda p: p.active).ids
+            if not unique_partners:
+                team.hours_total_available = 0.0
+                team.hours_total_used = 0.0
+                team.hours_percentage_used = 0.0
+                team.has_hours_stats = False
+                continue
+
+            # Build service domain once (reused for both queries)
+            base_service_domain = []
+            if has_sale_timesheet:
+                try:
+                    base_service_domain = SaleOrderLine._domain_sale_line_service(
+                        check_state=False
+                    )
+                except Exception:
+                    base_service_domain = [
+                        ("product_id.type", "=", "service"),
+                        ("product_id.service_policy", "=", "ordered_prepaid"),
+                        ("remaining_hours_available", "=", True),
+                    ]
+
+            # Optimize: Get all prepaid lines for all partners in one query
+            prepaid_domain = expression.AND([
+                [
+                    ("company_id", "=", team.company_id.id),
+                    ("order_partner_id", "child_of", unique_partners),
+                    ("state", "in", ["sale", "done"]),
+                    ("remaining_hours", ">", 0),
+                ],
+                base_service_domain,
+            ])
+            all_prepaid_lines = SaleOrderLine.search(prepaid_domain)
+
+            # Optimize: Get all lines for hours used calculation in one query
+            hours_used_domain = expression.AND([
+                [
+                    ("company_id", "=", team.company_id.id),
+                    ("order_partner_id", "child_of", unique_partners),
+                    ("state", "in", ["sale", "done"]),
+                ],
+                base_service_domain,
+            ])
+            all_lines = SaleOrderLine.search(hours_used_domain)
+
+            # Cache order payment status to avoid multiple checks
+            order_paid_cache = {}
+            orders_to_check = (all_prepaid_lines | all_lines).mapped("order_id")
+            for order in orders_to_check:
+                order_paid_cache[order.id] = self._is_order_paid(order)
+
+            # Group lines by commercial partner for calculation
+            partner_lines = {}
+            for line in all_prepaid_lines:
+                partner_id = line.order_partner_id.commercial_partner_id.id
+                if partner_id not in partner_lines:
+                    partner_lines[partner_id] = {"prepaid": [], "all": []}
+                partner_lines[partner_id]["prepaid"].append(line)
+
+            for line in all_lines:
+                partner_id = line.order_partner_id.commercial_partner_id.id
+                if partner_id not in partner_lines:
+                    partner_lines[partner_id] = {"prepaid": [], "all": []}
+                partner_lines[partner_id]["all"].append(line)
+
+            # Calculate stats per partner
+            for partner_id, lines_dict in partner_lines.items():
+                partner = self.env["res.partner"].browse(partner_id)
+                if not partner.exists():
+                    continue
+
+                prepaid_lines = lines_dict["prepaid"]
+                all_partner_lines = lines_dict["all"]
+
+                # 1. Calculate prepaid hours and highest price
+                highest_price = 0.0
+                prepaid_hours = 0.0
+
+                for line in prepaid_lines:
+                    order_id = line.order_id.id
+                    if order_paid_cache.get(order_id, False):
+                        prepaid_hours += max(0.0, line.remaining_hours or 0.0)
+                    # Track highest price from all lines (for credit calculation)
+                    if line.price_unit > highest_price:
+                        highest_price = line.price_unit
+
+                # 2. Calculate credit hours
+                credit_hours = 0.0
+                if (
+                    team.company_id.account_use_credit_limit
+                    and partner.credit_limit > 0
+                ):
+                    credit_used = partner.credit or 0.0
+                    credit_avail = max(0.0, partner.credit_limit - credit_used)
+                    if highest_price > 0:
+                        credit_hours = credit_avail / highest_price
+
+                total_available += prepaid_hours + credit_hours
+
+                # 3. Calculate hours used
+                for line in all_partner_lines:
+                    order_id = line.order_id.id
+                    if order_paid_cache.get(order_id, False):
+                        qty_delivered = line.qty_delivered or 0.0
+                        if qty_delivered > 0:
+                            qty_hours = (
+                                line.product_uom._compute_quantity(
+                                    qty_delivered, uom_hour, raise_if_failure=False
+                                )
+                                or 0.0
+                            )
+                            total_used += qty_hours
+
+            has_stats = total_available > 0 or total_used > 0
+
+            team.hours_total_available = total_available
+            team.hours_total_used = total_used
+            team.has_hours_stats = has_stats
+
+            # Calculate percentage
+            if has_stats:
+                grand_total = total_used + total_available
+                if grand_total > 0:
+                    team.hours_percentage_used = (total_used / grand_total) * 100
+                else:
+                    team.hours_percentage_used = 0.0
+            else:
+                team.hours_percentage_used = 0.0
+
+    def _check_helpdesk_team_sharing_access(self):
+        """Check if current user has access to this helpdesk team through sharing"""
+        self.ensure_one()
+        if self.env.user._is_portal():
+            collaborator = self.env["helpdesk.team.collaborator"].search(
+                [
+                    ("team_id", "=", self.id),
+                    ("partner_id", "=", self.env.user.partner_id.id),
+                ],
+                limit=1,
+            )
+            return collaborator
+        return self.env.user._is_internal()
+
+    def _get_new_collaborators(self, partners):
+        """Get new collaborators that can be added to the team"""
+        self.ensure_one()
+        return partners.filtered(
+            lambda partner: partner not in self.collaborator_ids.partner_id
+            and partner.partner_share
+        )
+
+    def _add_collaborators(self, partners, access_mode="user_own"):
+        """Add collaborators to the team"""
+        self.ensure_one()
+        new_collaborators = self._get_new_collaborators(partners)
+        if not new_collaborators:
+            return
+        self.write(
+            {
+                "collaborator_ids": [
+                    Command.create(
+                        {
+                            "partner_id": collaborator.id,
+                            "access_mode": access_mode,
+                        }
+                    )
+                    for collaborator in new_collaborators
+                ]
+            }
+        )
+        # Subscribe partners as followers
+        self.message_subscribe(partner_ids=new_collaborators.ids)
+
+    def action_open_share_team_wizard(self):
+        """Open the share team wizard"""
+        self.ensure_one()
+        action = self.env["ir.actions.actions"]._for_xml_id(
+            "helpdesk_extras.helpdesk_team_share_wizard_action"
+        )
+        action["context"] = {
+            "active_id": self.id,
+            "active_model": "helpdesk.team",
+            "default_res_model": "helpdesk.team",
+            "default_res_id": self.id,
+        }
+        return action
+
+    @api.model
+    def _is_order_paid(self, order):
+        """
+        Check if a sale order has received payment through its invoices.
+        Only considers orders with at least one invoice that is posted and fully paid.
+        This method can be used both in frontend and backend.
+
+        Args:
+            order: sale.order record
+
+        Returns:
+            bool: True if order has at least one paid invoice, False otherwise
+        """
+        if not order:
+            return False
+
+        # Use sudo to ensure access to invoice fields
+        order_sudo = order.sudo()
+
+        # Check if order has invoices
+        if not order_sudo.invoice_ids:
+            return False
+
+        # Check if at least one invoice is fully paid
+        # payment_state values: 'not_paid', 'partial', 'paid', 'in_payment', 'reversed', 'invoicing_legacy'
+        # We only consider invoices that are:
+        # - posted (state = 'posted')
+        # - fully paid (payment_state = 'paid')
+        paid_invoices = order_sudo.invoice_ids.filtered(
+            lambda inv: inv.state == "posted" and inv.payment_state == "paid"
+        )
+
+        # Debug: Log invoice states for troubleshooting
+        if order_sudo.invoice_ids:
+            invoice_states = []
+            for inv in order_sudo.invoice_ids:
+                try:
+                    invoice_states.append(
+                        f"Invoice {inv.id}: state={inv.state}, payment_state={getattr(inv, 'payment_state', 'N/A')}"
+                    )
+                except Exception:
+                    invoice_states.append(f"Invoice {inv.id}: error reading state")
+
+            # self.env['ir.logging'].sudo().create({
+            #     'name': 'helpdesk_extras',
+            #     'type': 'server',
+            #     'level': 'info',
+            #     'message': f'Order {order.id} - Invoice states: {"; ".join(invoice_states)} - Paid: {bool(paid_invoices)}',
+            #     'path': 'helpdesk.team',
+            #     'func': '_is_order_paid',
+            #     'line': '1',
+            # })
+
+        # Return True ONLY if at least one invoice is fully paid
+        # This is critical: we must have at least one invoice with payment_state == 'paid'
+        result = bool(paid_invoices)
+
+        # Extra verification: ensure we're really getting paid invoices
+        if result:
+            # Double-check that we have at least one invoice that is actually paid
+            verified_paid = any(
+                inv.state == "posted" and getattr(inv, "payment_state", "") == "paid"
+                for inv in order_sudo.invoice_ids
+            )
+            if not verified_paid:
+                result = False
+
+        return result
+
+    def _regenerate_form_from_template(self):
+        """Regenerate the website form XML based on the template"""
+        self.ensure_one()
+        if not self.template_id or not self.website_form_view_id:
+            return
+
+        # Get base form structure (from default template)
+        # We use the default template arch to ensure we start with a clean base
+        default_form = self.env.ref('website_helpdesk.ticket_submit_form', raise_if_not_found=False)
+        if not default_form:
+            return
+
+        # Get template fields sorted by sequence
+        template_fields = self.template_id.field_ids.sorted('sequence')
+        
+        # Log template fields for debugging
+        _logger.info(f"Regenerating form for team {self.id}, template {self.template_id.id} with {len(template_fields)} fields")
+        for tf in template_fields:
+            _logger.info(f"  - Field: {tf.field_id.name if tf.field_id else 'None'} (type: {tf.field_id.ttype if tf.field_id else 'None'})")
+        
+        # Whitelistear campos del template antes de construir el formulario
+        field_names = [tf.field_id.name for tf in template_fields 
+                      if tf.field_id and not tf.field_id.website_form_blacklisted]
+        if field_names:
+            try:
+                self.env['ir.model.fields'].formbuilder_whitelist('helpdesk.ticket', field_names)
+                _logger.info(f"Whitelisted fields: {field_names}")
+            except Exception as e:
+                _logger.warning(f"Could not whitelist fields {field_names}: {e}")
+
+        # Parse current arch to get existing description, team_id and submit button
+        root = etree.fromstring(self.website_form_view_id.arch.encode('utf-8'))
+        rows_el = root.xpath('.//div[contains(@class, "s_website_form_rows")]')
+        if not rows_el:
+            _logger.error(f"Could not find s_website_form_rows container in view {self.website_form_view_id.id}")
+            return
+        rows_el = rows_el[0]
+
+        # Get template field names to know which ones are already in template
+        template_field_names = set(tf.field_id.name for tf in template_fields if tf.field_id)
+        
+        # Get existing description, team_id and submit button HTML (to preserve them)
+        # BUT: only preserve description if it's NOT in the template
+        description_html = None
+        team_id_html = None
+        submit_button_html = None
+        
+        for child in list(rows_el):
+            classes = child.get('class', '')
+            if 's_website_form_submit' in classes:
+                submit_button_html = etree.tostring(child, encoding='unicode', pretty_print=True)
+                continue
+            
+            if 's_website_form_field' not in classes:
+                continue
+            
+            field_input = child.xpath('.//input[@name] | .//textarea[@name] | .//select[@name]')
+            if not field_input:
+                continue
+            
+            field_name = field_input[0].get('name')
+            if field_name == 'description':
+                # Only preserve description if it's NOT in the template
+                if 'description' not in template_field_names:
+                    description_html = etree.tostring(child, encoding='unicode', pretty_print=True)
+            elif field_name == 'team_id':
+                # Always preserve team_id (it's always needed, hidden)
+                team_id_html = etree.tostring(child, encoding='unicode', pretty_print=True)
+
+        # Build HTML for template fields
+        field_id_counter = 0
+        template_fields_html = []
+        for tf in template_fields:
+            try:
+                field_html, field_id_counter = self._build_template_field_html(tf, field_id_counter)
+                if field_html:
+                    template_fields_html.append(field_html)
+                    _logger.info(f"Built HTML for field {tf.field_id.name if tf.field_id else 'Unknown'}")
+            except Exception as e:
+                _logger.error(f"Error building HTML for field {tf.field_id.name if tf.field_id else 'Unknown'}: {e}", exc_info=True)
+
+        # Build complete rows container HTML
+        # Order: template fields -> description (if not in template) -> team_id -> submit button
+        rows_html_parts = []
+        
+        # Add template fields first (this includes description if it's in the template)
+        rows_html_parts.extend(template_fields_html)
+        
+        # Add description only if it exists AND is NOT in template
+        if description_html:
+            rows_html_parts.append(description_html)
+        
+        # Add team_id (always needed, hidden)
+        if team_id_html:
+            rows_html_parts.append(team_id_html)
+        
+        # Add submit button (if exists)
+        if submit_button_html:
+            rows_html_parts.append(submit_button_html)
+        
+        # Join all parts - each field HTML already has proper formatting
+        # We need to indent each field to match Odoo's formatting (32 spaces)
+        indented_parts = []
+        for part in rows_html_parts:
+            # Split by lines and indent each line
+            lines = part.split('\n')
+            indented_lines = []
+            for line in lines:
+                if line.strip():  # Only indent non-empty lines
+                    indented_lines.append('                                ' + line)
+                else:
+                    indented_lines.append('')
+            indented_parts.append('\n'.join(indented_lines))
+        
+        rows_html = '\n'.join(indented_parts)
+        
+        # Wrap in the rows container div
+        rows_container_html = f'''<div class="s_website_form_rows row s_col_no_bgcolor">
+{rows_html}
+                                </div>'''
+
+        # Use the same save method as form builder
+        try:
+            self.website_form_view_id.sudo().save(
+                rows_container_html,
+                xpath='.//div[contains(@class, "s_website_form_rows")]'
+            )
+            _logger.info(f"Successfully saved form using view.save() for team {self.id}, view {self.website_form_view_id.id}")
+        except Exception as e:
+            _logger.error(f"Error saving form with view.save(): {e}", exc_info=True)
+            raise
+
+    def _restore_default_form(self):
+        """Restore the default form when template is removed"""
+        self.ensure_one()
+        if not self.website_form_view_id:
+            return
+
+        # Get default form structure
+        default_form = self.env.ref('website_helpdesk.ticket_submit_form', raise_if_not_found=False)
+        if not default_form:
+            return
+
+        # Restore default arch
+        self.website_form_view_id.sudo().arch = default_form.arch
+
+    def _build_template_field_html(self, template_field, field_id_counter=0):
+        """Build HTML string for a template field exactly as Odoo's form builder does
+        
+        Args:
+            template_field: helpdesk.template.field record
+            field_id_counter: int, counter for generating unique field IDs (incremented and returned)
+        
+        Returns:
+            tuple: (html_string, updated_counter)
+        """
+        # Build the XML element first using existing method
+        field_el, field_id_counter = self._build_template_field_xml(template_field, field_id_counter)
+        if field_el is None:
+            return None, field_id_counter
+        
+        # Convert to HTML string with proper formatting
+        html_str = etree.tostring(field_el, encoding='unicode', pretty_print=True)
+        return html_str, field_id_counter
+
+    def _build_template_field_xml(self, template_field, field_id_counter=0):
+        """Build XML element for a template field exactly as Odoo's form builder does
+        
+        Args:
+            template_field: helpdesk.template.field record
+            field_id_counter: int, counter for generating unique field IDs (incremented and returned)
+        
+        Returns:
+            tuple: (field_element, updated_counter)
+        """
+        field = template_field.field_id
+        field_name = field.name
+        field_type = field.ttype
+        # Use custom label if provided, otherwise use field's default label
+        field_label = template_field.label_custom or field.field_description or field.name
+        required = template_field.required
+        sequence = template_field.sequence
+
+        # Generate unique ID - use counter to avoid collisions
+        field_id_counter += 1
+        field_id = f'helpdesk_{field_id_counter}_{abs(hash(field_name)) % 10000}'
+
+        # Build classes (exactly as form builder does) - CORREGIDO: mb-3 en lugar de mb-0 py-2
+        classes = ['mb-3', 's_website_form_field', 'col-12']
+        if required:
+            classes.append('s_website_form_required')
+
+        # Add visibility classes if configured (form builder uses these)
+        visibility_classes = []
+        if template_field.visibility_dependency:
+            visibility_classes.append('s_website_form_field_hidden_if')
+            visibility_classes.append('d-none')
+
+        # Create field container div (exactly as form builder does)
+        all_classes = classes + visibility_classes
+        field_div = etree.Element('div', {
+            'class': ' '.join(all_classes),
+            'data-type': field_type,
+            'data-name': 'Field'
+        })
+
+        # Add visibility attributes if configured (form builder uses these)
+        if template_field.visibility_dependency:
+            field_div.set('data-visibility-dependency', template_field.visibility_dependency.name)
+            if template_field.visibility_condition:
+                field_div.set('data-visibility-condition', template_field.visibility_condition)
+            if template_field.visibility_comparator:
+                field_div.set('data-visibility-comparator', template_field.visibility_comparator)
+            # Add visibility_between for range comparators (between/!between)
+            if template_field.visibility_comparator in ['between', '!between'] and template_field.visibility_between:
+                field_div.set('data-visibility-between', template_field.visibility_between)
+
+        # Create inner row (exactly as form builder does)
+        row_div = etree.SubElement(field_div, 'div', {
+            'class': 'row s_col_no_resize s_col_no_bgcolor'
+        })
+
+        # Create label (exactly as form builder does)
+        label = etree.SubElement(row_div, 'label', {
+            'class': 'col-form-label col-sm-auto s_website_form_label',
+            'style': 'width: 200px',
+            'for': field_id
+        })
+        label_content = etree.SubElement(label, 'span', {
+            'class': 's_website_form_label_content'
+        })
+        label_content.text = field_label
+        if required:
+            mark = etree.SubElement(label, 'span', {
+                'class': 's_website_form_mark'
+            })
+            mark.text = ' *'
+
+        # Create input container
+        input_div = etree.SubElement(row_div, 'div', {
+            'class': 'col-sm'
+        })
+
+        # Build input based on field type
+        input_el = None
+        if field_type == 'boolean':
+            # Checkbox - CORREGIDO: value debe ser 'Yes' no '1'
+            form_check = etree.SubElement(input_div, 'div', {
+                'class': 'form-check'
+            })
+            input_el = etree.SubElement(form_check, 'input', {
+                'type': 'checkbox',
+                'class': 's_website_form_input form-check-input',
+                'name': field_name,
+                'id': field_id,
+                'value': 'Yes'
+            })
+            if required:
+                input_el.set('required', '1')
+            # Set checked if default_value is 'Yes' or '1' or 'True'
+            if template_field.default_value and template_field.default_value.lower() in ('yes', '1', 'true'):
+                input_el.set('checked', 'checked')
+        elif field_type in ('text', 'html'):
+            # Textarea - CORREGIDO: eliminar atributo type (no existe en textarea)
+            input_el = etree.SubElement(input_div, 'textarea', {
+                'class': 'form-control s_website_form_input',
+                'name': field_name,
+                'id': field_id,
+                'rows': '3'
+            })
+            if template_field.placeholder:
+                input_el.set('placeholder', template_field.placeholder)
+            if required:
+                input_el.set('required', '1')
+            # Set default value as text content
+            if template_field.default_value:
+                input_el.text = template_field.default_value
+        elif field_type == 'selection':
+            # Check if custom selection options are provided (for non-relation selection fields)
+            selection_options = None
+            if template_field.selection_options and not field.relation:
+                try:
+                    selection_options = json.loads(template_field.selection_options)
+                    if not isinstance(selection_options, list):
+                        selection_options = None
+                except (json.JSONDecodeError, ValueError):
+                    _logger.warning(f"Invalid JSON in selection_options for field {field_name}: {template_field.selection_options}")
+                    selection_options = None
+            
+            # Determine widget type
+            widget_type = template_field.widget or 'default'
+            
+            # Check if this is a relation field (many2one stored as selection)
+            is_relation = bool(field.relation)
+            
+            if widget_type == 'radio' and not is_relation:
+                # Radio buttons for selection (non-relation)
+                radio_wrapper = etree.SubElement(input_div, 'div', {
+                    'class': 'row s_col_no_resize s_col_no_bgcolor s_website_form_multiple',
+                    'data-name': field_name,
+                    'data-display': 'horizontal'
+                })
+                # Get selection options
+                if selection_options:
+                    options_list = selection_options
+                else:
+                    # Get from model field definition
+                    model_name = field.model_id.model
+                    model = self.env[model_name]
+                    options_list = []
+                    if hasattr(model, field_name):
+                        model_field = model._fields.get(field_name)
+                        if model_field and hasattr(model_field, 'selection'):
+                            selection = model_field.selection
+                            if callable(selection):
+                                selection = selection(model)
+                            if isinstance(selection, (list, tuple)):
+                                options_list = selection
+                        elif field.selection:
+                            try:
+                                selection = eval(field.selection) if isinstance(field.selection, str) else field.selection
+                                if isinstance(selection, (list, tuple)):
+                                    options_list = selection
+                            except Exception:
+                                pass
+                
+                # Create radio buttons
+                for option_value, option_label in options_list:
+                    radio_div = etree.SubElement(radio_wrapper, 'div', {
+                        'class': 'radio col-12 col-lg-4 col-md-6'
+                    })
+                    form_check = etree.SubElement(radio_div, 'div', {
+                        'class': 'form-check'
+                    })
+                    radio_input = etree.SubElement(form_check, 'input', {
+                        'type': 'radio',
+                        'class': 's_website_form_input form-check-input',
+                        'name': field_name,
+                        'id': f'{field_id}_{abs(hash(str(option_value))) % 10000}',
+                        'value': str(option_value)
+                    })
+                    if required:
+                        radio_input.set('required', '1')
+                    if template_field.default_value and str(template_field.default_value) == str(option_value):
+                        radio_input.set('checked', 'checked')
+                    radio_label = etree.SubElement(form_check, 'label', {
+                        'class': 'form-check-label',
+                        'for': radio_input.get('id')
+                    })
+                    radio_label.text = option_label
+                input_el = radio_wrapper  # For consistency, but not used
+            elif widget_type == 'checkbox' and not is_relation:
+                # Checkboxes for selection (non-relation) - multiple selection
+                checkbox_wrapper = etree.SubElement(input_div, 'div', {
+                    'class': 'row s_col_no_resize s_col_no_bgcolor s_website_form_multiple',
+                    'data-name': field_name,
+                    'data-display': 'horizontal'
+                })
+                # Get selection options (same as radio)
+                if selection_options:
+                    options_list = selection_options
+                else:
+                    model_name = field.model_id.model
+                    model = self.env[model_name]
+                    options_list = []
+                    if hasattr(model, field_name):
+                        model_field = model._fields.get(field_name)
+                        if model_field and hasattr(model_field, 'selection'):
+                            selection = model_field.selection
+                            if callable(selection):
+                                selection = selection(model)
+                            if isinstance(selection, (list, tuple)):
+                                options_list = selection
+                        elif field.selection:
+                            try:
+                                selection = eval(field.selection) if isinstance(field.selection, str) else field.selection
+                                if isinstance(selection, (list, tuple)):
+                                    options_list = selection
+                            except Exception:
+                                pass
+                
+                # Create checkboxes
+                default_values = template_field.default_value.split(',') if template_field.default_value else []
+                for option_value, option_label in options_list:
+                    checkbox_div = etree.SubElement(checkbox_wrapper, 'div', {
+                        'class': 'checkbox col-12 col-lg-4 col-md-6'
+                    })
+                    form_check = etree.SubElement(checkbox_div, 'div', {
+                        'class': 'form-check'
+                    })
+                    checkbox_input = etree.SubElement(form_check, 'input', {
+                        'type': 'checkbox',
+                        'class': 's_website_form_input form-check-input',
+                        'name': field_name,
+                        'id': f'{field_id}_{abs(hash(str(option_value))) % 10000}',
+                        'value': str(option_value)
+                    })
+                    if required:
+                        checkbox_input.set('required', '1')
+                    if str(option_value) in [v.strip() for v in default_values]:
+                        checkbox_input.set('checked', 'checked')
+                    checkbox_label = etree.SubElement(form_check, 'label', {
+                        'class': 'form-check-label s_website_form_check_label',
+                        'for': checkbox_input.get('id')
+                    })
+                    checkbox_label.text = option_label
+                input_el = checkbox_wrapper  # For consistency, but not used
+            else:
+                # Default: Select dropdown
+                input_el = etree.SubElement(input_div, 'select', {
+                    'class': 'form-select s_website_form_input',
+                    'name': field_name,
+                    'id': field_id
+                })
+                if template_field.placeholder:
+                    input_el.set('placeholder', template_field.placeholder)
+                if required:
+                    input_el.set('required', '1')
+                # Add default option
+                default_option = etree.SubElement(input_el, 'option', {
+                    'value': ''
+                })
+                default_option.text = '-- Select --'
+                
+                # Populate selection options
+                if selection_options:
+                    # Use custom selection options
+                    for option_value, option_label in selection_options:
+                        option = etree.SubElement(input_el, 'option', {
+                            'value': str(option_value)
+                        })
+                        option.text = option_label
+                        if template_field.default_value and str(template_field.default_value) == str(option_value):
+                            option.set('selected', 'selected')
+                else:
+                    # Get from model field definition
+                    model_name = field.model_id.model
+                    model = self.env[model_name]
+                    if hasattr(model, field_name):
+                        model_field = model._fields.get(field_name)
+                        if model_field and hasattr(model_field, 'selection'):
+                            selection = model_field.selection
+                            if callable(selection):
+                                selection = selection(model)
+                            if isinstance(selection, (list, tuple)):
+                                for option_value, option_label in selection:
+                                    option = etree.SubElement(input_el, 'option', {
+                                        'value': str(option_value)
+                                    })
+                                    option.text = option_label
+                                    if template_field.default_value and str(template_field.default_value) == str(option_value):
+                                        option.set('selected', 'selected')
+                        elif field.selection:
+                            try:
+                                selection = eval(field.selection) if isinstance(field.selection, str) else field.selection
+                                if isinstance(selection, (list, tuple)):
+                                    for option_value, option_label in selection:
+                                        option = etree.SubElement(input_el, 'option', {
+                                            'value': str(option_value)
+                                        })
+                                        option.text = option_label
+                                        if template_field.default_value and str(template_field.default_value) == str(option_value):
+                                            option.set('selected', 'selected')
+                            except Exception:
+                                pass  # If selection can't be evaluated, just leave default option
+        elif field_type in ('integer', 'float'):
+            # Number input (exactly as form builder does)
+            input_type = 'number'
+            input_el = etree.SubElement(input_div, 'input', {
+                'type': input_type,
+                'class': 'form-control s_website_form_input',
+                'name': field_name,
+                'id': field_id
+            })
+            if template_field.placeholder:
+                input_el.set('placeholder', template_field.placeholder)
+            if template_field.default_value:
+                input_el.set('value', template_field.default_value)
+            if field_type == 'integer':
+                input_el.set('step', '1')
+            else:
+                input_el.set('step', 'any')
+            if required:
+                input_el.set('required', '1')
+        elif field_type == 'many2one':
+            # Determine widget type for many2one
+            widget_type = template_field.widget or 'default'
+            
+            if widget_type == 'radio':
+                # Radio buttons for many2one
+                radio_wrapper = etree.SubElement(input_div, 'div', {
+                    'class': 'row s_col_no_resize s_col_no_bgcolor s_website_form_multiple',
+                    'data-name': field_name,
+                    'data-display': 'horizontal'
+                })
+                # Load records from relation
+                relation = field.relation
+                if relation and relation != 'ir.attachment':
+                    try:
+                        records = self.env[relation].sudo().search_read(
+                            [], ['display_name'], limit=1000
+                        )
+                        for record in records:
+                            radio_div = etree.SubElement(radio_wrapper, 'div', {
+                                'class': 'radio col-12 col-lg-4 col-md-6'
+                            })
+                            form_check = etree.SubElement(radio_div, 'div', {
+                                'class': 'form-check'
+                            })
+                            radio_input = etree.SubElement(form_check, 'input', {
+                                'type': 'radio',
+                                'class': 's_website_form_input form-check-input',
+                                'name': field_name,
+                                'id': f'{field_id}_{record["id"]}',
+                                'value': str(record['id'])
+                            })
+                            if required:
+                                radio_input.set('required', '1')
+                            if template_field.default_value and str(template_field.default_value) == str(record['id']):
+                                radio_input.set('checked', 'checked')
+                            radio_label = etree.SubElement(form_check, 'label', {
+                                'class': 'form-check-label',
+                                'for': radio_input.get('id')
+                            })
+                            radio_label.text = record['display_name']
+                    except Exception:
+                        pass
+                input_el = radio_wrapper
+            elif widget_type == 'checkbox':
+                # Checkboxes for many2one (multiple selection - unusual but supported)
+                checkbox_wrapper = etree.SubElement(input_div, 'div', {
+                    'class': 'row s_col_no_resize s_col_no_bgcolor s_website_form_multiple',
+                    'data-name': field_name,
+                    'data-display': 'horizontal'
+                })
+                relation = field.relation
+                if relation and relation != 'ir.attachment':
+                    try:
+                        records = self.env[relation].sudo().search_read(
+                            [], ['display_name'], limit=1000
+                        )
+                        default_values = template_field.default_value.split(',') if template_field.default_value else []
+                        for record in records:
+                            checkbox_div = etree.SubElement(checkbox_wrapper, 'div', {
+                                'class': 'checkbox col-12 col-lg-4 col-md-6'
+                            })
+                            form_check = etree.SubElement(checkbox_div, 'div', {
+                                'class': 'form-check'
+                            })
+                            checkbox_input = etree.SubElement(form_check, 'input', {
+                                'type': 'checkbox',
+                                'class': 's_website_form_input form-check-input',
+                                'name': field_name,
+                                'id': f'{field_id}_{record["id"]}',
+                                'value': str(record['id'])
+                            })
+                            if required:
+                                checkbox_input.set('required', '1')
+                            if str(record['id']) in [v.strip() for v in default_values]:
+                                checkbox_input.set('checked', 'checked')
+                            checkbox_label = etree.SubElement(form_check, 'label', {
+                                'class': 'form-check-label s_website_form_check_label',
+                                'for': checkbox_input.get('id')
+                            })
+                            checkbox_label.text = record['display_name']
+                    except Exception:
+                        pass
+                input_el = checkbox_wrapper
+            else:
+                # Default: Select dropdown for many2one
+                input_el = etree.SubElement(input_div, 'select', {
+                    'class': 'form-select s_website_form_input',
+                    'name': field_name,
+                    'id': field_id
+                })
+                if template_field.placeholder:
+                    input_el.set('placeholder', template_field.placeholder)
+                if required:
+                    input_el.set('required', '1')
+                
+                # Add default option
+                default_option = etree.SubElement(input_el, 'option', {'value': ''})
+                default_option.text = '-- Select --'
+            
+            # Load records dynamically from relation
+            relation = field.relation
+            if relation and relation != 'ir.attachment':
+                try:
+                    # Try to get records from the relation model
+                    records = self.env[relation].sudo().search_read(
+                        [], ['display_name'], limit=1000
+                    )
+                    for record in records:
+                            option = etree.SubElement(input_el, 'option', {
+                                'value': str(record['id'])
+                            })
+                            option.text = record['display_name']
+                            if template_field.default_value and str(template_field.default_value) == str(record['id']):
+                                option.set('selected', 'selected')
+                except Exception:
+                    # If relation doesn't exist or access denied, try specific cases
+                    if field_name == 'request_type_id':
+                        request_types = self.env['helpdesk.request.type'].sudo().search([('active', '=', True)])
+                        for req_type in request_types:
+                            option = etree.SubElement(input_el, 'option', {
+                                'value': str(req_type.id)
+                            })
+                            option.text = req_type.name
+                    elif field_name == 'affected_module_id':
+                        modules = self.env['ir.module.module'].sudo().search([
+                            ('state', '=', 'installed'),
+                            ('application', '=', True)
+                        ], order='shortdesc')
+                        for module in modules:
+                            option = etree.SubElement(input_el, 'option', {
+                                'value': str(module.id)
+                            })
+                            option.text = module.shortdesc or module.name
+        elif field_type in ('date', 'datetime'):
+            # Date/Datetime field - NUEVO: soporte para fechas
+            date_wrapper = etree.SubElement(input_div, 'div', {
+                'class': f's_website_form_{field_type} input-group date'
+            })
+            input_el = etree.SubElement(date_wrapper, 'input', {
+                'type': 'text',
+                'class': 'form-control datetimepicker-input s_website_form_input',
+                'name': field_name,
+                'id': field_id
+            })
+            if template_field.placeholder:
+                input_el.set('placeholder', template_field.placeholder)
+            if template_field.default_value:
+                input_el.set('value', template_field.default_value)
+            if required:
+                input_el.set('required', '1')
+            # Add calendar icon
+            icon_div = etree.SubElement(date_wrapper, 'div', {
+                'class': 'input-group-text o_input_group_date_icon'
+            })
+            icon = etree.SubElement(icon_div, 'i', {'class': 'fa fa-calendar'})
+        elif field_type == 'binary':
+            # Binary field (file upload) - NUEVO: soporte para archivos
+            input_el = etree.SubElement(input_div, 'input', {
+                'type': 'file',
+                'class': 'form-control s_website_form_input',
+                'name': field_name,
+                'id': field_id
+            })
+            if required:
+                input_el.set('required', '1')
+        elif field_type in ('one2many', 'many2many'):
+            # One2many/Many2many fields - NUEVO: soporte para checkboxes múltiples
+            if field.relation == 'ir.attachment':
+                # Binary one2many (file upload multiple)
+                input_el = etree.SubElement(input_div, 'input', {
+                    'type': 'file',
+                    'class': 'form-control s_website_form_input',
+                    'name': field_name,
+                    'id': field_id,
+                    'multiple': ''
+                })
+                if required:
+                    input_el.set('required', '1')
+            else:
+                # Generic one2many/many2many as checkboxes
+                multiple_div = etree.SubElement(input_div, 'div', {
+                    'class': 'row s_col_no_resize s_col_no_bgcolor s_website_form_multiple',
+                    'data-name': field_name,
+                    'data-display': 'horizontal'
+                })
+                # Try to load records from relation
+                relation = field.relation
+                if relation:
+                    try:
+                        records = self.env[relation].sudo().search_read(
+                            [], ['display_name'], limit=100
+                        )
+                        for record in records:
+                            checkbox_div = etree.SubElement(multiple_div, 'div', {
+                                'class': 'checkbox col-12 col-lg-4 col-md-6'
+                            })
+                            form_check = etree.SubElement(checkbox_div, 'div', {
+                                'class': 'form-check'
+                            })
+                            checkbox_input = etree.SubElement(form_check, 'input', {
+                                'type': 'checkbox',
+                                'class': 's_website_form_input form-check-input',
+                                'name': field_name,
+                                'id': f'{field_id}_{record["id"]}',
+                                'value': str(record['id'])
+                            })
+                            checkbox_label = etree.SubElement(form_check, 'label', {
+                                'class': 'form-check-label s_website_form_check_label',
+                                'for': f'{field_id}_{record["id"]}'
+                            })
+                            checkbox_label.text = record['display_name']
+                    except Exception:
+                        pass  # If relation doesn't exist or access denied
+        elif field_type == 'monetary':
+            # Monetary field - NUEVO: soporte para montos
+            input_el = etree.SubElement(input_div, 'input', {
+                'type': 'number',
+                'class': 'form-control s_website_form_input',
+                'name': field_name,
+                'id': field_id,
+                'step': 'any'
+            })
+            if required:
+                input_el.set('required', '1')
+        else:
+            # Default: text input (char) - exactly as form builder does
+            input_el = etree.SubElement(input_div, 'input', {
+                'type': 'text',
+                'class': 'form-control s_website_form_input',
+                'name': field_name,
+                'id': field_id
+            })
+            if template_field.placeholder:
+                input_el.set('placeholder', template_field.placeholder)
+            if template_field.default_value:
+                input_el.set('value', template_field.default_value)
+            if required:
+                input_el.set('required', '1')
+        
+        # Add help text description if provided (exactly as form builder does)
+        if template_field.help_text:
+            help_text_div = etree.SubElement(input_div, 'div', {
+                'class': 's_website_form_field_description small form-text text-muted'
+            })
+            # Parse HTML help text and add as content
+            try:
+                # html.fromstring may wrap content in <html><body>, so we need to handle that
+                help_html = html.fragment_fromstring(template_field.help_text, create_parent='div')
+                # Copy all children and text from the parsed HTML
+                if help_html is not None:
+                    # If fragment_fromstring created a wrapper div, get its children
+                    if len(help_html) > 0:
+                        for child in help_html:
+                            help_text_div.append(child)
+                    elif help_html.text:
+                        help_text_div.text = help_html.text
+                    else:
+                        # Fallback: use text content
+                        help_text_div.text = help_html.text_content() or template_field.help_text
+                else:
+                    help_text_div.text = template_field.help_text
+            except Exception as e:
+                # Fallback: use plain text or raw HTML
+                _logger.warning(f"Error parsing help_text HTML for field {field_name}: {e}")
+                # Try to set as raw HTML (Odoo's HTML fields are sanitized, so this should be safe)
+                try:
+                    # Use etree to parse and append raw HTML
+                    raw_html = etree.fromstring(f'<div>{template_field.help_text}</div>')
+                    for child in raw_html:
+                        help_text_div.append(child)
+                    if not len(help_text_div):
+                        help_text_div.text = template_field.help_text
+                except Exception:
+                    # Final fallback: plain text
+                    help_text_div.text = template_field.help_text
+
+        return field_div, field_id_counter
+
+    def apply_workflow_template(self):
+        """Apply workflow template to create stages and SLAs for this team
+        
+        This method creates real helpdesk.stage and helpdesk.sla records
+        based on the workflow template configuration.
+        """
+        self.ensure_one()
+        if not self.workflow_template_id:
+            raise ValueError(_("No workflow template selected"))
+        
+        template = self.workflow_template_id
+        if not template.active:
+            raise ValueError(_("The selected workflow template is not active"))
+        
+        # Mapping: stage_template_id -> real_stage_id
+        stage_mapping = {}
+        
+        # 1. Create real stages from template stages
+        for stage_template in template.stage_template_ids.sorted('sequence'):
+            stage_vals = {
+                'name': stage_template.name,
+                'sequence': stage_template.sequence,
+                'fold': stage_template.fold,
+                'description': stage_template.description or False,
+                'template_id': stage_template.template_id_email.id if stage_template.template_id_email else False,
+                'legend_blocked': stage_template.legend_blocked,
+                'legend_done': stage_template.legend_done,
+                'legend_normal': stage_template.legend_normal,
+                'team_ids': [(4, self.id)],
+            }
+            real_stage = self.env['helpdesk.stage'].create(stage_vals)
+            stage_mapping[stage_template.id] = real_stage.id
+        
+        # 2. Create real SLAs from template SLAs
+        for sla_template in template.sla_template_ids.sorted('sequence'):
+            # Get real stage ID from mapping
+            real_stage_id = stage_mapping.get(sla_template.stage_template_id.id)
+            if not real_stage_id:
+                _logger.warning(
+                    f"Skipping SLA template '{sla_template.name}': "
+                    f"stage template {sla_template.stage_template_id.id} not found in mapping"
+                )
+                continue
+            
+            # Get real exclude stage IDs - map template stages to real stages
+            exclude_stage_ids = []
+            for exclude_template_stage in sla_template.exclude_stage_template_ids:
+                if exclude_template_stage.id in stage_mapping:
+                    exclude_stage_ids.append(stage_mapping[exclude_template_stage.id])
+                else:
+                    _logger.warning(
+                        f"SLA template '{sla_template.name}': "
+                        f"exclude stage template {exclude_template_stage.id} ({exclude_template_stage.name}) "
+                        f"not found in stage mapping. Skipping."
+                    )
+            
+            sla_vals = {
+                'name': sla_template.name,
+                'description': sla_template.description or False,
+                'team_id': self.id,
+                'stage_id': real_stage_id,
+                'time': sla_template.time,
+                'priority': sla_template.priority,
+                'tag_ids': [(6, 0, sla_template.tag_ids.ids)],
+                'exclude_stage_ids': [(6, 0, exclude_stage_ids)],
+                'active': True,
+            }
+            created_sla = self.env['helpdesk.sla'].create(sla_vals)
+            _logger.info(
+                f"Created SLA '{created_sla.name}' with {len(exclude_stage_ids)} excluded stage(s): "
+                f"{[self.env['helpdesk.stage'].browse(sid).name for sid in exclude_stage_ids]}"
+            )
+        
+        # 3. Ensure team has use_sla enabled if template has SLAs
+        if template.sla_template_ids and not self.use_sla:
+            self.use_sla = True
+        
+        return {
+            'type': 'ir.actions.client',
+            'tag': 'display_notification',
+            'params': {
+                'title': _('Workflow Template Applied'),
+                'message': _(
+                    'Successfully created %d stage(s) and %d SLA policy(ies) from template "%s".',
+                    len(stage_mapping),
+                    len(template.sla_template_ids),
+                    template.name
+                ),
+                'type': 'success',
+                'sticky': False,
+            }
+        }

+ 54 - 0
models/helpdesk_team_collaborator.py

@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class HelpdeskTeamCollaborator(models.Model):
+    _name = 'helpdesk.team.collaborator'
+    _description = 'Collaborators in helpdesk team shared'
+
+    team_id = fields.Many2one(
+        'helpdesk.team',
+        string='Helpdesk Team',
+        required=True,
+        readonly=True,
+        ondelete='cascade',
+        export_string_translation=False
+    )
+    partner_id = fields.Many2one(
+        'res.partner',
+        string='Collaborator',
+        required=True,
+        readonly=True,
+        export_string_translation=False
+    )
+    partner_email = fields.Char(
+        related='partner_id.email',
+        string='Email',
+        readonly=True,
+        export_string_translation=False
+    )
+    access_mode = fields.Selection(
+        [
+            ('admin', 'Administrator'),
+            ('user_all', 'User - All Tickets'),
+            ('user_own', 'User - Own Tickets'),
+        ],
+        string='Access Mode',
+        required=True,
+        default='user_own',
+        help="Administrator: can view all tickets and manage other users.\n"
+             "User - All Tickets: can view all tickets and create own tickets.\n"
+             "User - Own Tickets: can only create and view own tickets."
+    )
+
+    _sql_constraints = [
+        (
+            'unique_collaborator',
+            'UNIQUE(team_id, partner_id)',
+            'A collaborator cannot be selected more than once in the helpdesk team sharing access. Please remove duplicate(s) and try again.'
+        ),
+    ]
+
+    _rec_name = 'partner_id'

+ 704 - 0
models/helpdesk_template.py

@@ -0,0 +1,704 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+
+class HelpdeskTemplate(models.Model):
+    _name = 'helpdesk.template'
+    _description = 'Helpdesk Template'
+    _order = 'name'
+
+    name = fields.Char(
+        string='Name',
+        required=True,
+        translate=True,
+        help="Name of the template"
+    )
+    description = fields.Text(
+        string='Description',
+        translate=True,
+        help="Description of the template"
+    )
+    active = fields.Boolean(
+        string='Active',
+        default=True,
+        help="If unchecked, this template will be hidden and won't be available"
+    )
+    field_ids = fields.One2many(
+        'helpdesk.template.field',
+        'template_id',
+        string='Fields',
+        copy=True,
+        help="Fields included in this template"
+    )
+
+    @api.model
+    def default_get(self, fields_list):
+        """Set default required fields when creating a new template"""
+        res = super().default_get(fields_list)
+        
+        # Only set defaults if creating a new record (not editing existing)
+        if 'field_ids' in fields_list and not self.env.context.get('default_field_ids'):
+            # Get the required fields from form builder (same as website_helpdesk_form_editor.js)
+            required_field_names = ['partner_name', 'partner_email', 'name', 'description']
+            
+            # Get field records
+            ticket_model = self.env['ir.model'].search([('model', '=', 'helpdesk.ticket')], limit=1)
+            if ticket_model:
+                # Find the field records
+                required_fields = self.env['ir.model.fields'].search([
+                    ('model_id', '=', ticket_model.id),
+                    ('name', 'in', required_field_names),
+                    ('website_form_blacklisted', '=', False)
+                ])
+                
+                # Create field mapping
+                field_map = {f.name: f for f in required_fields}
+                
+                # Prepare default field_ids
+                field_ids_commands = []
+                sequence = 10
+                
+                # Add partner_name (required: true, sequence 10)
+                if 'partner_name' in field_map:
+                    field_ids_commands.append((0, 0, {
+                        'field_id': field_map['partner_name'].id,
+                        'required': True,
+                        'sequence': sequence
+                    }))
+                    sequence += 10
+                
+                # Add partner_email (required: true, sequence 20)
+                if 'partner_email' in field_map:
+                    field_ids_commands.append((0, 0, {
+                        'field_id': field_map['partner_email'].id,
+                        'required': True,
+                        'sequence': sequence
+                    }))
+                    sequence += 10
+                
+                # Add name (modelRequired: true, sequence 30) - required by model
+                # Note: model_required will be set automatically in create() based on field.required
+                if 'name' in field_map:
+                    name_field = field_map['name']
+                    field_ids_commands.append((0, 0, {
+                        'field_id': name_field.id,
+                        'required': True,  # Mark as required since it's modelRequired
+                        'model_required': name_field.required,  # Auto-detect from field definition
+                        'sequence': sequence
+                    }))
+                    sequence += 10
+                
+                # Add description (required: true, sequence 40)
+                if 'description' in field_map:
+                    field_ids_commands.append((0, 0, {
+                        'field_id': field_map['description'].id,
+                        'required': True,
+                        'sequence': sequence
+                    }))
+                    sequence += 10
+                
+                if field_ids_commands:
+                    res['field_ids'] = field_ids_commands
+                    _logger.info(f"Setting default required fields for new template: {[cmd[2]['field_id'] for cmd in field_ids_commands]}")
+        
+        return res
+
+    @api.model_create_multi
+    def create(self, vals_list):
+        """Override create to automatically add required fields from form builder"""
+        # Get the required fields from form builder (same as website_helpdesk_form_editor.js)
+        # These are the fields that are always required in the form builder:
+        # - partner_name (required: true)
+        # - partner_email (required: true)
+        # - name (modelRequired: true) - required by the model
+        # - description (required: true)
+        required_field_names = ['partner_name', 'partner_email', 'name', 'description']
+        
+        # Get field records
+        ticket_model = self.env['ir.model'].search([('model', '=', 'helpdesk.ticket')], limit=1)
+        if not ticket_model:
+            return super().create(vals_list)
+        
+        # Find the field records
+        required_fields = self.env['ir.model.fields'].search([
+            ('model_id', '=', ticket_model.id),
+            ('name', 'in', required_field_names),
+            ('website_form_blacklisted', '=', False)
+        ])
+        
+        # Create field mapping
+        field_map = {f.name: f for f in required_fields}
+        
+        # Prepare default field_ids for each template
+        for vals in vals_list:
+            if 'field_ids' not in vals or not vals.get('field_ids'):
+                # Only add default fields if no fields are provided
+                field_ids_commands = []
+                sequence = 10
+                
+                # Add partner_name (required: true, sequence 10)
+                if 'partner_name' in field_map:
+                    field_ids_commands.append((0, 0, {
+                        'field_id': field_map['partner_name'].id,
+                        'required': True,
+                        'sequence': sequence
+                    }))
+                    sequence += 10
+                
+                # Add partner_email (required: true, sequence 20)
+                if 'partner_email' in field_map:
+                    field_ids_commands.append((0, 0, {
+                        'field_id': field_map['partner_email'].id,
+                        'required': True,
+                        'sequence': sequence
+                    }))
+                    sequence += 10
+                
+                # Add name (modelRequired: true, sequence 30) - required by model
+                # Note: model_required will be set automatically in create() based on field.required
+                if 'name' in field_map:
+                    name_field = field_map['name']
+                    field_ids_commands.append((0, 0, {
+                        'field_id': name_field.id,
+                        'required': True,  # Mark as required since it's modelRequired
+                        'model_required': name_field.required,  # Auto-detect from field definition
+                        'sequence': sequence
+                    }))
+                    sequence += 10
+                
+                # Add description (required: true, sequence 40)
+                if 'description' in field_map:
+                    field_ids_commands.append((0, 0, {
+                        'field_id': field_map['description'].id,
+                        'required': True,
+                        'sequence': sequence
+                    }))
+                    sequence += 10
+                
+                if field_ids_commands:
+                    vals['field_ids'] = field_ids_commands
+                    _logger.info(f"Adding default required fields to new template: {[cmd[2]['field_id'] for cmd in field_ids_commands]}")
+        
+        return super().create(vals_list)
+
+    def write(self, vals):
+        """Override write to regenerate forms in all teams using this template"""
+        result = super().write(vals)
+        
+        # If template fields or active status changed, regenerate forms in all teams using this template
+        # Note: field_ids changes are handled by helpdesk.template.field create/write/unlink methods
+        # but we also check here in case field_ids is directly modified
+        if 'field_ids' in vals or 'active' in vals:
+            # Find all teams using this template
+            teams = self.env['helpdesk.team'].search([
+                ('template_id', 'in', self.ids),
+                ('use_website_helpdesk_form', '=', True)
+            ])
+            
+            # Regenerate form XML for each team
+            for team in teams:
+                # Ensure view exists before regenerating
+                if not team.website_form_view_id:
+                    team._ensure_submit_form_view()
+                # Regenerate or restore form based on template status
+                if team.website_form_view_id:
+                    try:
+                        if team.template_id.active:
+                            team._regenerate_form_from_template()
+                            _logger.info(f"Regenerated form for team {team.id} after template {team.template_id.id} change")
+                        else:
+                            # If template is deactivated, restore default form
+                            team._restore_default_form()
+                            _logger.info(f"Restored default form for team {team.id} after template {team.template_id.id} deactivation")
+                    except Exception as e:
+                        _logger.error(f"Error regenerating form for team {team.id}: {e}", exc_info=True)
+        
+        return result
+
+
+class HelpdeskTemplateField(models.Model):
+    _name = 'helpdesk.template.field'
+    _description = 'Helpdesk Template Field'
+    _order = 'sequence, id'
+
+    template_id = fields.Many2one(
+        'helpdesk.template',
+        string='Template',
+        required=True,
+        ondelete='cascade',
+        index=True
+    )
+    field_id = fields.Many2one(
+        'ir.model.fields',
+        string='Field',
+        required=True,
+        domain="[('model', '=', 'helpdesk.ticket'), ('website_form_blacklisted', '=', False)]",
+        ondelete='cascade',
+        help="Field from helpdesk.ticket model"
+    )
+    field_name = fields.Char(
+        related='field_id.name',
+        string='Field Name',
+        store=True,
+        readonly=True
+    )
+    field_type = fields.Selection(
+        related='field_id.ttype',
+        string='Field Type',
+        readonly=True
+    )
+    label_custom = fields.Char(
+        string='Custom Label',
+        help="Custom label for the field in the form. If empty, uses the field's default label."
+    )
+    placeholder = fields.Char(
+        string='Placeholder',
+        help="Placeholder text shown when field is empty"
+    )
+    default_value = fields.Char(
+        string='Default Value',
+        help="Default value for the field"
+    )
+    help_text = fields.Html(
+        string='Help Text',
+        help="Help text/description shown below the field (supports HTML formatting)"
+    )
+    widget = fields.Selection(
+        [
+            ('default', 'Default'),
+            ('radio', 'Radio Buttons'),
+            ('checkbox', 'Checkboxes'),
+        ],
+        string='Widget',
+        default='default',
+        help="Widget to use for selection/many2one fields. Default uses dropdown select."
+    )
+    selection_options = fields.Text(
+        string='Selection Options',
+        help="For selection fields (not relations): JSON array of [value, label] pairs. Example: [['option1', 'Option 1'], ['option2', 'Option 2']]"
+    )
+    sequence = fields.Integer(
+        string='Sequence',
+        default=10,
+        help="Order in which fields are displayed"
+    )
+    required = fields.Boolean(
+        string='Required',
+        default=False,
+        help="Make this field required in addition to its base configuration"
+    )
+    model_required = fields.Boolean(
+        string='Model Required',
+        default=False,
+        readonly=True,
+        help="This field is mandatory for the model and cannot be removed"
+    )
+    # Visibility conditions
+    visibility_dependency = fields.Many2one(
+        'ir.model.fields',
+        string='Visibility Dependency',
+        domain="[('model', '=', 'helpdesk.ticket'), ('website_form_blacklisted', '=', False)]",
+        help="Field on which visibility depends"
+    )
+    visibility_condition = fields.Char(
+        string='Visibility Condition Value',
+        help="Value to compare against the dependency field (for text, number, date, etc.)"
+    )
+    visibility_comparator = fields.Selection(
+        [
+            # Basic comparators
+            ('equal', 'Is equal to'),
+            ('!equal', 'Is not equal to'),
+            ('contains', 'Contains'),
+            ('!contains', "Doesn't contain"),
+            ('set', 'Is set'),
+            ('!set', 'Is not set'),
+            # Numeric comparators
+            ('greater', 'Is greater than'),
+            ('less', 'Is less than'),
+            ('greater or equal', 'Is greater than or equal to'),
+            ('less or equal', 'Is less than or equal to'),
+            # Date/Datetime comparators
+            ('dateEqual', 'Is equal to (date)'),
+            ('date!equal', 'Is not equal to (date)'),
+            ('after', 'Is after'),
+            ('before', 'Is before'),
+            ('equal or after', 'Is after or equal to'),
+            ('equal or before', 'Is before or equal to'),
+            ('between', 'Is between (included)'),
+            ('!between', 'Is not between (excluded)'),
+            # Selection/Many2one comparators
+            ('selected', 'Is equal to (selected)'),
+            ('!selected', 'Is not equal to (not selected)'),
+            # File comparators
+            ('fileSet', 'Is set (file)'),
+            ('!fileSet', 'Is not set (file)'),
+        ],
+        string='Visibility Comparator',
+        default='equal',
+        help="Comparison operator for visibility condition"
+    )
+    
+    # Computed field to determine dependency field type
+    visibility_dependency_type = fields.Char(
+        string='Dependency Field Type',
+        compute='_compute_visibility_dependency_type',
+        store=False,
+        help="Type of the visibility dependency field"
+    )
+    
+    # Field for many2one dependency - store ID as Integer (not Many2one to avoid model validation)
+    # The widget will handle the dynamic model change and display
+    visibility_condition_m2o_id = fields.Integer(
+        string='Visibility Condition (Many2one ID)',
+        help="ID of the selected record when dependency is a many2one field (model stored separately)"
+    )
+    visibility_condition_m2o_model = fields.Char(
+        string='M2O Model',
+        related='visibility_dependency.relation',
+        store=False,
+        readonly=True,
+        help="Model name for the many2one condition"
+    )
+    
+    # Field for selection dependency - computed selection options
+    visibility_condition_selection = fields.Selection(
+        selection='_get_visibility_condition_selection_options',
+        string='Visibility Condition (Selection)',
+        help="Selected value when dependency is a selection field"
+    )
+    
+    # Field for range conditions (between/!between) - second value for date/datetime ranges
+    visibility_between = fields.Char(
+        string='Visibility Between (End Value)',
+        help="Second value for 'between' and '!between' comparators (for date/datetime ranges)"
+    )
+    
+    def _get_visibility_condition_selection_options(self):
+        """Return selection options based on visibility_dependency field"""
+        # Handle empty recordset (when called from fields_get)
+        if not self:
+            return []
+        
+        # Handle multiple records (shouldn't happen, but be safe)
+        if len(self) > 1:
+            return []
+        
+        record = self[0] if self else None
+        if not record or not record.visibility_dependency or record.visibility_dependency.ttype != 'selection':
+            return []
+        
+        # Get selection options from ir.model.fields.selection
+        selection_records = self.env['ir.model.fields.selection'].search([
+            ('field_id', '=', record.visibility_dependency.id)
+        ], order='sequence, id')
+        
+        if selection_records:
+            return [(sel.value, sel.name) for sel in selection_records]
+        
+        # Fallback: try to get from field definition (for old-style selection)
+        try:
+            model = self.env[record.visibility_dependency.model]
+            field = model._fields.get(record.visibility_dependency.name)
+            if field and hasattr(field, 'selection') and field.selection:
+                if callable(field.selection):
+                    return field.selection(model)
+                return field.selection
+        except:
+            pass
+        
+        return []
+    
+    @api.depends('visibility_dependency')
+    def _compute_visibility_dependency_type(self):
+        """Compute the type of the visibility dependency field"""
+        for record in self:
+            if record.visibility_dependency:
+                record.visibility_dependency_type = record.visibility_dependency.ttype
+            else:
+                record.visibility_dependency_type = False
+    
+    @api.onchange('visibility_condition_m2o_id', 'visibility_dependency')
+    def _onchange_visibility_condition_m2o_id(self):
+        """Sync many2one ID to visibility_condition"""
+        if self.visibility_dependency and self.visibility_dependency.ttype == 'many2one':
+            if self.visibility_condition_m2o_id:
+                self.visibility_condition = str(self.visibility_condition_m2o_id)
+            else:
+                self.visibility_condition = False
+    
+    @api.onchange('visibility_condition_selection')
+    def _onchange_visibility_condition_selection(self):
+        """Sync selection value to visibility_condition"""
+        if self.visibility_condition_selection:
+            self.visibility_condition = self.visibility_condition_selection
+    
+    @api.onchange('visibility_dependency')
+    def _onchange_visibility_dependency(self):
+        """Clear condition values when dependency changes"""
+        if not self.visibility_dependency:
+            self.visibility_condition = False
+            self.visibility_condition_m2o_id = False
+            self.visibility_condition_selection = False
+        elif self.visibility_dependency.ttype not in ['many2one', 'selection']:
+            self.visibility_condition_m2o_id = False
+            self.visibility_condition_selection = False
+        elif self.visibility_dependency.ttype == 'many2one':
+            # Load current value into m2o_id if exists
+            if self.visibility_condition and self.visibility_condition.isdigit():
+                try:
+                    model_name = self.visibility_dependency.relation
+                    if model_name:
+                        model = self.env[model_name]
+                        record = model.browse(int(self.visibility_condition))
+                        if record.exists():
+                            # Store the ID - the widget will handle the model change
+                            self.visibility_condition_m2o_id = int(self.visibility_condition)
+                        else:
+                            self.visibility_condition_m2o_id = False
+                    else:
+                        self.visibility_condition_m2o_id = False
+                except:
+                    self.visibility_condition_m2o_id = False
+        elif self.visibility_dependency.ttype == 'selection':
+            # Load current value into selection if exists
+            if self.visibility_condition:
+                self.visibility_condition_selection = self.visibility_condition
+    
+    @api.onchange('visibility_comparator')
+    def _onchange_visibility_comparator(self):
+        """Clear visibility_between when comparator changes away from between/!between"""
+        if self.visibility_comparator not in ['between', '!between']:
+            self.visibility_between = False
+
+    _sql_constraints = [
+        ('unique_template_field', 'unique(template_id, field_id)',
+         'A field can only be added once to a template')
+    ]
+
+    @api.model
+    def _register_hook(self):
+        """Register label_custom field in ir.model.fields if it doesn't exist"""
+        super()._register_hook()
+        try:
+            model = self.env['ir.model'].search([('model', '=', 'helpdesk.template.field')], limit=1)
+            if model:
+                field_model = self.env['ir.model.fields']
+                existing_field = field_model.search([
+                    ('model_id', '=', model.id),
+                    ('name', '=', 'label_custom')
+                ], limit=1)
+                
+                if not existing_field:
+                    field_model.create({
+                        'model_id': model.id,
+                        'name': 'label_custom',
+                        'field_description': 'Custom Label',
+                        'ttype': 'char',
+                        'state': 'manual',
+                        'required': False,
+                        'readonly': False,
+                        'store': True,
+                    })
+                    _logger.info("Campo label_custom registrado en _register_hook")
+        except Exception as e:
+            _logger.error(f"Error registrando label_custom en _register_hook: {str(e)}", exc_info=True)
+
+    @api.model
+    def _migrate_label_custom_field(self):
+        """
+        Migration method to ensure label_custom field exists in database.
+        This method should be called after module update to fix any missing field issues.
+        """
+        try:
+            # Check if column exists in database
+            self.env.cr.execute("""
+                SELECT column_name 
+                FROM information_schema.columns 
+                WHERE table_name = 'helpdesk_template_field' 
+                AND column_name = 'label_custom'
+            """)
+            column_exists = self.env.cr.fetchone()
+            
+            if not column_exists:
+                _logger.warning("Column 'label_custom' does not exist. Adding it...")
+                # Add column manually if it doesn't exist
+                self.env.cr.execute("""
+                    ALTER TABLE helpdesk_template_field 
+                    ADD COLUMN label_custom VARCHAR
+                """)
+                self.env.cr.commit()
+                _logger.info("Column 'label_custom' added successfully")
+            else:
+                _logger.info("Column 'label_custom' already exists")
+            
+            # Update ir.model.fields to ensure field is registered
+            field_model = self.env['ir.model.fields']
+            model_id = self.env['ir.model'].search([('model', '=', 'helpdesk.template.field')], limit=1)
+            
+            if model_id:
+                existing_field = field_model.search([
+                    ('model_id', '=', model_id.id),
+                    ('name', '=', 'label_custom')
+                ], limit=1)
+                
+                if not existing_field:
+                    _logger.warning("Field 'label_custom' not found in ir.model.fields. Creating it...")
+                    field_model.create({
+                        'model_id': model_id.id,
+                        'name': 'label_custom',
+                        'field_description': 'Custom Label',
+                        'ttype': 'char',
+                        'state': 'manual',
+                    })
+                    _logger.info("Field 'label_custom' registered in ir.model.fields")
+                else:
+                    _logger.info("Field 'label_custom' already registered in ir.model.fields")
+            
+            # Clear cache to ensure changes are reflected
+            self.env.registry.clear_cache()
+            
+        except Exception as e:
+            _logger.error(f"Error in _migrate_label_custom_field: {str(e)}", exc_info=True)
+            # Don't raise to avoid breaking module update
+
+    @api.model_create_multi
+    def create(self, vals_list):
+        """Override create to mark model required fields and regenerate forms when template field is added"""
+        # Mark model required fields automatically based on field definition
+        for vals in vals_list:
+            if 'field_id' in vals and vals['field_id']:
+                field = self.env['ir.model.fields'].browse(vals['field_id'])
+                # Check if field is required at model level (not just in form)
+                # A field is model required if:
+                # 1. It's in the helpdesk.ticket model
+                # 2. It has required=True in ir.model.fields (mandatory at model level)
+                # 3. It's not blacklisted for website forms
+                if (field.model == 'helpdesk.ticket' and 
+                    field.required and 
+                    not field.website_form_blacklisted):
+                    vals['model_required'] = True
+                    _logger.info(f"Auto-marked field {field.name} as model_required (required at model level)")
+        
+        fields_created = super().create(vals_list)
+        
+        # Get unique templates that were modified
+        templates = fields_created.mapped('template_id')
+        
+        # Regenerate forms in all teams using these templates
+        for template in templates:
+            if not template:
+                continue
+            teams = self.env['helpdesk.team'].search([
+                ('template_id', '=', template.id),
+                ('use_website_helpdesk_form', '=', True)
+            ])
+            for team in teams:
+                # Ensure view exists before regenerating
+                if not team.website_form_view_id:
+                    team._ensure_submit_form_view()
+                # Regenerate form if view exists
+                if team.website_form_view_id:
+                    try:
+                        team._regenerate_form_from_template()
+                        _logger.info(f"Regenerated form for team {team.id} after adding field to template {template.id}")
+                    except Exception as e:
+                        _logger.error(f"Error regenerating form for team {team.id}: {e}", exc_info=True)
+        
+        return fields_created
+
+    def write(self, vals):
+        """Override write to mark model required fields and regenerate forms when template field is modified"""
+        # Mark/unmark model_required automatically based on field definition
+        if 'field_id' in vals and vals['field_id']:
+            field = self.env['ir.model.fields'].browse(vals['field_id'])
+            # Check if field is required at model level
+            if (field.model == 'helpdesk.ticket' and 
+                field.required and 
+                not field.website_form_blacklisted):
+                vals['model_required'] = True
+                _logger.info(f"Auto-marked field {field.name} as model_required (required at model level)")
+            else:
+                # Field is not model required, unmark it
+                vals['model_required'] = False
+        elif 'field_id' in vals and not vals['field_id']:
+            # Field_id is being cleared, unmark model_required
+            vals['model_required'] = False
+        
+        result = super().write(vals)
+        
+        # If any field configuration changed, regenerate forms
+        if any(key in vals for key in ['field_id', 'sequence', 'required', 'visibility_dependency', 
+                                       'visibility_condition', 'visibility_comparator', 'label_custom', 
+                                       'model_required', 'placeholder', 'default_value', 'help_text', 
+                                       'widget', 'selection_options']):
+            # Get unique templates that were modified
+            templates = self.mapped('template_id')
+            
+            # Regenerate forms in all teams using these templates
+            for template in templates:
+                if not template:
+                    continue
+                teams = self.env['helpdesk.team'].search([
+                    ('template_id', '=', template.id),
+                    ('use_website_helpdesk_form', '=', True)
+                ])
+                for team in teams:
+                    # Ensure view exists before regenerating
+                    if not team.website_form_view_id:
+                        team._ensure_submit_form_view()
+                    # Regenerate form if view exists
+                    if team.website_form_view_id:
+                        try:
+                            team._regenerate_form_from_template()
+                            _logger.info(f"Regenerated form for team {team.id} after modifying field in template {template.id}")
+                        except Exception as e:
+                            _logger.error(f"Error regenerating form for team {team.id}: {e}", exc_info=True)
+        
+        return result
+
+    def unlink(self):
+        """Override unlink to prevent deletion of model required fields and regenerate forms"""
+        # Prevent deletion of model required fields
+        model_required_fields = self.filtered('model_required')
+        if model_required_fields:
+            field_names = [f.field_id.name if f.field_id else 'Unknown' for f in model_required_fields]
+            raise UserError(
+                _("Cannot delete model required field(s): %s. This field is mandatory for the model and cannot be removed. "
+                  "Try hiding it with the 'Visibility' option instead and add it a default value.")
+                % ', '.join(field_names)
+            )
+        
+        # Get templates before deletion
+        templates = self.mapped('template_id')
+        
+        result = super().unlink()
+        
+        # Regenerate forms in all teams using these templates
+        for template in templates:
+            if not template:
+                continue
+            teams = self.env['helpdesk.team'].search([
+                ('template_id', '=', template.id),
+                ('use_website_helpdesk_form', '=', True)
+            ])
+            for team in teams:
+                # Ensure view exists before regenerating
+                if not team.website_form_view_id:
+                    team._ensure_submit_form_view()
+                # Regenerate form if view exists
+                if team.website_form_view_id:
+                    try:
+                        team._regenerate_form_from_template()
+                        _logger.info(f"Regenerated form for team {team.id} after removing field from template {template.id}")
+                    except Exception as e:
+                        _logger.error(f"Error regenerating form for team {team.id}: {e}", exc_info=True)
+        
+        return result

+ 2 - 0
models/helpdesk_template_field.py

@@ -0,0 +1,2 @@
+# Este archivo está vacío porque el modelo está en helpdesk_template.py
+# Se mantiene para referencia si se necesita separar en el futuro

+ 106 - 0
models/helpdesk_ticket.py

@@ -0,0 +1,106 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class HelpdeskTicket(models.Model):
+    _inherit = 'helpdesk.ticket'
+
+    request_type_id = fields.Many2one(
+        'helpdesk.request.type',
+        string='Request Type',
+        required=True,
+        tracking=True,
+        help="Type of ticket (e.g., Incident, Improvement)",
+        default=lambda self: self._default_request_type_id()
+    )
+    request_type_code = fields.Char(
+        related='request_type_id.code',
+        string='Request Type Code',
+        store=True,
+        readonly=True,
+        help="Code of the request type for conditional logic"
+    )
+    affected_module_id = fields.Many2one(
+        'ir.module.module',
+        string='Affected Module',
+        required=False,  # Not required at DB level to allow null if module is uninstalled
+        domain=[('state', '=', 'installed'), ('application', '=', True)],
+        ondelete='set null',
+        help="Odoo module where the issue or improvement occurs"
+    )
+    business_impact = fields.Selection(
+        [
+            ('0', 'Critical'),
+            ('1', 'High'),
+            ('2', 'Normal'),
+        ],
+        string='Business Impact',
+        default='2',
+        tracking=True,
+        help="Urgency reported by the client"
+    )
+    reproduce_steps = fields.Html(
+        string='Steps to Reproduce',
+        help="Detailed steps to reproduce the issue (only for Incidents)"
+    )
+    business_goal = fields.Html(
+        string='Business Goal',
+        help="Business objective for this improvement (only for Improvements)"
+    )
+    client_authorization = fields.Boolean(
+        string='Client Authorization',
+        default=False,
+        help="Checkbox from web form indicating client authorization"
+    )
+    estimated_hours = fields.Float(
+        string='Estimated Hours',
+        help="Hours quoted after analysis"
+    )
+    approval_status = fields.Selection(
+        [
+            ('draft', 'N/A'),
+            ('waiting', 'Waiting for Approval'),
+            ('approved', 'Approved'),
+            ('rejected', 'Rejected'),
+        ],
+        string='Approval Status',
+        default='draft',
+        tracking=True,
+        help="Status of the approval workflow"
+    )
+    has_template = fields.Boolean(
+        string='Has Template',
+        compute='_compute_has_template',
+        help="Indicates if the team has a template assigned"
+    )
+    attachment_ids = fields.One2many(
+        'ir.attachment',
+        'res_id',
+        string='Attachments',
+        domain=[('res_model', '=', 'helpdesk.ticket')],
+        help="Files attached to this ticket"
+    )
+
+    @api.depends('team_id.template_id')
+    def _compute_has_template(self):
+        """Compute if team has a template"""
+        for ticket in self:
+            ticket.has_template = bool(ticket.team_id and ticket.team_id.template_id)
+
+    @api.model
+    def _default_request_type_id(self):
+        """Default to 'Incident' type if available"""
+        incident_type = self.env.ref(
+            'helpdesk_extras.type_incident',
+            raise_if_not_found=False
+        )
+        return incident_type.id if incident_type else False
+
+    def _get_template_fields(self):
+        """Get template fields for this ticket's team"""
+        self.ensure_one()
+        if not self.team_id or not self.team_id.template_id:
+            return self.env['helpdesk.template.field']
+        return self.team_id.template_id.field_ids.sorted('sequence')

+ 94 - 0
models/helpdesk_workflow_template.py

@@ -0,0 +1,94 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models, Command
+
+
+class HelpdeskWorkflowTemplate(models.Model):
+    _name = 'helpdesk.workflow.template'
+    _description = 'Helpdesk Workflow Template'
+    _order = 'sequence, name'
+
+    name = fields.Char(
+        string='Template Name',
+        required=True,
+        translate=True,
+        help='Name of the workflow template'
+    )
+    sequence = fields.Integer(
+        string='Sequence',
+        default=10,
+        help='Order of templates'
+    )
+    description = fields.Text(
+        string='Description',
+        translate=True,
+        help='Description of the workflow template'
+    )
+    active = fields.Boolean(
+        string='Active',
+        default=True,
+        help='If unchecked, this template will be hidden'
+    )
+    stage_template_ids = fields.One2many(
+        'helpdesk.workflow.template.stage',
+        'template_id',
+        string='Stages',
+        help='Stages included in this workflow template'
+    )
+    sla_template_ids = fields.One2many(
+        'helpdesk.workflow.template.sla',
+        'template_id',
+        string='SLA Policies',
+        help='SLA policies included in this workflow template'
+    )
+    stage_count = fields.Integer(
+        string='Stages Count',
+        compute='_compute_counts',
+        store=False
+    )
+    sla_count = fields.Integer(
+        string='SLA Policies Count',
+        compute='_compute_counts',
+        store=False
+    )
+    team_ids = fields.One2many(
+        'helpdesk.team',
+        'workflow_template_id',
+        string='Teams Using This Template',
+        readonly=True
+    )
+    team_count = fields.Integer(
+        string='Teams Count',
+        compute='_compute_counts',
+        store=False
+    )
+
+    @api.depends('stage_template_ids', 'sla_template_ids', 'team_ids')
+    def _compute_counts(self):
+        for template in self:
+            template.stage_count = len(template.stage_template_ids)
+            template.sla_count = len(template.sla_template_ids)
+            template.team_count = len(template.team_ids)
+
+    def action_view_teams(self):
+        """Open teams using this template"""
+        self.ensure_one()
+        action = self.env['ir.actions.actions']._for_xml_id('helpdesk.helpdesk_team_action')
+        action.update({
+            'domain': [('workflow_template_id', '=', self.id)],
+            'context': {
+                'default_workflow_template_id': self.id,
+                'search_default_workflow_template_id': self.id,
+            },
+        })
+        return action
+
+    def copy_data(self, default=None):
+        """Override copy to duplicate stages and SLAs"""
+        defaults = super().copy_data(default=default)
+        # Note: Stages and SLAs will be copied automatically via ondelete='cascade'
+        # We just need to update the name
+        for template, vals in zip(self, defaults):
+            vals['name'] = self.env._("%s (copy)", template.name)
+        return defaults
+

+ 71 - 0
models/helpdesk_workflow_template_sla.py

@@ -0,0 +1,71 @@
+# -*- coding: utf-8 -*-
+
+from odoo import fields, models
+from odoo.addons.helpdesk.models.helpdesk_ticket import TICKET_PRIORITY
+
+
+class HelpdeskWorkflowTemplateSLA(models.Model):
+    _name = 'helpdesk.workflow.template.sla'
+    _description = 'Workflow Template SLA Policy'
+    _order = 'sequence, id'
+
+    template_id = fields.Many2one(
+        'helpdesk.workflow.template',
+        string='Template',
+        required=True,
+        ondelete='cascade',
+        index=True
+    )
+    name = fields.Char(
+        string='SLA Policy Name',
+        required=True,
+        translate=True,
+        help='Name of the SLA policy'
+    )
+    description = fields.Html(
+        string='Description',
+        translate=True,
+        help='Description of the SLA policy'
+    )
+    sequence = fields.Integer(
+        string='Sequence',
+        default=10,
+        help='Order of SLA policies'
+    )
+    stage_template_id = fields.Many2one(
+        'helpdesk.workflow.template.stage',
+        string='Target Stage',
+        required=True,
+        domain="[('template_id', '=', template_id)]",
+        help='Minimum stage a ticket needs to reach in order to satisfy this SLA'
+    )
+    exclude_stage_template_ids = fields.Many2many(
+        'helpdesk.workflow.template.stage',
+        'workflow_template_sla_exclude_stage_rel',
+        'sla_template_id',
+        'stage_template_id',
+        string='Excluding Stages',
+        domain="[('template_id', '=', template_id), ('id', '!=', stage_template_id)]",
+        help='Stages where time spent will NOT count towards the SLA deadline. '
+             'When a ticket is in these stages, the SLA timer is frozen. '
+             'Useful for stages like "On Hold" or "Waiting for Customer" where the ticket is waiting for external action.'
+    )
+    time = fields.Float(
+        string='Within (hours)',
+        required=True,
+        default=0.0,
+        help='Maximum number of working hours a ticket should take to reach the target stage'
+    )
+    priority = fields.Selection(
+        TICKET_PRIORITY,
+        string='Priority',
+        default='0',
+        required=True,
+        help='Priority level for this SLA policy'
+    )
+    tag_ids = fields.Many2many(
+        'helpdesk.tag',
+        string='Tags',
+        help='Tags that trigger this SLA policy (optional, if empty applies to all tickets)'
+    )
+

+ 63 - 0
models/helpdesk_workflow_template_stage.py

@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+
+from odoo import fields, models
+
+
+class HelpdeskWorkflowTemplateStage(models.Model):
+    _name = 'helpdesk.workflow.template.stage'
+    _description = 'Workflow Template Stage'
+    _order = 'sequence, id'
+
+    template_id = fields.Many2one(
+        'helpdesk.workflow.template',
+        string='Template',
+        required=True,
+        ondelete='cascade',
+        index=True
+    )
+    name = fields.Char(
+        string='Stage Name',
+        required=True,
+        translate=True,
+        help='Name of the stage'
+    )
+    sequence = fields.Integer(
+        string='Sequence',
+        default=10,
+        help='Order of stages in the workflow'
+    )
+    fold = fields.Boolean(
+        string='Folded in Kanban',
+        default=False,
+        help='Tickets in a folded stage are considered as closed'
+    )
+    description = fields.Text(
+        string='Description',
+        translate=True,
+        help='Description of the stage'
+    )
+    template_id_email = fields.Many2one(
+        'mail.template',
+        string='Email Template',
+        domain="[('model', '=', 'helpdesk.ticket')]",
+        help='Email automatically sent to the customer when the ticket reaches this stage'
+    )
+    legend_blocked = fields.Char(
+        string='Red Kanban Label',
+        default='Blocked',
+        translate=True,
+        required=True
+    )
+    legend_done = fields.Char(
+        string='Green Kanban Label',
+        default='Ready',
+        translate=True,
+        required=True
+    )
+    legend_normal = fields.Char(
+        string='Grey Kanban Label',
+        default='In Progress',
+        translate=True,
+        required=True
+    )
+

+ 107 - 0
scripts/check_and_fix_views.py

@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+Script para verificar y arreglar vistas que referencian label_custom
+Ejecutar desde shell de Odoo:
+    exec(open('src/user/helpdesk_extras/scripts/check_and_fix_views.py').read())
+    check_and_fix_views(env)
+"""
+
+import logging
+import re
+_logger = logging.getLogger(__name__)
+
+def check_and_fix_views(env):
+    """Verifica y arregla vistas que referencian label_custom"""
+    try:
+        _logger.info("Buscando vistas que referencian label_custom...")
+        
+        # Buscar todas las vistas del modelo helpdesk.template.field
+        views = env['ir.ui.view'].search([
+            ('model', '=', 'helpdesk.template.field')
+        ])
+        
+        _logger.info(f"Encontradas {len(views)} vistas para helpdesk.template.field")
+        
+        # Buscar vistas que contengan label_custom en el arch
+        problematic_views = []
+        for view in views:
+            if 'label_custom' in view.arch:
+                problematic_views.append(view)
+                _logger.warning(f"Vista {view.id} ({view.name}) contiene 'label_custom'")
+        
+        # También buscar en vistas del modelo helpdesk.template que tengan field_ids
+        template_views = env['ir.ui.view'].search([
+            ('model', '=', 'helpdesk.template')
+        ])
+        
+        for view in template_views:
+            if 'label_custom' in view.arch:
+                problematic_views.append(view)
+                _logger.warning(f"Vista template {view.id} ({view.name}) contiene 'label_custom'")
+        
+        if problematic_views:
+            _logger.info(f"Encontradas {len(problematic_views)} vistas problemáticas")
+            _logger.info("Opciones:")
+            _logger.info("1. Eliminar el campo de las vistas (recomendado)")
+            _logger.info("2. Eliminar las vistas completamente")
+            
+            # Opción 1: Remover label_custom de las vistas
+            for view in problematic_views:
+                try:
+                    # Remover campo label_custom del arch
+                    new_arch = view.arch
+                    # Buscar y remover <field name="label_custom".../>
+                    pattern = r'<field[^>]*name=["\']label_custom["\'][^>]*/>'
+                    new_arch = re.sub(pattern, '', new_arch, flags=re.IGNORECASE)
+                    # Buscar y remover <field name="label_custom"...>...</field>
+                    pattern = r'<field[^>]*name=["\']label_custom["\'][^>]*>.*?</field>'
+                    new_arch = re.sub(pattern, '', new_arch, flags=re.IGNORECASE | re.DOTALL)
+                    
+                    if new_arch != view.arch:
+                        view.arch = new_arch
+                        _logger.info(f"Removido label_custom de vista {view.id}")
+                except Exception as e:
+                    _logger.error(f"Error procesando vista {view.id}: {str(e)}")
+            
+            env.cr.commit()
+            _logger.info("Vistas actualizadas")
+        else:
+            _logger.info("No se encontraron vistas problemáticas")
+        
+        # Verificar que el campo esté registrado
+        model = env['ir.model'].search([('model', '=', 'helpdesk.template.field')], limit=1)
+        if model:
+            field_model = env['ir.model.fields']
+            existing_field = field_model.search([
+                ('model_id', '=', model.id),
+                ('name', '=', 'label_custom')
+            ], limit=1)
+            
+            if not existing_field:
+                _logger.info("Registrando campo label_custom en ir.model.fields...")
+                field_model.create({
+                    'model_id': model.id,
+                    'name': 'label_custom',
+                    'field_description': 'Custom Label',
+                    'ttype': 'char',
+                    'state': 'manual',
+                    'required': False,
+                    'readonly': False,
+                    'store': True,
+                })
+                env.cr.commit()
+                _logger.info("Campo registrado")
+            else:
+                _logger.info("Campo ya está registrado")
+        
+        # Limpiar caché
+        env.registry.clear_cache()
+        _logger.info("Caché limpiado")
+        
+        return True
+        
+    except Exception as e:
+        _logger.error(f"Error en check_and_fix_views: {str(e)}", exc_info=True)
+        return False
+

+ 94 - 0
scripts/fix_label_custom.py

@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+Script para arreglar el campo label_custom en helpdesk.template.field
+Ejecutar desde shell de Odoo:
+    python src/user/helpdesk_extras/scripts/fix_label_custom.py
+O desde shell interactivo:
+    exec(open('src/user/helpdesk_extras/scripts/fix_label_custom.py').read())
+"""
+
+import logging
+_logger = logging.getLogger(__name__)
+
+def fix_label_custom_field(env):
+    """Arregla el campo label_custom en la base de datos"""
+    try:
+        _logger.info("Iniciando fix de label_custom...")
+        
+        # 1. Verificar y agregar columna en base de datos si no existe
+        env.cr.execute("""
+            SELECT column_name 
+            FROM information_schema.columns 
+            WHERE table_name = 'helpdesk_template_field' 
+            AND column_name = 'label_custom'
+        """)
+        column_exists = env.cr.fetchone()
+        
+        if not column_exists:
+            _logger.info("Agregando columna label_custom a helpdesk_template_field...")
+            env.cr.execute("""
+                ALTER TABLE helpdesk_template_field 
+                ADD COLUMN IF NOT EXISTS label_custom VARCHAR
+            """)
+            env.cr.commit()
+            _logger.info("Columna agregada exitosamente")
+        else:
+            _logger.info("Columna label_custom ya existe")
+        
+        # 2. Verificar y registrar campo en ir.model.fields
+        model = env['ir.model'].search([('model', '=', 'helpdesk.template.field')], limit=1)
+        if not model:
+            _logger.error("Modelo helpdesk.template.field no encontrado")
+            return False
+        
+        field_model = env['ir.model.fields']
+        existing_field = field_model.search([
+            ('model_id', '=', model.id),
+            ('name', '=', 'label_custom')
+        ], limit=1)
+        
+        if not existing_field:
+            _logger.info("Registrando campo label_custom en ir.model.fields...")
+            field_model.create({
+                'model_id': model.id,
+                'name': 'label_custom',
+                'field_description': 'Custom Label',
+                'ttype': 'char',
+                'state': 'manual',
+                'required': False,
+                'readonly': False,
+                'store': True,
+            })
+            env.cr.commit()
+            _logger.info("Campo registrado exitosamente")
+        else:
+            _logger.info("Campo label_custom ya está registrado en ir.model.fields")
+        
+        # 3. Limpiar caché
+        env.registry.clear_cache()
+        _logger.info("Caché limpiado")
+        
+        # 4. Verificar registros existentes
+        template_fields = env['helpdesk.template.field'].search([])
+        _logger.info(f"Total de template fields encontrados: {len(template_fields)}")
+        
+        # Asegurar que todos los registros tengan el campo (aunque sea None)
+        for tf in template_fields:
+            if 'label_custom' not in tf._fields:
+                _logger.warning(f"Template field {tf.id} no tiene campo label_custom en _fields")
+        
+        _logger.info("Fix completado exitosamente")
+        return True
+        
+    except Exception as e:
+        _logger.error(f"Error en fix_label_custom_field: {str(e)}", exc_info=True)
+        return False
+
+# Si se ejecuta directamente desde shell
+if __name__ == '__main__':
+    # Esto solo funciona si se ejecuta desde shell de Odoo
+    # En shell: exec(open('src/user/helpdesk_extras/scripts/fix_label_custom.py').read())
+    # Luego: fix_label_custom_field(env)
+    pass
+

+ 101 - 0
security/helpdesk_security.xml

@@ -0,0 +1,101 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <data noupdate="1">
+        
+        <!-- Helpdesk Team Collaborator: Portal users can only see their own collaboration -->
+        <record id="helpdesk_team_collaborator_rule_portal" model="ir.rule">
+            <field name="name">Helpdesk Team Collaborator: portal users can only see their own collaboration</field>
+            <field name="model_id" ref="helpdesk_extras.model_helpdesk_team_collaborator"/>
+            <field name="domain_force">[
+                '|',
+                    ('partner_id', '=', user.partner_id.id),
+                    '&amp;',
+                        ('team_id.collaborator_ids', 'any', [
+                ('partner_id', '=', user.partner_id.id),
+                            ('access_mode', '=', 'admin'),
+                        ]),
+            ]</field>
+            <field name="groups" eval="[(4, ref('base.group_portal'))]"/>
+        </record>
+
+        <!-- Helpdesk Team: Portal users can see teams where they are followers, or teams without followers -->
+        <record id="helpdesk_team_rule_portal_collaborator" model="ir.rule">
+            <field name="name">Helpdesk Team: portal users can see teams where they are followers, or teams without followers</field>
+            <field name="model_id" ref="helpdesk.model_helpdesk_team"/>
+            <field name="domain_force">[
+                '|',
+                    ('message_partner_ids', '=', False),
+                    ('message_partner_ids', 'in', [user.partner_id.id]),
+            ]</field>
+            <field name="groups" eval="[(4, ref('base.group_portal'))]"/>
+        </record>
+
+        <!-- Helpdesk Ticket: Portal users with admin access can see all tickets in the team -->
+        <record id="helpdesk_ticket_rule_portal_admin" model="ir.rule">
+            <field name="name">Helpdesk Ticket: portal users with admin access can see all tickets</field>
+            <field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
+            <field name="domain_force">[
+                '|',
+                    ('team_id.message_partner_ids', '=', False),
+                    '&amp;',
+                        ('team_id.message_partner_ids', 'in', [user.partner_id.id]),
+                        ('team_id.collaborator_ids', 'any', [
+                            ('partner_id', '=', user.partner_id.id),
+                            ('access_mode', '=', 'admin'),
+                        ]),
+            ]</field>
+            <field name="perm_read" eval="True"/>
+            <field name="perm_write" eval="True"/>
+            <field name="perm_create" eval="True"/>
+            <field name="perm_unlink" eval="False"/>
+            <field name="groups" eval="[(4, ref('base.group_portal'))]"/>
+        </record>
+
+        <!-- Helpdesk Ticket: Portal users with user_all access can see all tickets and create own -->
+        <record id="helpdesk_ticket_rule_portal_user_all" model="ir.rule">
+            <field name="name">Helpdesk Ticket: portal users with user_all access can see all tickets</field>
+            <field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
+            <field name="domain_force">[
+                '|',
+                    ('team_id.message_partner_ids', '=', False),
+                    '&amp;',
+                        ('team_id.message_partner_ids', 'in', [user.partner_id.id]),
+                        ('team_id.collaborator_ids', 'any', [
+                            ('partner_id', '=', user.partner_id.id),
+                            ('access_mode', '=', 'user_all'),
+                        ]),
+            ]</field>
+            <field name="perm_read" eval="True"/>
+            <field name="perm_write" eval="False"/>
+            <field name="perm_create" eval="True"/>
+            <field name="perm_unlink" eval="False"/>
+            <field name="groups" eval="[(4, ref('base.group_portal'))]"/>
+        </record>
+
+        <!-- Helpdesk Ticket: Portal users with user_own access can only see and create own tickets -->
+        <record id="helpdesk_ticket_rule_portal_user_own" model="ir.rule">
+            <field name="name">Helpdesk Ticket: portal users with user_own access can only see own tickets</field>
+            <field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
+            <field name="domain_force">[
+                '|',
+                    ('team_id.message_partner_ids', '=', False),
+                    '&amp;',
+                        ('team_id.message_partner_ids', 'in', [user.partner_id.id]),
+                        '&amp;',
+                            ('team_id.collaborator_ids', 'any', [
+                                ('partner_id', '=', user.partner_id.id),
+                                ('access_mode', '=', 'user_own'),
+                            ]),
+                            '|',
+                                ('partner_id', '=', user.partner_id.id),
+                                ('user_id', '=', user.id),
+            ]</field>
+            <field name="perm_read" eval="True"/>
+            <field name="perm_write" eval="False"/>
+            <field name="perm_create" eval="True"/>
+            <field name="perm_unlink" eval="False"/>
+            <field name="groups" eval="[(4, ref('base.group_portal'))]"/>
+        </record>
+
+    </data>
+</odoo>

+ 25 - 0
security/ir.model.access.csv

@@ -0,0 +1,25 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_helpdesk_team_collaborator_user,helpdesk.team.collaborator.user,helpdesk_extras.model_helpdesk_team_collaborator,helpdesk.group_helpdesk_user,1,1,1,1
+access_helpdesk_team_collaborator_manager,helpdesk.team.collaborator.manager,helpdesk_extras.model_helpdesk_team_collaborator,helpdesk.group_helpdesk_manager,1,1,1,1
+access_helpdesk_team_collaborator_portal,helpdesk.team.collaborator.portal,helpdesk_extras.model_helpdesk_team_collaborator,base.group_portal,1,1,1,1
+access_helpdesk_team_share_wizard_user,helpdesk.team.share.wizard.user,helpdesk_extras.model_helpdesk_team_share_wizard,helpdesk.group_helpdesk_user,1,1,1,1
+access_helpdesk_team_share_wizard_manager,helpdesk.team.share.wizard.manager,helpdesk_extras.model_helpdesk_team_share_wizard,helpdesk.group_helpdesk_manager,1,1,1,1
+access_helpdesk_team_share_wizard_portal,helpdesk.team.share.wizard.portal,helpdesk_extras.model_helpdesk_team_share_wizard,base.group_portal,1,1,1,1
+access_helpdesk_team_share_collaborator_wizard_user,helpdesk.team.share.collaborator.wizard.user,helpdesk_extras.model_helpdesk_team_share_collaborator_wizard,helpdesk.group_helpdesk_user,1,1,1,1
+access_helpdesk_team_share_collaborator_wizard_manager,helpdesk.team.share.collaborator.wizard.manager,helpdesk_extras.model_helpdesk_team_share_collaborator_wizard,helpdesk.group_helpdesk_manager,1,1,1,1
+access_helpdesk_team_share_collaborator_wizard_portal,helpdesk.team.share.collaborator.wizard.portal,helpdesk_extras.model_helpdesk_team_share_collaborator_wizard,base.group_portal,1,1,1,1
+access_helpdesk_request_type_internal,helpdesk.request.type.internal,helpdesk_extras.model_helpdesk_request_type,base.group_user,1,0,0,0
+access_helpdesk_request_type_manager,helpdesk.request.type.manager,helpdesk_extras.model_helpdesk_request_type,helpdesk.group_helpdesk_manager,1,1,1,1
+access_helpdesk_request_type_portal,helpdesk.request.type.portal,helpdesk_extras.model_helpdesk_request_type,base.group_portal,1,0,0,0
+access_helpdesk_template_internal,helpdesk.template.internal,helpdesk_extras.model_helpdesk_template,base.group_user,1,0,0,0
+access_helpdesk_template_manager,helpdesk.template.manager,helpdesk_extras.model_helpdesk_template,helpdesk.group_helpdesk_manager,1,1,1,1
+access_helpdesk_template_field_internal,helpdesk.template.field.internal,helpdesk_extras.model_helpdesk_template_field,base.group_user,1,0,0,0
+access_helpdesk_template_field_manager,helpdesk.template.field.manager,helpdesk_extras.model_helpdesk_template_field,helpdesk.group_helpdesk_manager,1,1,1,1
+access_helpdesk_workflow_template_internal,helpdesk.workflow.template.internal,helpdesk_extras.model_helpdesk_workflow_template,base.group_user,1,0,0,0
+access_helpdesk_workflow_template_manager,helpdesk.workflow.template.manager,helpdesk_extras.model_helpdesk_workflow_template,helpdesk.group_helpdesk_manager,1,1,1,1
+access_helpdesk_workflow_template_stage_internal,helpdesk.workflow.template.stage.internal,helpdesk_extras.model_helpdesk_workflow_template_stage,base.group_user,1,0,0,0
+access_helpdesk_workflow_template_stage_manager,helpdesk.workflow.template.stage.manager,helpdesk_extras.model_helpdesk_workflow_template_stage,helpdesk.group_helpdesk_manager,1,1,1,1
+access_helpdesk_workflow_template_sla_internal,helpdesk.workflow.template.sla.internal,helpdesk_extras.model_helpdesk_workflow_template_sla,base.group_user,1,0,0,0
+access_helpdesk_workflow_template_sla_manager,helpdesk.workflow.template.sla.manager,helpdesk_extras.model_helpdesk_workflow_template_sla,helpdesk.group_helpdesk_manager,1,1,1,1
+access_helpdesk_workflow_template_apply_wizard_user,helpdesk.workflow.template.apply.wizard.user,helpdesk_extras.model_helpdesk_workflow_template_apply_wizard,helpdesk.group_helpdesk_user,1,1,1,1
+access_helpdesk_workflow_template_apply_wizard_manager,helpdesk.workflow.template.apply.wizard.manager,helpdesk_extras.model_helpdesk_workflow_template_apply_wizard,helpdesk.group_helpdesk_manager,1,1,1,1

+ 32 - 0
static/src/js/helpdesk_template_field_list.js

@@ -0,0 +1,32 @@
+/** @odoo-module **/
+
+import { registry } from '@web/core/registry';
+import { ListRenderer } from '@web/views/list/list_renderer';
+import { X2ManyField, x2ManyField } from '@web/views/fields/x2many/x2many_field';
+
+/**
+ * Custom ListRenderer for helpdesk.template.field that hides delete button
+ * for fields with model_required=True
+ */
+export class HelpdeskTemplateFieldListRenderer extends ListRenderer {
+    static recordRowTemplate = 'helpdesk_extras.ListRenderer.RecordRow';
+}
+
+/**
+ * Custom X2ManyField for helpdesk.template.field_ids
+ */
+export class HelpdeskTemplateFieldOne2Many extends X2ManyField {
+    static components = {
+        ...X2ManyField.components,
+        ListRenderer: HelpdeskTemplateFieldListRenderer,
+    };
+}
+
+export const helpdeskTemplateFieldOne2Many = {
+    ...x2ManyField,
+    component: HelpdeskTemplateFieldOne2Many,
+};
+
+// Register the widget so it can be used in views with widget="helpdesk_template_field_ids"
+registry.category('fields').add('helpdesk_template_field_ids', helpdeskTemplateFieldOne2Many);
+

+ 142 - 0
static/src/js/helpdesk_template_field_m2o_widget.js

@@ -0,0 +1,142 @@
+/** @odoo-module **/
+
+import { registry } from '@web/core/registry';
+import { _t } from '@web/core/l10n/translation';
+import { many2OneField, Many2OneField } from '@web/views/fields/many2one/many2one_field';
+import { Component, onWillStart, onWillUpdateProps, useState } from '@odoo/owl';
+import { useService } from '@web/core/utils/hooks';
+
+/**
+ * Custom Many2One field widget that dynamically changes the model
+ * based on visibility_condition_m2o_model field
+ * Similar to Many2OneReferenceField but for our use case
+ */
+export class DynamicMany2OneField extends Component {
+    static template = "helpdesk_extras.DynamicMany2OneField";
+    static components = { Many2OneField };
+    static props = Many2OneField.props;
+
+    setup() {
+        this.orm = useService("orm");
+        this.displayNameState = useState({ value: null });
+        
+        onWillStart(async () => {
+            await this.loadDisplayName();
+        });
+
+        onWillUpdateProps(async (nextProps) => {
+            // Reload display_name if value or relation changed
+            const currentValue = this.props.record.data[this.props.name];
+            const nextValue = nextProps.record.data[nextProps.name];
+            const currentRelation = this.relation;
+            const nextRelation = this.getRelationFromProps(nextProps);
+            
+            if (currentValue !== nextValue || currentRelation !== nextRelation) {
+                await this.loadDisplayNameFromProps(nextProps);
+            }
+        });
+    }
+
+    getRelationFromProps(props) {
+        const modelField = props.record.data.visibility_condition_m2o_model;
+        if (modelField && typeof modelField === 'string' && modelField.length > 0) {
+            return modelField;
+        }
+        return 'res.partner';
+    }
+
+    async loadDisplayNameFromProps(props) {
+        const value = props.record.data[props.name];
+        const relation = this.getRelationFromProps(props);
+        
+        // If we have an ID but no display_name, load it
+        if (value && typeof value === 'number' && relation) {
+            try {
+                const records = await this.orm.webRead(relation, [value], {
+                    specification: { display_name: {} },
+                });
+                if (records && records.length > 0) {
+                    this.displayNameState.value = records[0].display_name;
+                } else {
+                    this.displayNameState.value = null;
+                }
+            } catch (error) {
+                // Record might not exist or model might not be accessible
+                this.displayNameState.value = null;
+            }
+        } else {
+            this.displayNameState.value = null;
+        }
+    }
+
+    get relation() {
+        // Get the model from visibility_condition_m2o_model field (now a related field)
+        const modelField = this.props.record.data.visibility_condition_m2o_model;
+        
+        if (modelField && typeof modelField === 'string' && modelField.length > 0) {
+            return modelField;
+        }
+        
+        // Fallback to base model
+        return 'res.partner';
+    }
+
+    async loadDisplayName() {
+        await this.loadDisplayNameFromProps(this.props);
+    }
+
+    get m2oProps() {
+        const relation = this.relation;
+        const value = this.props.record.data[this.props.name];
+        
+        // Handle different value formats
+        let m2oValue = false;
+        if (value) {
+            if (Array.isArray(value)) {
+                // Value is already a tuple [id, display_name]
+                m2oValue = value;
+            } else if (typeof value === 'number') {
+                // Value is just an ID, use loaded display_name or empty string
+                const displayName = this.displayNameState.value || '';
+                m2oValue = [value, displayName];
+            } else if (value && typeof value === 'object' && 'id' in value) {
+                // Value is an object with id and display_name
+                m2oValue = [value.id, value.display_name || ''];
+            }
+        }
+        
+        return {
+            ...this.props,
+            relation,
+            value: m2oValue,
+            readonly: this.props.readonly || !relation,
+            update: async (changes) => {
+                if (changes[this.props.name]) {
+                    // changes[this.props.name] is a tuple [id, display_name]
+                    const newValue = changes[this.props.name];
+                    // Update display_name state
+                    this.displayNameState.value = newValue[1] || null;
+                    // Since the field is Integer, only pass the ID (first element of tuple)
+                    return this.props.record.update({ 
+                        [this.props.name]: newValue[0]  // Only the ID
+                    });
+                } else {
+                    this.displayNameState.value = null;
+                    return this.props.record.update({ [this.props.name]: false });
+                }
+            },
+        };
+    }
+}
+
+export const dynamicMany2OneField = {
+    component: DynamicMany2OneField,
+    displayName: _t("Dynamic Many2One"),
+    relatedFields: [{ name: "display_name", type: "char" }],
+    supportedTypes: ["integer"],  // Changed from "many2one" to "integer" since the field is now Integer
+    extractProps: many2OneField.extractProps,
+};
+
+// Register the widget
+registry.category('fields').add('dynamic_many2one', dynamicMany2OneField);
+

+ 165 - 0
static/src/js/website_helpdesk_form_block.js

@@ -0,0 +1,165 @@
+/** @odoo-module **/
+
+import { rpc } from '@web/core/network/rpc';
+import publicWidget from '@web/legacy/js/public/public_widget';
+
+publicWidget.registry.HelpdeskFormBlock = publicWidget.Widget.extend({
+    selector: '#helpdesk_ticket_form',
+
+    /**
+     * @override
+     */
+    start: function () {
+        var self = this;
+        return this._super.apply(this, arguments).then(function () {
+            // Wait a bit for the form to be fully rendered
+            setTimeout(function () {
+                self._checkFormBlock();
+            }, 500);
+        });
+    },
+
+    /**
+     * Check if form should be blocked and block/unblock accordingly
+     */
+    _checkFormBlock: function () {
+        var self = this;
+        var form = this.$el;
+        var teamId = null;
+
+        // First try to get from data attribute (most reliable)
+        var dataValues = form.siblings('span.hidden[data-for="helpdesk_ticket_form"]');
+        if (dataValues.length) {
+            try {
+                var values = JSON.parse(dataValues.attr('data-values') || '{}');
+                teamId = values.team_id || values.team;
+                console.log('Helpdesk Form Block: Found team_id in data-values:', teamId);
+            } catch (e) {
+                console.warn('Error parsing team_id from data-values', e);
+            }
+        }
+
+        // Fallback: Get team_id from hidden input
+        if (!teamId) {
+            var teamIdInput = form.find('input[name="team_id"]');
+            if (teamIdInput.length) {
+                teamId = teamIdInput.val();
+                console.log('Helpdesk Form Block: Found team_id in hidden input:', teamId);
+            }
+        }
+
+        // Try to get from form container if available
+        if (!teamId && form.closest('[data-team-id]').length) {
+            teamId = form.closest('[data-team-id]').attr('data-team-id');
+            console.log('Helpdesk Form Block: Found team_id in data-team-id:', teamId);
+        }
+
+        // If no team_id, don't block (public forms)
+        if (!teamId) {
+            console.log('Helpdesk Form Block: No team_id found, skipping block check');
+            return;
+        }
+
+        console.log('Helpdesk Form Block: Checking block status for team_id:', teamId);
+
+        // Check if form should be blocked
+        rpc('/helpdesk/form/check_block', {
+            team_id: parseInt(teamId, 10),
+        }).then(function (result) {
+            console.log('Helpdesk Form Block: Result from server:', result);
+            if (result && result.should_block) {
+                console.log('Helpdesk Form Block: Blocking form');
+                self._blockForm(result.message);
+            } else {
+                console.log('Helpdesk Form Block: Form not blocked');
+                self._unblockForm();
+            }
+        }).catch(function (error) {
+            console.warn('Error checking form block status:', error);
+            // On error, don't block to avoid breaking the form
+            self._unblockForm();
+        });
+    },
+
+    /**
+     * Block form fields and submit button
+     */
+    _blockForm: function (message) {
+        var form = this.$el;
+        var blockMessage = $('#helpdesk_form_block_message');
+        var blockMessageText = $('#helpdesk_form_block_message_text');
+
+        // Show blocking message
+        if (blockMessage.length && blockMessageText.length) {
+            blockMessageText.text(message || 'El formulario no está disponible en este momento.');
+            blockMessage.removeClass('d-none');
+        }
+
+        // Block all input fields
+        form.find('input, textarea, select').each(function () {
+            var $field = $(this);
+            $field.prop('disabled', true);
+            $field.addClass('helpdesk-form-blocked');
+        });
+
+        // Block submit button (both button and link types)
+        form.find('button[type="submit"], input[type="submit"], .s_website_form_send').each(function () {
+            var $button = $(this);
+            if ($button.is('a')) {
+                // For links, prevent default and disable pointer events
+                $button.on('click.helpdesk-block', function (e) {
+                    e.preventDefault();
+                    e.stopPropagation();
+                    return false;
+                });
+                $button.css('pointer-events', 'none');
+                $button.css('opacity', '0.6');
+            } else {
+                $button.prop('disabled', true);
+            }
+            $button.addClass('helpdesk-form-blocked');
+        });
+
+        // Add visual indication
+        form.addClass('helpdesk-form-blocked');
+    },
+
+    /**
+     * Unblock form fields and submit button
+     */
+    _unblockForm: function () {
+        var form = this.$el;
+        var blockMessage = $('#helpdesk_form_block_message');
+
+        // Hide blocking message
+        if (blockMessage.length) {
+            blockMessage.addClass('d-none');
+        }
+
+        // Unblock all input fields
+        form.find('input, textarea, select').each(function () {
+            var $field = $(this);
+            $field.prop('disabled', false);
+            $field.removeClass('helpdesk-form-blocked');
+        });
+
+        // Unblock submit button (both button and link types)
+        form.find('button[type="submit"], input[type="submit"], .s_website_form_send').each(function () {
+            var $button = $(this);
+            if ($button.is('a')) {
+                // For links, restore pointer events and opacity
+                $button.off('click.helpdesk-block');
+                $button.css('pointer-events', '');
+                $button.css('opacity', '');
+            } else {
+                $button.prop('disabled', false);
+            }
+            $button.removeClass('helpdesk-form-blocked');
+        });
+
+        // Remove visual indication
+        form.removeClass('helpdesk-form-blocked');
+    },
+});
+
+export default publicWidget.registry.HelpdeskFormBlock;

+ 261 - 0
static/src/snippets/s_helpdesk_hours/000.js

@@ -0,0 +1,261 @@
+/** @odoo-module **/
+
+import { rpc } from "@web/core/network/rpc";
+import publicWidget from "@web/legacy/js/public/public_widget";
+
+publicWidget.registry.HelpdeskHours = publicWidget.Widget.extend({
+    selector: '.s_helpdesk_hours',
+    disabledInEditableMode: false,
+
+    /**
+     * @override
+     */
+    start: function () {
+        const def = this._super.apply(this, arguments);
+        if (this.editableMode) {
+            // In edit mode, show sample data
+            this._updateDisplay({
+                total_available: 100.0,
+                hours_used: 25.0,
+                prepaid_hours: 60.0,
+                credit_hours: 40.0,
+                credit_available: 2000.0,
+                highest_price: 50.0,
+            });
+            return def;
+        }
+        // In view mode, fetch real data only if user is authenticated
+        // The backend will handle authentication check
+        return Promise.all([def, this._fetchHoursData()]);
+    },
+
+    /**
+     * Fetch hours data from the backend
+     * @private
+     */
+    _fetchHoursData: function () {
+        const self = this;
+        const $loading = this.$el.find('.s_helpdesk_hours_loading');
+        const $error = this.$el.find('.s_helpdesk_hours_error');
+        const $content = this.$el.find('.s_helpdesk_hours_progress, .s_helpdesk_hours_breakdown');
+
+        // Show loading state
+        $loading.show();
+        $error.hide();
+        $content.hide();
+
+        return rpc('/helpdesk/hours/available', {})
+            .then(function (data) {
+                $loading.hide();
+                if (data.error) {
+                    if (data.error === 'Access denied') {
+                        $error.find('.s_helpdesk_hours_error_message').text(
+                            'Debe iniciar sesión como usuario del portal para ver sus horas disponibles.'
+                        );
+                    } else {
+                        $error.find('.s_helpdesk_hours_error_message').text(data.error);
+                    }
+                    $error.show();
+                    $content.hide();
+                } else {
+                    $error.hide();
+                    $content.show();
+                    self._updateDisplay(data);
+                }
+            })
+            .catch(function (error) {
+                $loading.hide();
+                // Check if it's an authentication error
+                if (error && (error.message && error.message.includes('403') || error.code === 403)) {
+                    $error.find('.s_helpdesk_hours_error_message').text(
+                        'Debe iniciar sesión como usuario del portal para ver sus horas disponibles.'
+                    );
+                } else {
+                    $error.find('.s_helpdesk_hours_error_message').text(
+                        'Error al cargar las horas disponibles. Por favor, intente más tarde.'
+                    );
+                }
+                $error.show();
+                $content.hide();
+                console.error('Error fetching helpdesk hours:', error);
+            });
+    },
+
+    /**
+     * Update the display with hours data
+     * @private
+     * @param {Object} data - Hours data from backend
+     */
+    _updateDisplay: function (data) {
+        // Ensure all values are numbers with fallback to 0
+        const total = parseFloat(data.total_available) || 0;
+        const used = parseFloat(data.hours_used) || 0;
+        const prepaid = parseFloat(data.prepaid_hours) || 0;
+        const credit = parseFloat(data.credit_hours) || 0;
+        const creditAvailable = parseFloat(data.credit_available) || 0;
+
+        // Debug logging
+        console.log('Helpdesk Hours Data (RAW):', data);
+        console.log('Helpdesk Hours Data (PARSED):', {
+            total_available: total,
+            prepaid_hours: prepaid,
+            credit_hours: credit,
+            credit_available: creditAvailable,
+            hours_used: used,
+        });
+
+        // Get elements
+        const $normalContent = this.$el.find('.s_helpdesk_hours_normal_content');
+        const $noHoursMessage = this.$el.find('.s_helpdesk_hours_no_hours');
+
+        // If total is 0, show no hours message and hide normal content
+        if (total === 0) {
+            $normalContent.hide();
+            $noHoursMessage.show();
+
+            // Update packages link
+            if (data.packages_url) {
+                this.$el.find('.s_helpdesk_hours_packages_link').attr('href', data.packages_url);
+            }
+
+            // Update contact links if available
+            if (data.whatsapp_number) {
+                const whatsappLink = 'https://wa.me/' + data.whatsapp_number.replace(/[^0-9]/g, '');
+                this.$el.find('.s_helpdesk_hours_whatsapp_link').attr('href', whatsappLink).text('WhatsApp ' + data.whatsapp_number);
+            } else {
+                this.$el.find('.s_helpdesk_hours_whatsapp_link').text('WhatsApp');
+            }
+
+            if (data.email) {
+                this.$el.find('.s_helpdesk_hours_email_link').attr('href', 'mailto:' + data.email).text('correo electrónico');
+            } else {
+                this.$el.find('.s_helpdesk_hours_email_link').text('correo electrónico');
+            }
+
+            return;
+        }
+
+        // Show normal content and hide no hours message
+        $noHoursMessage.hide();
+        $normalContent.show();
+
+        // Calculate percentage
+        const totalWithUsed = total + used;
+        const percentage = totalWithUsed > 0 ? Math.round((used / totalWithUsed) * 100) : 0;
+
+        // Update progress bar
+        const $progressBar = this.$el.find('.s_helpdesk_hours_progress_bar');
+        $progressBar.css('width', percentage + '%');
+        this.$el.find('.s_helpdesk_hours_percentage').text(percentage);
+
+        // Update hours in summary section with formatted values
+        const formattedPrepaid = this._formatHours(prepaid);
+        const formattedCredit = this._formatHours(credit);
+        const formattedTotal = this._formatHours(total);
+
+        // Debug logging
+        console.log('Formatting hours:', {
+            prepaid: prepaid,
+            formattedPrepaid: formattedPrepaid,
+            credit: credit,
+            formattedCredit: formattedCredit,
+            total: total,
+            formattedTotal: formattedTotal,
+        });
+
+        // Update all elements individually - this ensures all values are set correctly
+        this.$el.find('.s_helpdesk_hours_prepaid').text(formattedPrepaid + 'h');
+        this.$el.find('.s_helpdesk_hours_prepaid_label').text(formattedPrepaid + 'h');
+        this.$el.find('.s_helpdesk_hours_total').text(formattedTotal + 'h');
+
+        // Show/hide credit information based on credit_available
+        const $creditLine = this.$el.find('.s_helpdesk_hours_credit').closest('p');
+        const hasCredit = creditAvailable > 0 || credit > 0;
+
+        if (hasCredit) {
+            $creditLine.show();
+            this.$el.find('.s_helpdesk_hours_credit').text(formattedCredit + 'h');
+            this.$el.find('.s_helpdesk_hours_credit_label').text(formattedCredit + 'h');
+
+            // Update total text paragraph to include credit
+            const $totalParagraph = this.$el.find('p').has('.s_helpdesk_hours_total');
+            if ($totalParagraph.length) {
+                $totalParagraph.html(
+                    'Total disponible: <span class="s_helpdesk_hours_total">' + formattedTotal + 'h</span> ' +
+                    '(<span class="s_helpdesk_hours_prepaid_label">' + formattedPrepaid + 'h</span> Prepago + ' +
+                    '<span class="s_helpdesk_hours_credit_label">' + formattedCredit + 'h</span> Crédito)'
+                );
+            }
+        } else {
+            $creditLine.hide();
+
+            // Update total text paragraph to exclude credit
+            const $totalParagraph = this.$el.find('p').has('.s_helpdesk_hours_total');
+            if ($totalParagraph.length) {
+                $totalParagraph.html(
+                    'Total disponible: <span class="s_helpdesk_hours_total">' + formattedTotal + 'h</span> ' +
+                    '(<span class="s_helpdesk_hours_prepaid_label">' + formattedPrepaid + 'h</span> Prepago)'
+                );
+            }
+        }
+
+        // Final update to ensure all elements have the correct values after HTML replacement
+        this.$el.find('.s_helpdesk_hours_prepaid').text(formattedPrepaid + 'h');
+        this.$el.find('.s_helpdesk_hours_prepaid_label').text(formattedPrepaid + 'h');
+        this.$el.find('.s_helpdesk_hours_total').text(formattedTotal + 'h');
+        if (hasCredit) {
+            this.$el.find('.s_helpdesk_hours_credit').text(formattedCredit + 'h');
+            this.$el.find('.s_helpdesk_hours_credit_label').text(formattedCredit + 'h');
+        }
+
+        // Debug: Verify what's actually displayed
+        console.log('Final displayed values:', {
+            prepaid: this.$el.find('.s_helpdesk_hours_prepaid').text(),
+            total: this.$el.find('.s_helpdesk_hours_total').text(),
+            credit: this.$el.find('.s_helpdesk_hours_credit').text(),
+        });
+    },
+
+    /**
+     * Format hours number for display
+     * @private
+     * @param {Number} hours - Hours to format
+     * @returns {String} Formatted hours
+     */
+    _formatHours: function (hours) {
+        // Ensure we always have a valid number
+        const numHours = parseFloat(hours);
+
+        // Debug logging
+        console.log('_formatHours input:', hours, 'parsed:', numHours);
+
+        if (isNaN(numHours) || numHours === null || numHours === undefined) {
+            return '0';
+        }
+
+        // Allow 0 to be displayed if it's explicitly 0
+        if (numHours === 0) {
+            return '0';
+        }
+
+        // Round to 2 decimal places
+        const rounded = Math.round(numHours * 100) / 100;
+
+        // Check if it's a whole number
+        if (rounded % 1 === 0) {
+            // It's a whole number, return as integer string
+            return rounded.toString();
+        }
+
+        // It has decimals, format to remove trailing zeros
+        // Convert to string and remove trailing zeros after decimal point only
+        let formatted = rounded.toString();
+        // Remove trailing zeros after decimal point (e.g., "10.50" -> "10.5", "10.00" -> "10")
+        formatted = formatted.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1');
+
+        const result = formatted || '0';
+
+        console.log('_formatHours result:', result);
+        return result;
+    },
+});

+ 36 - 0
static/src/snippets/s_helpdesk_hours/000.scss

@@ -0,0 +1,36 @@
+.s_helpdesk_hours {
+    // Colores del gradiente M22
+    --m22-orange: #FF6B00;
+    --m22-magenta: #E1467C;
+    
+    // Texto con gradiente (para porcentaje y link)
+    .s_helpdesk_hours_gradient_text,
+    .s_helpdesk_hours_link {
+        background: linear-gradient(to right, var(--m22-orange), var(--m22-magenta));
+        -webkit-background-clip: text;
+        -webkit-text-fill-color: transparent;
+        background-clip: text;
+        display: inline-block;
+    }
+    
+    // Link hover
+    .s_helpdesk_hours_link {
+        transition: opacity 0.2s ease;
+        
+        &:hover {
+            opacity: 0.8;
+        }
+    }
+    
+    // Barra de progreso con gradiente
+    .s_helpdesk_hours_progress_bar {
+        background: linear-gradient(to right, var(--m22-orange), var(--m22-magenta));
+        transition: width 0.6s ease;
+    }
+    
+    // Estados de loading y error dentro de la card
+    .s_helpdesk_hours_loading,
+    .s_helpdesk_hours_error {
+        margin-top: 1rem;
+    }
+}

+ 15 - 0
static/src/xml/helpdesk_template_field_list.xml

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<templates>
+    <t
+        t-name="helpdesk_extras.ListRenderer.RecordRow"
+        t-inherit="web.ListRenderer.RecordRow"
+        t-inherit-mode="primary"
+    >
+        <!-- Hide the "delete" button for model_required fields -->
+        <xpath expr="//td[hasclass('o_list_record_remove')]" position="attributes">
+            <attribute name="t-att-class">{
+                'd-none': record.data.model_required
+            }</attribute>
+        </xpath>
+    </t>
+</templates>

+ 8 - 0
static/src/xml/helpdesk_template_field_m2o_widget.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+    <t t-name="helpdesk_extras.DynamicMany2OneField">
+        <Many2OneField t-props="m2oProps"/>
+    </t>
+
+</templates>

+ 772 - 0
views/helpdesk_portal_templates.xml

@@ -0,0 +1,772 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <!-- Extend portal tickets page to add "New" button for collaborators -->
+    <template id="portal_helpdesk_ticket_new_button" name="Portal Helpdesk Tickets New Button" inherit_id="helpdesk.portal_helpdesk_ticket" priority="20">
+        <!-- Add button after the searchbar, similar to how helpdesk_sale_timesheet extends this template -->
+        <xpath expr="//t[@t-call='portal.portal_searchbar']" position="after">
+            <div t-if="collaborator_team_form_url or admin_teams" class="mb-3 text-end">
+                <a t-if="collaborator_team_form_url" t-attf-href="#{collaborator_team_form_url}" class="btn btn-primary me-2">
+                    <i class="fa fa-plus me-2"/>
+                    Nuevo Ticket
+                </a>
+                <t t-if="admin_teams" t-foreach="admin_teams" t-as="admin_team">
+                    <a t-attf-href="/my/helpdesk/teams/#{admin_team.id}/collaborators" class="btn btn-primary me-2">
+                        <i class="fa fa-users me-2"/>
+                        Gestionar Colaboradores - <t t-esc="admin_team.name"/>
+                    </a>
+                </t>
+            </div>
+        </xpath>
+    </template>
+    
+    <!-- Extend ticket detail view to show new fields -->
+    <template id="portal_helpdesk_ticket_detail_extras" name="Portal Helpdesk Ticket Detail Extras" inherit_id="helpdesk.tickets_followup" priority="20">
+        <!-- Add new fields after "Reported on" -->
+        <xpath expr="//div[@name='description']" position="before">
+            <div t-if="ticket.request_type_id" class="row mb-4">
+                <strong class="col-lg-3">Request Type</strong>
+                <span class="col-lg-9" t-field="ticket.request_type_id.name"/>
+            </div>
+            <div t-if="ticket.affected_module_id" class="row mb-4">
+                <strong class="col-lg-3">Affected Module</strong>
+                <span class="col-lg-9" t-field="ticket.affected_module_id.shortdesc"/>
+            </div>
+            <div t-if="ticket.business_impact" class="row mb-4">
+                <strong class="col-lg-3">Business Impact</strong>
+                <span class="col-lg-9">
+                    <t t-if="ticket.business_impact == '0'">Critical</t>
+                    <t t-elif="ticket.business_impact == '1'">High</t>
+                    <t t-elif="ticket.business_impact == '2'">Normal</t>
+                </span>
+            </div>
+            <div t-if="ticket.reproduce_steps and ticket.request_type_code == 'incident'" class="row mb-4">
+                <strong class="col-lg-3">Steps to Reproduce</strong>
+                <div class="col-lg-9" t-field="ticket.reproduce_steps"/>
+            </div>
+            <div t-if="ticket.business_goal and ticket.request_type_code == 'improvement'" class="row mb-4">
+                <strong class="col-lg-3">Business Goal</strong>
+                <div class="col-lg-9" t-field="ticket.business_goal"/>
+            </div>
+            <div t-if="ticket.estimated_hours" class="row mb-4">
+                <strong class="col-lg-3">Estimated Hours</strong>
+                <span class="col-lg-9" t-field="ticket.estimated_hours" t-options='{"widget": "float"}'/>
+            </div>
+            <div t-if="ticket.approval_status" class="row mb-4">
+                <strong class="col-lg-3">Approval Status</strong>
+                <span class="col-lg-9">
+                    <t t-if="ticket.approval_status == 'draft'">N/A</t>
+                    <t t-elif="ticket.approval_status == 'waiting'">Waiting for Approval</t>
+                    <t t-elif="ticket.approval_status == 'approved'">Approved</t>
+                    <t t-elif="ticket.approval_status == 'rejected'">Rejected</t>
+                </span>
+            </div>
+        </xpath>
+    </template>
+
+
+    <!-- Page to manage collaborators -->
+    <template id="portal_team_collaborators" name="Team Collaborators Management">
+        <t t-call="portal.portal_layout">
+            <t t-set="title">Gestionar Colaboradores</t>
+            <div class="container mt-3">
+                <div class="row">
+                    <div class="col-12">
+                        <h2 class="mb-4">
+                            <i class="fa fa-users me-2"/>
+                            Gestionar Colaboradores - <t t-esc="team.name"/>
+                        </h2>
+                        
+                        <!-- Success/Error Messages -->
+                        <t t-if="request.session.get('success')">
+                            <div class="alert alert-success alert-dismissible fade show" role="alert">
+                                <t t-esc="request.session.pop('success')"/>
+                                <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
+                            </div>
+                        </t>
+                        <t t-if="request.session.get('error')">
+                            <div class="alert alert-danger alert-dismissible fade show" role="alert">
+                                <t t-esc="request.session.pop('error')"/>
+                                <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
+                            </div>
+                        </t>
+                        
+                        <!-- Add Collaborator Form -->
+                        <div class="card mb-3">
+                            <div class="card-header">
+                                <h5 class="mb-0">Agregar Colaborador</h5>
+                            </div>
+                            <div class="card-body">
+                                <form t-attf-action="/my/helpdesk/teams/#{team.id}/collaborators/add" method="post" id="add-collaborator-form">
+                                    <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
+                                    <div class="row">
+                                        <div class="col-md-5">
+                                            <div class="mb-3" style="position: relative;">
+                                                <label class="form-label">Contacto</label>
+                                                <div class="input-group" style="position: relative;">
+                                                    <input type="text" 
+                                                           class="form-control partner-search" 
+                                                           placeholder="Buscar contacto o crear nuevo..."
+                                                           autocomplete="off"
+                                                           id="partner-search-input"
+                                                           required="required"/>
+                                                    <input type="hidden" name="partner_id" id="partner-id-input"/>
+                                                    <a t-attf-href="/my/helpdesk/teams/#{team.id}/collaborators/new" class="btn btn-outline-secondary">
+                                                        <i class="fa fa-plus"></i> Nuevo
+                                                    </a>
+                                                </div>
+                                                <div class="partner-search-results" id="partner-search-results" style="display: none; position: absolute; z-index: 1000; background: white; border: 1px solid #ddd; max-height: 200px; overflow-y: auto; width: 100%; box-shadow: 0 2px 5px rgba(0,0,0,0.2); top: 100%; left: 0;"></div>
+                                            </div>
+                                        </div>
+                                        <div class="col-md-4">
+                                            <div class="mb-3">
+                                                <label class="form-label">Rol</label>
+                                                <select name="access_mode" class="form-select" required="required">
+                                                    <option value="user_own">User - Own Tickets</option>
+                                                    <option value="user_all">User - All Tickets</option>
+                                                    <option value="admin">Administrator</option>
+                                                </select>
+                                            </div>
+                                        </div>
+                                        <div class="col-md-3">
+                                            <div class="mb-3">
+                                                <label class="form-label">&#160;</label>
+                                                <button type="submit" class="btn btn-primary w-100">
+                                                    <i class="fa fa-plus me-2"/>
+                                                    Agregar
+                                                </button>
+                                            </div>
+                                        </div>
+                                    </div>
+                                    <small class="text-muted">Solo puedes agregar contactos de tu misma red de contactos (misma empresa).</small>
+                                </form>
+                            </div>
+                        </div>
+                        
+                        <!-- Collaborators List -->
+                        <div class="card">
+                            <div class="card-header">
+                                <h5 class="mb-0">Colaboradores del Equipo</h5>
+                            </div>
+                            <div class="card-body">
+                                <t t-if="collaborators">
+                                    <div class="table-responsive">
+                                        <table class="table table-hover">
+                                            <thead>
+                                                <tr>
+                                                    <th>Colaborador</th>
+                                                    <th>Email</th>
+                                                    <th>Rol</th>
+                                                    <th class="text-end">Acciones</th>
+                                                </tr>
+                                            </thead>
+                                            <tbody>
+                                                <t t-foreach="collaborators" t-as="collaborator">
+                                                    <tr>
+                                                        <td>
+                                                            <t t-esc="collaborator.partner_id.name"/>
+                                                        </td>
+                                                        <td>
+                                                            <t t-esc="collaborator.partner_email or ''"/>
+                                                        </td>
+                                                        <td>
+                                                            <t t-if="collaborator.partner_id.id != current_partner_id">
+                                                                <form t-attf-action="/my/helpdesk/teams/#{team.id}/collaborators/#{collaborator.id}/update" method="post" style="display: inline;">
+                                                                    <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
+                                                                    <select name="access_mode" class="form-select form-select-sm" onchange="this.form.submit();">
+                                                                        <option value="admin" t-att-selected="collaborator.access_mode == 'admin'">Administrator</option>
+                                                                        <option value="user_all" t-att-selected="collaborator.access_mode == 'user_all'">User - All Tickets</option>
+                                                                        <option value="user_own" t-att-selected="collaborator.access_mode == 'user_own'">User - Own Tickets</option>
+                                                                    </select>
+                                                                </form>
+                                                            </t>
+                                                            <t t-else="">
+                                                                <select class="form-select form-select-sm" disabled="disabled">
+                                                                    <option value="admin" t-att-selected="collaborator.access_mode == 'admin'">Administrator</option>
+                                                                    <option value="user_all" t-att-selected="collaborator.access_mode == 'user_all'">User - All Tickets</option>
+                                                                    <option value="user_own" t-att-selected="collaborator.access_mode == 'user_own'">User - Own Tickets</option>
+                                                                </select>
+                                                                <small class="text-muted d-block mt-1">No puedes cambiar tu propio rol</small>
+                                                            </t>
+                                                        </td>
+                                                        <td class="text-end">
+                                                            <t t-if="collaborator.partner_id.id != current_partner_id">
+                                                                <form t-attf-action="/my/helpdesk/teams/#{team.id}/collaborators/#{collaborator.id}/delete" method="post" style="display: inline;" onsubmit="return confirm('¿Estás seguro de que deseas eliminar este colaborador?');">
+                                                                    <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
+                                                                    <button type="submit" class="btn btn-sm btn-outline-danger">
+                                                                        <i class="fa fa-trash me-1"/>
+                                                                        Eliminar
+                                                                    </button>
+                                                                </form>
+                                                            </t>
+                                                            <t t-else="">
+                                                                <span class="text-muted">No puedes eliminarte a ti mismo</span>
+                                                            </t>
+                                                        </td>
+                                                    </tr>
+                                                </t>
+                                            </tbody>
+                                        </table>
+                                    </div>
+                                </t>
+                                <t t-else="">
+                                    <p class="text-muted mb-0">No hay colaboradores en este equipo.</p>
+                                </t>
+                            </div>
+                        </div>
+                        
+                        <div class="mt-3">
+                            <a href="/my/tickets" class="btn btn-secondary">
+                                <i class="fa fa-arrow-left me-2"/>
+                                Volver a Tickets
+                            </a>
+                        </div>
+                    </div>
+                </div>
+            </div>
+            
+            <script type="text/javascript">
+                <t t-set="teamId" t-value="team.id"/>
+                <![CDATA[
+                document.addEventListener('DOMContentLoaded', function() {
+                    'use strict';
+                    
+                    var teamId = ]]><t t-esc="teamId"/><![CDATA[;
+                    var searchUrl = '/my/helpdesk/teams/' + teamId + '/collaborators/search-partners';
+                    var createUrl = '/my/helpdesk/teams/' + teamId + '/collaborators/create-partner';
+                    
+                    var searchInput = document.getElementById('partner-search-input');
+                    var partnerIdInput = document.getElementById('partner-id-input');
+                    var resultsDiv = document.getElementById('partner-search-results');
+                    var inputGroup = searchInput ? searchInput.closest('.input-group') : null;
+                    var parentDiv = searchInput ? searchInput.closest('.mb-3') : null;
+                    
+                    if (!searchInput || !resultsDiv || !partnerIdInput) {
+                        console.error('Required elements not found');
+                        return;
+                    }
+                    
+                    // Set width of results div to match input group
+                    if (inputGroup && parentDiv) {
+                        resultsDiv.style.width = inputGroup.offsetWidth + 'px';
+                    }
+                    
+                    var searchTimeout;
+                    
+                    // Function to perform search
+                    function performSearch(searchTerm) {
+                        clearTimeout(searchTimeout);
+                        var term = searchTerm ? searchTerm.trim() : '';
+                        
+                        console.log('🔍 Performing search with term:', term);
+                        
+                        // If term is empty or less than 2 chars, show all available contacts (empty search)
+                        // This allows users to see all related contacts even without typing
+                        searchTimeout = setTimeout(function() {
+                            console.log('📡 Fetching from:', searchUrl);
+                            fetch(searchUrl, {
+                                method: 'POST',
+                                headers: {
+                                    'Content-Type': 'application/json',
+                                    'X-Requested-With': 'XMLHttpRequest'
+                                },
+                                body: JSON.stringify({search_term: term, limit: 20})
+                            })
+                            .then(function(response) {
+                                console.log('📥 Response status:', response.status, response.statusText);
+                                if (!response.ok) {
+                                    throw new Error('Network response was not ok: ' + response.status);
+                                }
+                                return response.json();
+                            })
+                            .then(function(data) {
+                                console.log('📦 Raw response data:', data);
+                                
+                                // Odoo JSON-RPC wraps the response in {jsonrpc: "2.0", result: {...}}
+                                // Check if it's wrapped
+                                var responseData = data;
+                                if (data && data.jsonrpc && data.result !== undefined) {
+                                    console.log('📦 Unwrapping JSON-RPC response');
+                                    responseData = data.result;
+                                }
+                                
+                                console.log('📦 Processed response data:', responseData);
+                                
+                                if (responseData.error) {
+                                    console.error('❌ Error in response:', responseData.error);
+                                    resultsDiv.innerHTML = '<div class="p-2 text-danger">' + (responseData.error || 'Error desconocido') + '</div>';
+                                    resultsDiv.style.display = 'block';
+                                    return;
+                                }
+                                
+                                if (responseData.partners && responseData.partners.length > 0) {
+                                    console.log('✅ Found', responseData.partners.length, 'partners');
+                                    resultsDiv.innerHTML = responseData.partners.map(function(p) {
+                                        var name = (p.name || '').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+                                        var email = (p.email || 'Sin email').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+                                        return '<div class="p-2 border-bottom partner-option" style="cursor: pointer;" data-id="' + p.id + '" data-name="' + name + '" data-email="' + email + '"><strong>' + (p.name || 'Sin nombre') + '</strong><br/><small class="text-muted">' + email + '</small></div>';
+                                    }).join('');
+                                    
+                                    resultsDiv.querySelectorAll('.partner-option').forEach(function(option) {
+                                        option.addEventListener('click', function() {
+                                            searchInput.value = this.dataset.name || '';
+                                            partnerIdInput.value = this.dataset.id || '';
+                                            resultsDiv.style.display = 'none';
+                                        });
+                                    });
+                                    
+                                    resultsDiv.style.display = 'block';
+                                } else {
+                                    console.log('⚠️ No partners found in response');
+                                    resultsDiv.innerHTML = '<div class="p-2 text-muted">No se encontraron contactos</div>';
+                                    resultsDiv.style.display = 'block';
+                                }
+                            })
+                            .catch(function(error) {
+                                console.error('❌ Search error:', error);
+                                resultsDiv.innerHTML = '<div class="p-2 text-danger">Error al buscar: ' + (error.message || error) + '</div>';
+                                resultsDiv.style.display = 'block';
+                            });
+                        }, 300);
+                    }
+                    
+                    // Search on input
+                    searchInput.addEventListener('input', function() {
+                        var term = this.value.trim();
+                        if (term.length === 0) {
+                            partnerIdInput.value = '';
+                        }
+                        performSearch(term);
+                    });
+                    
+                    // Show all contacts when focusing on the input (if empty)
+                    searchInput.addEventListener('focus', function() {
+                        if (this.value.trim().length === 0) {
+                            performSearch('');
+                        }
+                    });
+                    
+                    // Close results when clicking outside
+                    document.addEventListener('click', function(e) {
+                        if (parentDiv && !parentDiv.contains(e.target)) {
+                            resultsDiv.style.display = 'none';
+                        }
+                    });
+                });
+                ]]>
+            </script>
+        </t>
+    </template>
+
+    <!-- Page to create new contact and add as collaborator -->
+    <template id="portal_new_collaborator" name="New Collaborator">
+        <t t-call="portal.portal_layout">
+            <t t-set="title">Crear Nuevo Contacto y Colaborador</t>
+            <div class="container mt-3">
+                <div class="row">
+                    <div class="col-12">
+                        <h2 class="mb-4">
+                            <i class="fa fa-user-plus me-2"/>
+                            Crear Nuevo Contacto y Colaborador - <t t-esc="team.name"/>
+                        </h2>
+                        
+                        <!-- Success/Error Messages -->
+                        <t t-if="request.session.get('success')">
+                            <div class="alert alert-success alert-dismissible fade show" role="alert">
+                                <t t-esc="request.session.pop('success')"/>
+                                <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
+                            </div>
+                        </t>
+                        <t t-if="request.session.get('error')">
+                            <div class="alert alert-danger alert-dismissible fade show" role="alert">
+                                <t t-esc="request.session.pop('error')"/>
+                                <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
+                            </div>
+                        </t>
+                        
+                        <!-- Create Contact Form -->
+                        <div class="card">
+                            <div class="card-header">
+                                <h5 class="mb-0">Información del Contacto</h5>
+                            </div>
+                            <div class="card-body">
+                                <form t-attf-action="/my/helpdesk/teams/#{team.id}/collaborators/create" method="post" id="create-collaborator-form">
+                                    <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
+                                    
+                                    <div class="row">
+                                        <div class="col-md-6">
+                                            <div class="mb-3">
+                                                <label for="name" class="form-label">Nombre <span class="text-danger">*</span></label>
+                                                <input type="text" 
+                                                       class="form-control" 
+                                                       id="name" 
+                                                       name="name" 
+                                                       required="required" 
+                                                       placeholder="Nombre completo del contacto"
+                                                       value=""/>
+                                                <div class="invalid-feedback">El nombre es requerido.</div>
+                                            </div>
+                                        </div>
+                                        <div class="col-md-6">
+                                            <div class="mb-3">
+                                                <label for="email" class="form-label">Email <span class="text-danger">*</span></label>
+                                                <input type="email" 
+                                                       class="form-control" 
+                                                       id="email" 
+                                                       name="email" 
+                                                       required="required" 
+                                                       placeholder="email@ejemplo.com"
+                                                       value=""/>
+                                                <div class="invalid-feedback">El email es requerido y debe ser válido.</div>
+                                            </div>
+                                        </div>
+                                    </div>
+                                    
+                                    <div class="row">
+                                        <div class="col-md-6">
+                                            <div class="mb-3">
+                                                <label for="phone" class="form-label">Teléfono</label>
+                                                <input type="tel" 
+                                                       class="form-control" 
+                                                       id="phone" 
+                                                       name="phone" 
+                                                       placeholder="+52 55 1234 5678"
+                                                       value=""/>
+                                                <small class="form-text text-muted">Opcional.</small>
+                                            </div>
+                                        </div>
+                                        <div class="col-md-6">
+                                            <div class="mb-3">
+                                                <label for="function" class="form-label">Cargo / Función</label>
+                                                <input type="text" 
+                                                       class="form-control" 
+                                                       id="function" 
+                                                       name="function" 
+                                                       placeholder="Ej: Gerente de TI, Desarrollador, etc."
+                                                       value=""/>
+                                                <small class="form-text text-muted">Opcional.</small>
+                                            </div>
+                                        </div>
+                                    </div>
+                                    
+                                    <div class="row">
+                                        <div class="col-md-6">
+                                            <div class="mb-3">
+                                                <label for="access_mode" class="form-label">Rol del Colaborador <span class="text-danger">*</span></label>
+                                                <select class="form-select" id="access_mode" name="access_mode" required="required">
+                                                    <option value="user_own">User - Own Tickets</option>
+                                                    <option value="user_all">User - All Tickets</option>
+                                                    <option value="admin">Administrator</option>
+                                                </select>
+                                                <small class="form-text text-muted">
+                                                    <strong>Administrator:</strong> Puede ver todos los tickets y gestionar otros usuarios.<br/>
+                                                    <strong>User - All Tickets:</strong> Puede ver todos los tickets y crear sus propios tickets.<br/>
+                                                    <strong>User - Own Tickets:</strong> Solo puede crear y ver sus propios tickets.
+                                                </small>
+                                            </div>
+                                        </div>
+                                    </div>
+                                    
+                                    <div class="alert alert-info">
+                                        <i class="fa fa-info-circle me-2"></i>
+                                        <small>El contacto se creará en tu misma red de contactos y se agregará automáticamente como colaborador del equipo con el rol seleccionado.</small>
+                                    </div>
+                                    
+                                    <div class="mt-4">
+                                        <button type="submit" class="btn btn-primary">
+                                            <i class="fa fa-save me-2"/>
+                                            Crear Contacto y Agregar como Colaborador
+                                        </button>
+                                        <a t-attf-href="/my/helpdesk/teams/#{team.id}/collaborators" class="btn btn-secondary ms-2">
+                                            <i class="fa fa-times me-2"/>
+                                            Cancelar
+                                        </a>
+                                    </div>
+                                </form>
+                            </div>
+                        </div>
+                        
+                        <div class="mt-3">
+                            <a t-attf-href="/my/helpdesk/teams/#{team.id}/collaborators" class="btn btn-secondary">
+                                <i class="fa fa-arrow-left me-2"/>
+                                Volver a Colaboradores
+                            </a>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </t>
+    </template>
+
+    <!-- Wizard to share team / manage collaborators -->
+
+    <!-- Wizard to share team / manage collaborators -->
+    <template id="portal_team_share_wizard" name="Team Share Wizard">
+        <t t-call="portal.portal_layout">
+            <t t-set="title">Compartir Equipo</t>
+            <div class="container mt-3">
+                <div class="row">
+                    <div class="col-12">
+                        <h2 class="mb-4">
+                            <i class="fa fa-share-alt me-2"/>
+                            Compartir Equipo - <t t-esc="team.name"/>
+                        </h2>
+                        
+                        <form t-attf-action="/my/helpdesk/teams/#{team.id}/collaborators/share" method="post" class="card">
+                            <div class="card-header">
+                                <h5 class="mb-0">Gestionar Colaboradores</h5>
+                            </div>
+                            <div class="card-body">
+                                <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
+                                
+                                <div class="mb-3">
+                                    <label class="form-label">Enlace Público</label>
+                                    <div class="input-group">
+                                        <input type="text" class="form-control" t-att-value="wizard.share_link" readonly="readonly"/>
+                                        <button type="button" class="btn btn-outline-secondary" onclick="navigator.clipboard.writeText(this.previousElementSibling.value); this.innerHTML='&lt;i class=&quot;fa fa-check&quot;&gt;&lt;/i&gt; Copiado'; setTimeout(() =&gt; this.innerHTML='&lt;i class=&quot;fa fa-copy&quot;&gt;&lt;/i&gt; Copiar', 2000);">
+                                            <i class="fa fa-copy"/>
+                                            Copiar
+                                        </button>
+                                    </div>
+                                </div>
+                                
+                                <div class="mb-3">
+                                    <label class="form-label">Colaboradores</label>
+                                    <div class="table-responsive">
+                                        <table class="table table-sm">
+                                            <thead>
+                                                <tr>
+                                                    <th>Colaborador</th>
+                                                    <th>Rol</th>
+                                                    <th>Enviar Invitación</th>
+                                                    <th class="text-end">Acción</th>
+                                                </tr>
+                                            </thead>
+                                            <tbody>
+                                                <t t-foreach="wizard.collaborator_ids" t-as="collab_wiz">
+                                                    <tr>
+                                                        <td>
+                                                            <t t-esc="collab_wiz.partner_id.name"/>
+                                                            <br/>
+                                                            <small class="text-muted"><t t-esc="collab_wiz.partner_id.email or ''"/></small>
+                                                        </td>
+                                                        <td>
+                                                            <select name="access_mode" class="form-select form-select-sm">
+                                                                <option value="admin" t-att-selected="collab_wiz.access_mode == 'admin'">Administrator</option>
+                                                                <option value="user_all" t-att-selected="collab_wiz.access_mode == 'user_all'">User - All Tickets</option>
+                                                                <option value="user_own" t-att-selected="collab_wiz.access_mode == 'user_own'">User - Own Tickets</option>
+                                                            </select>
+                                                        </td>
+                                                        <td class="text-center">
+                                                            <input type="checkbox" name="send_invitation" t-att-checked="collab_wiz.send_invitation" class="form-check-input"/>
+                                                        </td>
+                                                        <td class="text-end">
+                                                            <button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('tr').remove();">
+                                                                <i class="fa fa-trash"/>
+                                                            </button>
+                                                        </td>
+                                                    </tr>
+                                                </t>
+                                            </tbody>
+                                        </table>
+                                    </div>
+                                    <button type="button" class="btn btn-sm btn-outline-primary mt-2" onclick="addCollaboratorRow(this);">
+                                        <i class="fa fa-plus me-1"/>
+                                        Agregar Colaborador
+                                    </button>
+                                </div>
+                                
+                                <div class="alert alert-info">
+                                    <strong>Roles disponibles:</strong>
+                                    <ul class="mb-0">
+                                        <li><strong>Administrator:</strong> Puede ver todos los tickets y gestionar otros usuarios.</li>
+                                        <li><strong>User - All Tickets:</strong> Puede ver todos los tickets y crear sus propios tickets.</li>
+                                        <li><strong>User - Own Tickets:</strong> Solo puede crear y ver sus propios tickets.</li>
+                                    </ul>
+                                    <p class="mb-0 mt-2"><strong>Nota:</strong> Solo puedes agregar contactos de tu misma red de contactos (misma empresa).</p>
+                                </div>
+                            </div>
+                            <div class="card-footer">
+                                <button type="submit" name="action_share" class="btn btn-primary">
+                                    <i class="fa fa-share me-2"/>
+                                    Guardar Cambios
+                                </button>
+                                <a t-attf-href="/my/helpdesk/teams/#{team.id}/collaborators" class="btn btn-secondary">
+                                    Cancelar
+                                </a>
+                            </div>
+                        </form>
+                        
+                        <div class="mt-3">
+                            <a t-attf-href="/my/helpdesk/teams/#{team.id}/collaborators" class="btn btn-secondary">
+                                <i class="fa fa-arrow-left me-2"/>
+                                Volver a Colaboradores
+                            </a>
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <script type="text/javascript">
+                <t t-set="teamId" t-value="team.id"/>
+                <![CDATA[
+                (function() {
+                    'use strict';
+                    
+                    var teamId = ]]><t t-esc="teamId"/><![CDATA[;
+                    var searchUrl = '/my/helpdesk/teams/' + teamId + '/collaborators/search-partners';
+                    var createUrl = '/my/helpdesk/teams/' + teamId + '/collaborators/create-partner';
+                    
+                    window.addCollaboratorRow = function(button) {
+                        var tbody = button.closest('.card-body').querySelector('tbody');
+                        var row = document.createElement('tr');
+                        row.innerHTML = '<td><div class="input-group" style="position: relative;"><input type="text" class="form-control partner-search" placeholder="Buscar contacto o crear nuevo..." autocomplete="off" data-partner-id="" data-partner-name=""/><button type="button" class="btn btn-outline-secondary" onclick="createNewPartner(this);"><i class="fa fa-plus"></i> Nuevo</button></div><div class="partner-search-results" style="display: none; position: absolute; z-index: 1000; background: white; border: 1px solid #ddd; max-height: 200px; overflow-y: auto; width: 100%; box-shadow: 0 2px 5px rgba(0,0,0,0.2);"></div></td><td><select name="new_access_mode" class="form-select form-select-sm"><option value="user_own">User - Own Tickets</option><option value="user_all">User - All Tickets</option><option value="admin">Administrator</option></select></td><td class="text-center"><input type="checkbox" name="new_send_invitation" class="form-check-input" checked/></td><td class="text-end"><button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest(\'tr\').remove();"><i class="fa fa-trash"></i></button></td>';
+                        tbody.appendChild(row);
+                        
+                        var searchInput = row.querySelector('.partner-search');
+                        var resultsDiv = row.querySelector('.partner-search-results');
+                        var inputGroup = searchInput.closest('.input-group');
+                        inputGroup.style.position = 'relative';
+                        resultsDiv.style.position = 'absolute';
+                        resultsDiv.style.top = '100%';
+                        resultsDiv.style.left = '0';
+                        resultsDiv.style.width = inputGroup.offsetWidth + 'px';
+                        
+                        var searchTimeout;
+                        
+                        searchInput.addEventListener('input', function() {
+                            clearTimeout(searchTimeout);
+                            var term = this.value.trim();
+                            
+                            if (term.length < 2) {
+                                resultsDiv.style.display = 'none';
+                                return;
+                            }
+                            
+                            searchTimeout = setTimeout(function() {
+                                fetch(searchUrl, {
+                                    method: 'POST',
+                                    headers: {'Content-Type': 'application/json'},
+                                    body: JSON.stringify({search_term: term, limit: 10})
+                                })
+                                .then(function(response) { return response.json(); })
+                                .then(function(data) {
+                                    if (data.error) {
+                                        resultsDiv.innerHTML = '<div class="p-2 text-danger">' + data.error + '</div>';
+                                        resultsDiv.style.display = 'block';
+                                        return;
+                                    }
+                                    
+                                    if (data.partners && data.partners.length > 0) {
+                                        resultsDiv.innerHTML = data.partners.map(function(p) {
+                                            return '<div class="p-2 border-bottom partner-option" style="cursor: pointer;" data-id="' + p.id + '" data-name="' + p.name.replace(/"/g, '&quot;') + '" data-email="' + (p.email || '') + '"><strong>' + p.name + '</strong><br/><small class="text-muted">' + (p.email || 'Sin email') + '</small></div>';
+                                        }).join('');
+                                        
+                                        resultsDiv.querySelectorAll('.partner-option').forEach(function(option) {
+                                            option.addEventListener('click', function() {
+                                                searchInput.value = this.dataset.name;
+                                                searchInput.dataset.partnerId = this.dataset.id;
+                                                searchInput.dataset.partnerName = this.dataset.name;
+                                                resultsDiv.style.display = 'none';
+                                            });
+                                        });
+                                        
+                                        resultsDiv.style.display = 'block';
+                                    } else {
+                                        resultsDiv.innerHTML = '<div class="p-2 text-muted">No se encontraron contactos</div>';
+                                        resultsDiv.style.display = 'block';
+                                    }
+                                })
+                                .catch(function(error) {
+                                    resultsDiv.innerHTML = '<div class="p-2 text-danger">Error al buscar: ' + error + '</div>';
+                                    resultsDiv.style.display = 'block';
+                                });
+                            }, 300);
+                        });
+                        
+                        document.addEventListener('click', function(e) {
+                            if (!row.contains(e.target)) {
+                                resultsDiv.style.display = 'none';
+                            }
+                        });
+                        
+                        searchInput.focus();
+                    };
+                    
+                    window.createNewPartner = function(button) {
+                        var row = button.closest('tr');
+                        var searchInput = row.querySelector('.partner-search');
+                        var name = searchInput.value.trim();
+                        
+                        if (!name) {
+                            alert('Por favor ingresa un nombre para el nuevo contacto.');
+                            return;
+                        }
+                        
+                        var email = prompt('Ingresa el email del nuevo contacto (opcional):', '');
+                        if (email === null) return;
+                        
+                        fetch(createUrl, {
+                            method: 'POST',
+                            headers: {'Content-Type': 'application/json'},
+                            body: JSON.stringify({name: name, email: email || ''})
+                        })
+                        .then(function(response) { return response.json(); })
+                        .then(function(data) {
+                            if (data.error) {
+                                alert('Error: ' + data.error);
+                                return;
+                            }
+                            
+                            if (data.partner) {
+                                searchInput.value = data.partner.name;
+                                searchInput.dataset.partnerId = data.partner.id;
+                                searchInput.dataset.partnerName = data.partner.name;
+                                alert('Contacto creado exitosamente.');
+                            }
+                        })
+                        .catch(function(error) {
+                            alert('Error al crear contacto: ' + error);
+                        });
+                    };
+                    
+                    document.addEventListener('DOMContentLoaded', function() {
+                        var form = document.querySelector('form[action*="/collaborators/share"]');
+                        if (form) {
+                            form.addEventListener('submit', function(e) {
+                                var rows = form.querySelectorAll('tbody tr');
+                                rows.forEach(function(row, index) {
+                                    var searchInput = row.querySelector('.partner-search');
+                                    if (searchInput && searchInput.dataset.partnerId) {
+                                        var hiddenInput = document.createElement('input');
+                                        hiddenInput.type = 'hidden';
+                                        hiddenInput.name = 'new_collaborator_partner_id[]';
+                                        hiddenInput.value = searchInput.dataset.partnerId;
+                                        form.appendChild(hiddenInput);
+                                        
+                                        var accessModeSelect = row.querySelector('select[name="new_access_mode"]');
+                                        if (accessModeSelect) {
+                                            var accessModeInput = document.createElement('input');
+                                            accessModeInput.type = 'hidden';
+                                            accessModeInput.name = 'new_collaborator_access_mode[]';
+                                            accessModeInput.value = accessModeSelect.value;
+                                            form.appendChild(accessModeInput);
+                                        }
+                                        
+                                        var sendInvitationCheckbox = row.querySelector('input[name="new_send_invitation"]');
+                                        if (sendInvitationCheckbox) {
+                                            var sendInvitationInput = document.createElement('input');
+                                            sendInvitationInput.type = 'hidden';
+                                            sendInvitationInput.name = 'new_collaborator_send_invitation[]';
+                                            sendInvitationInput.value = sendInvitationCheckbox.checked ? '1' : '0';
+                                            form.appendChild(sendInvitationInput);
+                                        }
+                                    }
+                                });
+                            });
+                        }
+                    });
+                })();
+                ]]>
+            </script>
+        </t>
+    </template>
+</odoo>

+ 59 - 0
views/helpdesk_request_type_views.xml

@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+    <record id="helpdesk_request_type_view_tree" model="ir.ui.view">
+        <field name="name">helpdesk.request.type.tree</field>
+        <field name="model">helpdesk.request.type</field>
+        <field name="arch" type="xml">
+            <list string="Request Types">
+                <field name="name"/>
+                <field name="code"/>
+                <field name="is_billable_default"/>
+                <field name="active" widget="boolean_toggle"/>
+            </list>
+        </field>
+    </record>
+
+    <record id="helpdesk_request_type_view_form" model="ir.ui.view">
+        <field name="name">helpdesk.request.type.form</field>
+        <field name="model">helpdesk.request.type</field>
+        <field name="arch" type="xml">
+            <form string="Request Type">
+                <sheet>
+                    <group>
+                        <group>
+                            <field name="name" placeholder="e.g., Incident, Improvement"/>
+                            <field name="code" placeholder="e.g., incident, improvement"/>
+                        </group>
+                        <group>
+                            <field name="is_billable_default"/>
+                            <field name="active"/>
+                        </group>
+                    </group>
+                </sheet>
+            </form>
+        </field>
+    </record>
+
+    <record id="helpdesk_request_type_action" model="ir.actions.act_window">
+        <field name="name">Request Types</field>
+        <field name="res_model">helpdesk.request.type</field>
+        <field name="view_mode">list,form</field>
+        <field name="help" type="html">
+            <p class="o_view_nocontent_smiling_face">
+                Create your first request type!
+            </p>
+            <p>
+                Request types help categorize tickets (e.g., Incident, Improvement).
+            </p>
+        </field>
+    </record>
+
+    <menuitem id="helpdesk_request_type_menu"
+              name="Request Types"
+              parent="helpdesk.helpdesk_menu_config"
+              action="helpdesk_request_type_action"
+              sequence="20"
+              groups="helpdesk.group_helpdesk_manager"/>
+
+</odoo>

+ 97 - 0
views/helpdesk_team_views.xml

@@ -0,0 +1,97 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+    <record id="helpdesk_team_view_form_inherit_helpdesk_extras" model="ir.ui.view">
+        <field name="name">helpdesk.team.form.inherit.helpdesk.extras</field>
+        <field name="inherit_id" ref="helpdesk.helpdesk_team_view_form"/>
+        <field name="model">helpdesk.team</field>
+        <field name="arch" type="xml">
+            <xpath expr="//button[@name='action_view_sla_policy']" position="after">
+                <button name="action_open_share_team_wizard"
+                        type="object"
+                        class="oe_stat_button"
+                        icon="fa-share-alt">
+                    <field name="collaborator_ids" widget="statinfo" string="Collaborators"/>
+                </button>
+            </xpath>
+            <xpath expr="//div[@id='channels']" position="before">
+                <h2>Workflow Template</h2>
+                <div class="row mt16 o_settings_container">
+                    <setting string="Workflow Template" help="Select a workflow template to quickly set up stages and SLA policies">
+                        <field name="workflow_template_id" options="{'no_create': True}"/>
+                        <button name="%(helpdesk_extras.helpdesk_workflow_template_apply_wizard_action)d"
+                                type="action"
+                                string="Apply Template"
+                                class="btn-primary mt-2"
+                                context="{'active_id': id, 'default_team_id': id}"/>
+                    </setting>
+                </div>
+                <h2>Template</h2>
+                <div class="row mt16 o_settings_container">
+                    <setting string="Ticket Template" help="Template to use for tickets in this team">
+                        <field name="template_id" options="{'no_create': True}"/>
+                    </setting>
+                </div>
+                <h2>Collaborators</h2>
+                <div class="row mt16 o_settings_container">
+                    <setting string="Team Collaborators" help="Partners with access to this helpdesk team">
+                        <button name="action_open_share_team_wizard"
+                                type="object"
+                                string="Share Team"
+                                class="btn-primary"/>
+                        <field name="collaborator_ids" nolabel="1" class="mt16">
+                            <list string="Collaborators" editable="bottom">
+                                <field name="partner_id" options="{'no_create': True, 'no_open': True}"/>
+                                <field name="partner_email"/>
+                                <field name="access_mode"/>
+                            </list>
+                        </field>
+                    </setting>
+                </div>
+            </xpath>
+        </field>
+    </record>
+
+    <record id="helpdesk_team_view_kanban_inherit_helpdesk_extras_new" model="ir.ui.view">
+        <field name="name">helpdesk.team.kanban.inherit.helpdesk.extras.new</field>
+        <field name="inherit_id" ref="helpdesk.helpdesk_team_view_kanban"/>
+        <field name="model">helpdesk.team</field>
+        <field name="priority">100</field>
+        <field name="arch" type="xml">
+            <xpath expr="//templates" position="before">
+                <field name="has_hours_stats"/>
+                <field name="hours_percentage_used"/>
+                <field name="hours_total_available"/>
+                <field name="hours_total_used"/>
+            </xpath>
+            
+            <xpath expr="//div[contains(@class, 'mt-auto')]" position="before">
+                <div t-if="record.has_hours_stats.raw_value" class="mt-3 mb-2">
+                    <div class="d-flex justify-content-between mb-1">
+                        <span class="text-muted small fw-bold">Hours Consumption</span>
+                        <span class="fw-bold small">
+                            <t t-esc="Math.round(record.hours_percentage_used.raw_value)"/>%
+                        </span>
+                    </div>
+                    <div class="progress" style="height: 6px;">
+                        <div role="progressbar" 
+                             t-attf-style="width: #{record.hours_percentage_used.raw_value}%;" 
+                             t-att-aria-valuenow="record.hours_percentage_used.raw_value" 
+                             aria-valuemin="0" 
+                             aria-valuemax="100"
+                             t-attf-class="progress-bar #{record.hours_percentage_used.raw_value > 100 ? 'bg-danger' : (record.hours_percentage_used.raw_value > 80 ? 'bg-warning' : 'bg-primary')}"/>
+                    </div>
+                    <div class="d-flex justify-content-between mt-1">
+                        <span class="text-muted small" style="font-size: 0.75rem;">
+                            Used: <field name="hours_total_used" widget="float_time"/>
+                        </span>
+                        <span class="text-muted small" style="font-size: 0.75rem;">
+                            Avail: <field name="hours_total_available" widget="float_time"/>
+                        </span>
+                    </div>
+                </div>
+            </xpath>
+        </field>
+    </record>
+
+</odoo>

+ 108 - 0
views/helpdesk_template_views.xml

@@ -0,0 +1,108 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+    <record id="helpdesk_template_view_list" model="ir.ui.view">
+        <field name="name">helpdesk.template.list</field>
+        <field name="model">helpdesk.template</field>
+        <field name="arch" type="xml">
+            <list string="Templates">
+                <field name="name"/>
+                <field name="active" widget="boolean_toggle"/>
+                <field name="field_ids" widget="many2many_tags"/>
+            </list>
+        </field>
+    </record>
+
+    <record id="helpdesk_template_view_form" model="ir.ui.view">
+        <field name="name">helpdesk.template.form</field>
+        <field name="model">helpdesk.template</field>
+        <field name="arch" type="xml">
+            <form string="Template">
+                <sheet>
+                    <group>
+                        <group>
+                            <field name="name" placeholder="e.g., Incident Template, Improvement Template"/>
+                            <field name="active"/>
+                        </group>
+                    </group>
+                    <notebook>
+                        <page string="Fields" name="fields">
+                            <field name="field_ids" nolabel="1" widget="helpdesk_template_field_ids">
+                                <list string="Template Fields" editable="bottom">
+                                    <field name="sequence" widget="handle"/>
+                                    <field name="field_id" 
+                                           domain="[('model', '=', 'helpdesk.ticket'), ('website_form_blacklisted', '=', False)]"
+                                           options="{'no_create': True, 'no_open': True}" 
+                                           required="1"/>
+                                    <field name="field_name" readonly="1"/>
+                                    <field name="field_type" readonly="1"/>
+                                    <field name="required"/>
+                                    <field name="model_required" invisible="1"/>
+                                    <field name="label_custom" placeholder="Custom label (optional)"/>
+                                    <field name="placeholder" placeholder="Placeholder text"/>
+                                    <field name="default_value" placeholder="Default value"/>
+                                    <field name="help_text" widget="html" placeholder="Help text (HTML)"/>
+                                    <field name="widget" 
+                                           column_invisible="1"
+                                           invisible="field_type not in ['selection', 'many2one']"/>
+                                    <field name="selection_options" 
+                                           widget="text" 
+                                           column_invisible="1"
+                                           placeholder='[["value1", "Label 1"], ["value2", "Label 2"]]'
+                                           invisible="field_type != 'selection' or field_id.relation"/>
+                                    <field name="visibility_dependency" 
+                                           domain="[('model', '=', 'helpdesk.ticket'), ('website_form_blacklisted', '=', False)]"
+                                           options="{'no_create': True, 'no_open': True}" 
+                                           placeholder="Select field for visibility condition"/>
+                                    <field name="visibility_comparator" 
+                                           placeholder="Select comparator"/>
+                                    <field name="visibility_condition" 
+                                           placeholder="Enter value to compare"
+                                           invisible="visibility_dependency_type in ['many2one', 'selection']"/>
+                                    <field name="visibility_condition_m2o_id" 
+                                           widget="dynamic_many2one"
+                                           placeholder="Select value"
+                                           invisible="visibility_dependency_type != 'many2one'"
+                                           options="{'no_create': True, 'no_open': True}"/>
+                                    <field name="visibility_condition_selection" 
+                                           placeholder="Select value"
+                                           invisible="visibility_dependency_type != 'selection'"/>
+                                    <field name="visibility_between" 
+                                           placeholder="End value for range (date/datetime)"
+                                           invisible="visibility_comparator not in ['between', '!between']"/>
+                                    <field name="visibility_dependency_type" invisible="1"/>
+                                    <field name="visibility_condition_m2o_model" invisible="1"/>
+                                </list>
+                            </field>
+                        </page>
+                        <page string="Description" name="description">
+                            <field name="description" placeholder="Describe the purpose of this template..."/>
+                        </page>
+                    </notebook>
+                </sheet>
+            </form>
+        </field>
+    </record>
+
+    <record id="helpdesk_template_action" model="ir.actions.act_window">
+        <field name="name">Templates</field>
+        <field name="res_model">helpdesk.template</field>
+        <field name="view_mode">list,form</field>
+        <field name="help" type="html">
+            <p class="o_view_nocontent_smiling_face">
+                Create your first template!
+            </p>
+            <p>
+                Templates allow you to configure which fields are shown in tickets and their visibility conditions.
+            </p>
+        </field>
+    </record>
+
+    <menuitem id="helpdesk_template_menu"
+              name="Templates"
+              parent="helpdesk.helpdesk_menu_config"
+              action="helpdesk_template_action"
+              sequence="25"
+              groups="helpdesk.group_helpdesk_manager"/>
+
+</odoo>

+ 65 - 0
views/helpdesk_ticket_views.xml

@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+    <!-- Extend helpdesk.ticket form view -->
+    <record id="helpdesk_ticket_view_form_inherit_helpdesk_extras" model="ir.ui.view">
+        <field name="name">helpdesk.ticket.form.inherit.helpdesk.extras</field>
+        <field name="inherit_id" ref="helpdesk.helpdesk_ticket_view_form"/>
+        <field name="model">helpdesk.ticket</field>
+        <field name="arch" type="xml">
+            <!-- Add Extras page with all custom fields -->
+            <xpath expr="//notebook" position="inside">
+                <page string="Extras" name="extras">
+                    <group>
+                        <group string="Request Information">
+                            <field name="request_type_id" required="1"/>
+                            <field name="affected_module_id"/>
+                            <field name="business_impact"/>
+                        </group>
+                        <group string="Details">
+                            <field name="reproduce_steps" 
+                                   invisible="request_type_code != 'incident'"
+                                   placeholder="Describe step by step how to reproduce the issue..."/>
+                            <field name="business_goal" 
+                                   invisible="request_type_code != 'improvement'"
+                                   placeholder="Describe the business objective for this improvement..."/>
+                        </group>
+                    </group>
+                    <group string="Approval &amp; Billing">
+                        <field name="client_authorization"/>
+                        <field name="estimated_hours"/>
+                        <field name="approval_status"/>
+                    </group>
+                    <group string="Attachments">
+                        <field name="attachment_ids" widget="many2many_binary"/>
+                    </group>
+                    <group string="Template Information" invisible="not has_template">
+                        <field name="team_id" invisible="1"/>
+                        <div class="alert alert-info" role="alert">
+                            <strong>Template Active:</strong> This ticket uses a template configured for the team.
+                            <br/>
+                            <small>Template fields are configured in Helpdesk > Configuration > Templates</small>
+                            <br/>
+                            <small>Template fields will be displayed in the web form when creating tickets.</small>
+                        </div>
+                    </group>
+                </page>
+            </xpath>
+        </field>
+    </record>
+
+    <!-- Extend helpdesk.ticket tree view -->
+    <record id="helpdesk_ticket_view_tree_inherit_helpdesk_extras" model="ir.ui.view">
+        <field name="name">helpdesk.ticket.tree.inherit.helpdesk.extras</field>
+        <field name="inherit_id" ref="helpdesk.helpdesk_tickets_view_tree"/>
+        <field name="model">helpdesk.ticket</field>
+        <field name="arch" type="xml">
+            <xpath expr="//field[@name='name']" position="after">
+                <field name="request_type_id" optional="show"/>
+                <field name="affected_module_id" optional="show"/>
+                <field name="approval_status" optional="show"/>
+            </xpath>
+        </field>
+    </record>
+
+</odoo>

+ 179 - 0
views/helpdesk_workflow_template_views.xml

@@ -0,0 +1,179 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <!-- List View -->
+    <record id="helpdesk_workflow_template_view_tree" model="ir.ui.view">
+        <field name="name">helpdesk.workflow.template.tree</field>
+        <field name="model">helpdesk.workflow.template</field>
+        <field name="arch" type="xml">
+            <list string="Workflow Templates" decoration-muted="active == False">
+                <field name="sequence" widget="handle" invisible="1"/>
+                <field name="name"/>
+                <field name="stage_count" string="Stages" sum="Total Stages"/>
+                <field name="sla_count" string="SLA Policies" sum="Total SLAs"/>
+                <field name="team_count" string="Teams Using"/>
+                <field name="active" widget="boolean_toggle"/>
+            </list>
+        </field>
+    </record>
+
+    <!-- Kanban View -->
+    <record id="helpdesk_workflow_template_view_kanban" model="ir.ui.view">
+        <field name="name">helpdesk.workflow.template.kanban</field>
+        <field name="model">helpdesk.workflow.template</field>
+        <field name="arch" type="xml">
+            <kanban default_order="name" class="o_kanban_small_column" sample="1">
+                <field name="name"/>
+                <field name="active"/>
+                <field name="stage_count"/>
+                <field name="sla_count"/>
+                <field name="team_count"/>
+                <templates>
+                    <t t-name="card">
+                        <div class="oe_kanban_card oe_kanban_global_click">
+                            <div class="o_kanban_card_header">
+                                <div class="o_kanban_card_header_title">
+                                    <div class="o_primary">
+                                        <field name="name" class="fw-bold fs-5"/>
+                                    </div>
+                                </div>
+                            </div>
+                            <div class="o_kanban_card_content">
+                                <div class="row mb-2">
+                                    <div class="col-6">
+                                        <div class="text-muted small">Stages</div>
+                                        <div class="fw-bold"><field name="stage_count"/></div>
+                                    </div>
+                                    <div class="col-6">
+                                        <div class="text-muted small">SLA Policies</div>
+                                        <div class="fw-bold"><field name="sla_count"/></div>
+                                    </div>
+                                </div>
+                                <div t-if="record.team_count.raw_value > 0" class="mt-2">
+                                    <span class="badge bg-info">
+                                        <i class="fa fa-users me-1"/>
+                                        <field name="team_count"/>
+                                    </span>
+                                </div>
+                            </div>
+                        </div>
+                    </t>
+                </templates>
+            </kanban>
+        </field>
+    </record>
+
+    <!-- Form View -->
+    <record id="helpdesk_workflow_template_view_form" model="ir.ui.view">
+        <field name="name">helpdesk.workflow.template.form</field>
+        <field name="model">helpdesk.workflow.template</field>
+        <field name="arch" type="xml">
+            <form string="Workflow Template">
+                <header>
+                    <button name="action_view_teams" string="Teams Using This Template" type="object" class="oe_stat_button" icon="fa-users" invisible="team_count == 0">
+                        <field name="team_count" widget="statinfo" string="Teams"/>
+                    </button>
+                    <field name="active" widget="boolean_toggle" options="{'invisible': [('id', '=', False)]}"/>
+                </header>
+                <sheet>
+                    <group>
+                        <group>
+                            <field name="name" placeholder="e.g., Basic Support, Premium Support"/>
+                            <field name="description" placeholder="Describe this workflow template..."/>
+                        </group>
+                    </group>
+                    
+                    <notebook>
+                        <page string="Stages" name="stages">
+                            <field name="stage_template_ids" nolabel="1">
+                                <list string="Stages" editable="bottom" default_order="sequence">
+                                    <field name="sequence" widget="handle"/>
+                                    <field name="name" required="1"/>
+                                    <field name="fold" widget="boolean_toggle"/>
+                                    <field name="description"/>
+                                </list>
+                            </field>
+                        </page>
+                        <page string="SLA Policies" name="slas">
+                            <field name="sla_template_ids" nolabel="1">
+                                <list string="SLA Policies" editable="bottom" default_order="sequence">
+                                    <field name="sequence" widget="handle"/>
+                                    <field name="name" required="1"/>
+                                    <field name="stage_template_id" required="1" domain="[('template_id', '=', parent.id)]"/>
+                                    <field name="time" widget="float_time" required="1"/>
+                                    <field name="priority" widget="priority"/>
+                                    <field name="exclude_stage_template_ids" widget="many2many_tags" 
+                                           domain="[('template_id', '=', parent.id), ('id', '!=', stage_template_id)]"
+                                           string="Excluded Stages"/>
+                                    <field name="tag_ids" widget="many2many_tags" optional="hide"/>
+                                </list>
+                                <form string="SLA Policy">
+                                    <sheet>
+                                        <group>
+                                            <group>
+                                                <field name="name"/>
+                                                <field name="sequence"/>
+                                                <field name="stage_template_id" domain="[('template_id', '=', template_id)]"/>
+                                                <field name="time" widget="float_time"/>
+                                                <field name="priority" widget="priority"/>
+                                            </group>
+                                            <group>
+                                                <field name="description"/>
+                                                <field name="exclude_stage_template_ids" widget="many2many_tags" 
+                                                       domain="[('template_id', '=', template_id), ('id', '!=', stage_template_id)]"
+                                                       help="Stages where time spent will NOT count towards the SLA deadline. Useful for 'On Hold' or 'Waiting for Customer' stages."/>
+                                                <field name="tag_ids" widget="many2many_tags"/>
+                                            </group>
+                                        </group>
+                                    </sheet>
+                                </form>
+                            </field>
+                        </page>
+                    </notebook>
+                </sheet>
+            </form>
+        </field>
+    </record>
+
+    <!-- Search View -->
+    <record id="helpdesk_workflow_template_view_search" model="ir.ui.view">
+        <field name="name">helpdesk.workflow.template.search</field>
+        <field name="model">helpdesk.workflow.template</field>
+        <field name="arch" type="xml">
+            <search string="Workflow Templates">
+                <field name="name" string="Template Name"/>
+                <filter string="Active" name="active" domain="[('active', '=', True)]"/>
+                <filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
+                <separator/>
+                <filter string="Has Stages" name="has_stages" domain="[('stage_template_ids', '!=', False)]"/>
+                <filter string="Has SLAs" name="has_slas" domain="[('sla_template_ids', '!=', False)]"/>
+                <group expand="0" string="Group By">
+                    <filter string="Active" name="group_active" context="{'group_by': 'active'}"/>
+                </group>
+            </search>
+        </field>
+    </record>
+
+    <!-- Action -->
+    <record id="helpdesk_workflow_template_action" model="ir.actions.act_window">
+        <field name="name">Workflow Templates</field>
+        <field name="res_model">helpdesk.workflow.template</field>
+        <field name="view_mode">kanban,list,form</field>
+        <field name="context">{'search_default_active': 1}</field>
+        <field name="help" type="html">
+            <p class="o_view_nocontent_smiling_face">
+                Create your first workflow template!
+            </p>
+            <p>
+                Workflow templates allow you to quickly set up stages and SLA policies for helpdesk teams.
+                Create a template with predefined stages and SLAs, then apply it to any team with one click.
+            </p>
+        </field>
+    </record>
+
+    <!-- Menu -->
+    <menuitem id="menu_helpdesk_workflow_template"
+              name="Workflow Templates"
+              parent="helpdesk.helpdesk_menu_config"
+              action="helpdesk_workflow_template_action"
+              sequence="30"/>
+</odoo>

+ 98 - 0
views/snippets/s_helpdesk_hours.xml

@@ -0,0 +1,98 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+<template name="Helpdesk Hours Available" id="s_helpdesk_hours">
+    <section class="s_helpdesk_hours pt80 pb80" style="background-color: #F0F2F5;">
+        <div class="container">
+            <div class="row justify-content-center">
+                <div class="col-12 col-sm-10 col-md-6 col-lg-5">
+                    <!-- Card Principal -->
+                    <div class="bg-white rounded shadow p-4">
+                        <!-- Título -->
+                        <h2 class="fs-6 fw-semibold mb-0" style="color: #1F2937;">Resumen de Horas de Soporte</h2>
+                        
+                        <!-- Normal content (when hours available) -->
+                        <div class="s_helpdesk_hours_normal_content">
+                            <!-- Horas Disponibles -->
+                            <div class="mt-4">
+                                <p class="small mb-2" style="color: #4B5563;">
+                                    Horas Prepago Restantes: <span class="fw-semibold s_helpdesk_hours_prepaid" style="color: #111827;">15h</span>
+                                </p>
+                                <p class="small mb-0" style="color: #4B5563;">
+                                    Crédito Disponible: <span class="fw-semibold s_helpdesk_hours_credit" style="color: #111827;">10h</span>
+                                </p>
+                            </div>
+                            
+                            <!-- Barra de Progreso -->
+                            <div class="mt-4">
+                                <div class="d-flex justify-content-between align-items-baseline mb-1">
+                                    <p class="small fw-semibold mb-0 s_helpdesk_hours_gradient_text">
+                                        <span class="s_helpdesk_hours_percentage">50</span>% Usado del Total
+                                    </p>
+                                </div>
+                                <div class="w-100 rounded-pill overflow-hidden" style="background-color: #E5E7EB; height: 8px;">
+                                    <div class="s_helpdesk_hours_progress_bar rounded-pill" style="width: 50%; height: 8px;"></div>
+                                </div>
+                                <p class="mt-2 mb-0" style="font-size: 0.75rem; color: #6B7280;">
+                                    Total disponible: <span class="s_helpdesk_hours_total">25h</span> (<span class="s_helpdesk_hours_prepaid_label">15h</span> Prepago + <span class="s_helpdesk_hours_credit_label">10h</span> Crédito)
+                                </p>
+                            </div>
+                            
+                            <!-- Link de Acción -->
+                            <div class="mt-4 text-center">
+                                <a href="/my/tickets" class="small fw-semibold s_helpdesk_hours_link text-decoration-none">Ver detalle completo</a>
+                            </div>
+                        </div>
+                        
+                        <!-- Loading state -->
+                        <div class="s_helpdesk_hours_loading text-center py-4" style="display: none;">
+                            <div class="spinner-border spinner-border-sm" style="color: #FF6B00;" role="status">
+                                <span class="visually-hidden">Cargando...</span>
+                            </div>
+                            <p class="small mt-2 mb-0" style="color: #6B7280;">Cargando información...</p>
+                        </div>
+                        
+                        <!-- Error state -->
+                        <div class="s_helpdesk_hours_error alert alert-warning mb-0" style="display: none;">
+                            <i class="fa fa-exclamation-triangle me-2"></i>
+                            <span class="s_helpdesk_hours_error_message">No se pudo cargar la información de horas disponibles.</span>
+                        </div>
+                        
+                        <!-- No hours available message -->
+                        <div class="s_helpdesk_hours_no_hours mt-4" style="display: none;">
+                            <p class="mb-3" style="color: #1F2937; font-size: 1rem;">
+                                No tienes horas disponibles para soporte.
+                            </p>
+                            <p class="mb-2" style="color: #4B5563; font-size: 0.875rem;">
+                                Puedes adquirir un paquete en: 
+                                <a href="#" class="s_helpdesk_hours_packages_link text-decoration-none fw-semibold" style="color: #FF6B00;">Ver paquetes disponibles</a>
+                            </p>
+                            <p class="mb-0" style="color: #4B5563; font-size: 0.875rem;">
+                                Si tienes alguna duda o comentario contáctanos mediante 
+                                <a href="#" class="s_helpdesk_hours_whatsapp_link text-decoration-none" style="color: #FF6B00;">WhatsApp</a> 
+                                o 
+                                <a href="#" class="s_helpdesk_hours_email_link text-decoration-none" style="color: #FF6B00;">correo electrónico</a>.
+                            </p>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </section>
+</template>
+
+<asset id="helpdesk_extras.s_helpdesk_hours_js" name="Helpdesk Hours JS">
+    <bundle>web.assets_frontend</bundle>
+    <path>helpdesk_extras/static/src/snippets/s_helpdesk_hours/000.js</path>
+</asset>
+
+<asset id="helpdesk_extras.s_helpdesk_hours_scss" name="Helpdesk Hours SCSS">
+    <bundle>web.assets_frontend</bundle>
+    <path>helpdesk_extras/static/src/snippets/s_helpdesk_hours/000.scss</path>
+</asset>
+
+<template id="s_helpdesk_hours_options" inherit_id="website.snippet_options">
+    <xpath expr="//t[@t-set='so_content_addition_selector']" position="inside" t-translation="off">, .s_helpdesk_hours</xpath>
+</template>
+
+</odoo>

+ 17 - 0
views/snippets/snippets.xml

@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+<!-- Add Helpdesk Hours snippet to snippets menu in Categories -->
+<template id="snippets" inherit_id="website.snippets">
+    <xpath expr="//snippets[@id='snippet_structure']//t[@t-snippet='website.s_embed_code']" position="before">
+        <t t-snippet="helpdesk_extras.s_helpdesk_hours"
+           string="Helpdesk Hours Available"
+           group="content"
+           t-thumbnail="/website/static/src/img/snippets_thumbs/s_progress_bar.svg"
+           t-image-preview="/website/static/src/img/snippets_thumbs/s_progress_bar.svg">
+            <keywords>helpdesk, hours, available, tickets, progress, bar, prepaid, credit, portal</keywords>
+        </t>
+    </xpath>
+</template>
+
+</odoo>

+ 17 - 0
views/website_helpdesk_form.xml

@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <!-- Extend helpdesk ticket form to add blocking logic when no hours available -->
+    <template id="helpdesk_ticket_form_block" name="Helpdesk Ticket Form Block" inherit_id="website_helpdesk.ticket_submit_form">
+        <xpath expr="//form[@id='helpdesk_ticket_form']" position="attributes">
+            <attribute name="class" add="s_helpdesk_form_blockable"/>
+        </xpath>
+        
+        <!-- Add blocking message div -->
+        <xpath expr="//div[@id='helpdesk_section']" position="inside">
+            <div id="helpdesk_form_block_message" class="alert alert-warning d-none" role="alert" style="margin-bottom: 20px;">
+                <strong>Información:</strong> <span id="helpdesk_form_block_message_text"></span>
+            </div>
+        </xpath>
+    </template>
+    
+</odoo>

+ 5 - 0
wizard/__init__.py

@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+
+from . import helpdesk_team_share_wizard
+from . import helpdesk_team_share_collaborator_wizard
+from . import helpdesk_workflow_template_apply_wizard

+ 113 - 0
wizard/helpdesk_team_share_collaborator_wizard.py

@@ -0,0 +1,113 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import re
+
+from odoo import api, fields, models, _
+from odoo.exceptions import ValidationError
+
+
+class HelpdeskTeamShareCollaboratorWizard(models.TransientModel):
+    _name = 'helpdesk.team.share.collaborator.wizard'
+    _description = 'Helpdesk Team Sharing Collaborator Wizard'
+
+    parent_wizard_id = fields.Many2one(
+        'helpdesk.team.share.wizard',
+        export_string_translation=False,
+    )
+    partner_id = fields.Many2one(
+        'res.partner',
+        string='Collaborator',
+        required=True,
+    )
+    access_mode = fields.Selection(
+        [
+            ('admin', 'Administrator'),
+            ('user_all', 'User - All Tickets'),
+            ('user_own', 'User - Own Tickets'),
+        ],
+        string='Access Mode',
+        required=True,
+        default='user_own',
+        help="Administrator: can view all tickets and manage other users.\n"
+             "User - All Tickets: can view all tickets and create own tickets.\n"
+             "User - Own Tickets: can only create and view own tickets."
+    )
+    send_invitation = fields.Boolean(
+        string='Send Invitation',
+        compute='_compute_send_invitation',
+        store=True,
+        readonly=False,
+        default=True,
+    )
+
+    @api.depends('partner_id', 'access_mode')
+    def _compute_send_invitation(self):
+        team = self.parent_wizard_id.resource_ref
+        for collaborator in self:
+            if (
+                collaborator.partner_id not in team.message_partner_ids
+                or (collaborator.access_mode != 'user_own' and collaborator.partner_id not in team.collaborator_ids.partner_id)
+            ):
+                collaborator.send_invitation = True
+            else:
+                collaborator.send_invitation = False
+
+    @api.constrains('partner_id')
+    def _check_partner_share(self):
+        """Validate that partner is a portal/external partner"""
+        for collaborator in self:
+            if collaborator.partner_id and not collaborator.partner_id.partner_share:
+                raise ValidationError(_(
+                    "Partner '%s' is an internal user and cannot be added as a collaborator. "
+                    "Only external partners (portal users) can be collaborators."
+                ) % collaborator.partner_id.display_name)
+    
+    @api.constrains('partner_id')
+    def _check_partner_commercial_partner(self):
+        """Validate that partner belongs to the same commercial_partner_id as admin"""
+        for collaborator in self:
+            if not collaborator.partner_id or not collaborator.parent_wizard_id:
+                continue
+            
+            # Get admin's commercial_partner_id from context
+            admin_commercial_partner_id = collaborator._context.get('default_admin_commercial_partner_id')
+            if not admin_commercial_partner_id:
+                # Try to get from parent wizard context
+                admin_commercial_partner_id = collaborator.parent_wizard_id._context.get('default_admin_commercial_partner_id')
+            
+            if admin_commercial_partner_id:
+                partner_commercial_id = collaborator.partner_id.commercial_partner_id.id
+                if partner_commercial_id != admin_commercial_partner_id:
+                    raise ValidationError(_(
+                        "Partner '%s' does not belong to your contact network. "
+                        "You can only add contacts from your company network."
+                    ) % collaborator.partner_id.display_name)
+
+    @api.constrains('partner_id')
+    def _check_partner_email(self):
+        """Validate that partner has a valid email if invitation will be sent"""
+        for collaborator in self:
+            if collaborator.partner_id and collaborator.send_invitation:
+                email = collaborator.partner_id.email
+                if not email:
+                    raise ValidationError(_(
+                        "Partner '%s' does not have an email address. "
+                        "An email is required to send an invitation."
+                    ) % collaborator.partner_id.display_name)
+                # Basic email format validation
+                email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
+                if not re.match(email_pattern, email):
+                    raise ValidationError(_(
+                        "Partner '%s' has an invalid email address format: %s"
+                    ) % (collaborator.partner_id.display_name, email))
+
+    @api.constrains('access_mode')
+    def _check_access_mode(self):
+        """Validate access mode value"""
+        valid_modes = ['admin', 'user_all', 'user_own']
+        for collaborator in self:
+            if collaborator.access_mode and collaborator.access_mode not in valid_modes:
+                raise ValidationError(_(
+                    "Invalid access mode '%s'. Valid modes are: %s"
+                ) % (collaborator.access_mode, ', '.join(valid_modes)))

+ 275 - 0
wizard/helpdesk_team_share_wizard.py

@@ -0,0 +1,275 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import operator
+
+from odoo import Command, api, fields, models, _
+from odoo.exceptions import ValidationError
+
+
+class HelpdeskTeamShareWizard(models.TransientModel):
+    _name = 'helpdesk.team.share.wizard'
+    _inherit = 'portal.share'
+    _description = 'Helpdesk Team Sharing'
+
+    @api.model
+    def default_get(self, fields):
+        # The helpdesk team share action could be called in `helpdesk.team.collaborator`
+        # and so we have to check the active_model and active_id to use
+        # the right team.
+        active_model = self._context.get('active_model', '')
+        active_id = self._context.get('active_id', False)
+        if active_model == 'helpdesk.team.collaborator':
+            active_model = 'helpdesk.team'
+            active_id = self._context.get('default_team_id', False)
+        
+        # Call parent - portal.share will handle _get_share_url
+        # If the model doesn't have portal.mixin, we'll handle it in the wizard
+        result = {}
+        result['res_model'] = active_model or self._context.get('active_model', False)
+        result['res_id'] = active_id or self._context.get('active_id', False)
+        
+        # Try to get share_link from portal.share, but handle if _get_share_url doesn't exist
+        if result['res_model'] and result['res_id']:
+            record = self.env[result['res_model']].browse(result['res_id'])
+            base_url = record.get_base_url()
+            # Check if record has _get_share_url method
+            if hasattr(record, '_get_share_url'):
+                try:
+                    share_url = record._get_share_url(redirect=True)
+                    result['share_link'] = base_url + share_url
+                except:
+                    result['share_link'] = f"{base_url}/helpdesk/team/{result['res_id']}"
+            else:
+                # Fallback: generate a simple share URL
+                result['share_link'] = f"{base_url}/helpdesk/team/{result['res_id']}"
+        else:
+            result['share_link'] = ''
+        
+        # Get other default values from portal.share if available
+        try:
+            portal_defaults = super(HelpdeskTeamShareWizard, self.with_context(active_model=active_model, active_id=active_id)).default_get(fields)
+            # Merge portal defaults but keep our share_link
+            portal_defaults['share_link'] = result.get('share_link', portal_defaults.get('share_link', ''))
+            result.update(portal_defaults)
+        except:
+            pass
+        
+        # Continue with the rest of the logic
+        if result.get('res_model') and result.get('res_id'):
+            # Use sudo() to access team data to avoid security rule issues
+            team = self.env[result['res_model']].sudo().browse(result['res_id'])
+            
+            # Check if we're editing a specific collaborator
+            specific_collaborator_id = self._context.get('default_collaborator_id')
+            specific_collaborator = None
+            if specific_collaborator_id:
+                try:
+                    specific_collaborator = team.collaborator_ids.filtered(lambda c: c.id == specific_collaborator_id)
+                    if not specific_collaborator:
+                        specific_collaborator = None
+                    else:
+                        specific_collaborator = specific_collaborator[0]
+                except (ValueError, TypeError):
+                    specific_collaborator = None
+            
+            collaborator_vals_list = []
+            collaborator_ids = []
+            
+            # If editing a specific collaborator, only include that one
+            if specific_collaborator:
+                collaborator_ids.append(specific_collaborator.partner_id.id)
+                collaborator_vals_list.append({
+                    'partner_id': specific_collaborator.partner_id.id,
+                    'partner_name': specific_collaborator.partner_id.display_name,
+                    'access_mode': specific_collaborator.access_mode,
+                })
+            else:
+                # Include all collaborators
+                for collaborator in team.collaborator_ids:
+                    collaborator_ids.append(collaborator.partner_id.id)
+                    collaborator_vals_list.append({
+                        'partner_id': collaborator.partner_id.id,
+                        'partner_name': collaborator.partner_id.display_name,
+                        'access_mode': collaborator.access_mode,
+                    })
+                # Also include followers if not editing a specific collaborator
+                # Use sudo() to access message_partner_ids
+                for follower in team.message_partner_ids:
+                    if follower.partner_share and follower.id not in collaborator_ids:
+                        collaborator_vals_list.append({
+                            'partner_id': follower.id,
+                            'partner_name': follower.display_name,
+                            'access_mode': 'user_own',
+                        })
+            
+            if collaborator_vals_list:
+                # Only sort if not editing a specific collaborator
+                if not specific_collaborator:
+                    collaborator_vals_list.sort(key=operator.itemgetter('partner_name'))
+                result['collaborator_ids'] = [
+                    Command.create({
+                        'partner_id': collaborator['partner_id'],
+                        'access_mode': collaborator['access_mode'],
+                        'send_invitation': False
+                    })
+                    for collaborator in collaborator_vals_list
+                ]
+        return result
+
+    @api.model
+    def _selection_target_model(self):
+        team_model = self.env['ir.model']._get('helpdesk.team')
+        return [(team_model.model, team_model.name)]
+
+    share_link = fields.Char(
+        "Public Link",
+        help="Anyone with this link can access the helpdesk team."
+    )
+    collaborator_ids = fields.One2many(
+        'helpdesk.team.share.collaborator.wizard',
+        'parent_wizard_id',
+        string='Collaborators'
+    )
+    existing_partner_ids = fields.Many2many(
+        'res.partner',
+        compute='_compute_existing_partner_ids',
+        export_string_translation=False
+    )
+
+    @api.depends('res_model', 'res_id')
+    def _compute_resource_ref(self):
+        for wizard in self:
+            if wizard.res_model and wizard.res_model == 'helpdesk.team':
+                wizard.resource_ref = '%s,%s' % (wizard.res_model, wizard.res_id or 0)
+            else:
+                wizard.resource_ref = None
+
+    @api.depends('collaborator_ids')
+    def _compute_existing_partner_ids(self):
+        for wizard in self:
+            wizard.existing_partner_ids = wizard.collaborator_ids.partner_id
+
+    @api.model_create_multi
+    def create(self, vals_list):
+        wizards = super().create(vals_list)
+        for wizard in wizards:
+            if not wizard.resource_ref:
+                continue
+            # Use sudo() to access team data to avoid security rule issues
+            team = wizard.resource_ref.sudo()
+            if not team:
+                continue
+            collaborator_ids_vals_list = []
+            team_collaborator_ids_to_remove = [
+                c.id
+                for c in team.collaborator_ids
+                if c.partner_id not in wizard.collaborator_ids.partner_id
+            ]
+            # Use sudo() to access message_partner_ids
+            team_followers = team.message_partner_ids
+            team_followers_to_add = []
+            team_followers_to_remove = [
+                partner.id
+                for partner in team_followers
+                if partner not in wizard.collaborator_ids.partner_id and partner.partner_share
+            ]
+            team_collaborator_per_partner_id = {c.partner_id.id: c for c in team.collaborator_ids}
+            collaborator_ids_to_add = []
+            collaborator_ids_vals_list = []
+            for collaborator in wizard.collaborator_ids:
+                partner_id = collaborator.partner_id.id
+                team_collaborator = team_collaborator_per_partner_id.get(partner_id, self.env['helpdesk.team.collaborator'])
+                if collaborator.access_mode in ("admin", "user_all", "user_own"):
+                    if not team_collaborator:
+                        collaborator_ids_to_add.append((partner_id, collaborator.access_mode))
+                    elif team_collaborator.access_mode != collaborator.access_mode:
+                        collaborator_ids_vals_list.append(
+                            Command.update(
+                                team_collaborator.id,
+                                {'access_mode': collaborator.access_mode},
+                            )
+                        )
+                elif team_collaborator:
+                    team_collaborator_ids_to_remove.append(team_collaborator.id)
+                if partner_id not in team_followers.ids:
+                    team_followers_to_add.append(partner_id)
+            if collaborator_ids_to_add:
+                partners_to_add = self.env['res.partner'].browse([pid for pid, _ in collaborator_ids_to_add])
+                # Validate partners before adding
+                invalid_partners = partners_to_add.filtered(lambda p: not p.partner_share)
+                if invalid_partners:
+                    raise ValidationError(_(
+                        "The following partners are internal users and cannot be added as collaborators: %s"
+                    ) % ', '.join(invalid_partners.mapped('display_name')))
+                
+                partners = team._get_new_collaborators(partners_to_add)
+                collaborator_per_partner = {pid: mode for pid, mode in collaborator_ids_to_add}
+                collaborator_ids_vals_list.extend(
+                    Command.create({
+                        'partner_id': partner_id,
+                        'access_mode': collaborator_per_partner[partner_id],
+                    }) for partner_id in partners.ids
+                )
+            if team_collaborator_ids_to_remove:
+                collaborator_ids_vals_list.extend(
+                    Command.delete(collaborator_id) for collaborator_id in team_collaborator_ids_to_remove
+                )
+            team_vals = {}
+            if collaborator_ids_vals_list:
+                team_vals['collaborator_ids'] = collaborator_ids_vals_list
+            if team_vals:
+                team.write(team_vals)
+            if team_followers_to_add:
+                team.message_subscribe(partner_ids=team_followers_to_add)
+            if team_followers_to_remove:
+                team.message_unsubscribe(team_followers_to_remove)
+        return wizards
+
+    def action_share_record(self):
+        # Confirmation dialog is only opened if new portal user(s) need to be created in a 'on invitation' website
+        self.ensure_one()
+        if not self.collaborator_ids:
+            return
+        on_invite = self.env['res.users']._get_signup_invitation_scope() == 'b2b'
+        new_portal_user = self.collaborator_ids.filtered(lambda c: c.send_invitation and not c.partner_id.user_ids) and on_invite
+        if not new_portal_user:
+            return self.action_send_mail()
+        return {
+            'name': _('Confirmation'),
+            'type': 'ir.actions.act_window',
+            'view_mode': 'form',
+            'res_model': 'helpdesk.team.share.wizard',
+            'res_id': self.id,
+            'target': 'new',
+            'context': self.env.context,
+        }
+
+    def action_send_mail(self):
+        result = {
+            'type': 'ir.actions.client',
+            'tag': 'display_notification',
+            'params': {
+                'type': 'success',
+                'message': _("Helpdesk team shared with your collaborators."),
+                'next': {'type': 'ir.actions.act_window_close'},
+            }
+        }
+        partner_ids_to_notify = []
+        for collaborator in self.collaborator_ids:
+            if collaborator.send_invitation:
+                partner_ids_to_notify.append(collaborator.partner_id.id)
+        if partner_ids_to_notify:
+            partners = self.env['res.partner'].browse(partner_ids_to_notify)
+            # Validate that partners have email addresses
+            partners_without_email = partners.filtered(lambda p: not p.email)
+            if partners_without_email:
+                raise ValidationError(_(
+                    "The following partners do not have email addresses and cannot receive invitations: %s"
+                ) % ', '.join(partners_without_email.mapped('display_name')))
+            
+            portal_partners = partners.filtered('user_ids')
+            # send mail to users
+            self._send_public_link(portal_partners)
+            self._send_signup_link(partners=partners.with_context({'signup_valid': True}) - portal_partners)
+        return result

+ 47 - 0
wizard/helpdesk_team_share_wizard_views.xml

@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+    <record id="helpdesk_team_share_wizard_view_form" model="ir.ui.view">
+        <field name="name">helpdesk.team.share.wizard.view.form</field>
+        <field name="model">helpdesk.team.share.wizard</field>
+        <field name="arch" type="xml">
+            <form string="Share Helpdesk Team">
+                <field name="res_model" invisible="1"/>
+                <field name="res_id" invisible="1"/>
+                <group>
+                    <field name="share_link" widget="CopyClipboardChar"/>
+                </group>
+                <field name="collaborator_ids" nolabel="1">
+                    <list string="Collaborators" editable="bottom">
+                        <field name="partner_id"
+                               options="{'no_create': True, 'no_open': True}"
+                               domain="[('id', 'not in', parent.existing_partner_ids), ('partner_share', '=', True)]"
+                               context="{'show_email': True}"
+                        />
+                        <field name="access_mode"/>
+                        <field name="send_invitation"/>
+                    </list>
+                </field>
+                <p class="text-muted">Choose one of the following access modes for your collaborators:</p>
+                <ul class="text-muted">
+                    <li><b>Administrator:</b> can view all tickets and manage other users.</li>
+                    <li><b>User - All Tickets:</b> can view all tickets and create own tickets.</li>
+                    <li><b>User - Own Tickets:</b> can only create and view own tickets.</li>
+                </ul>
+                <footer>
+                    <button string="Share Team" name="action_share_record" type="object" class="btn-primary" data-hotkey="q"/>
+                    <button string="Discard" class="btn-secondary" special="cancel" data-hotkey="x" />
+                </footer>
+            </form>
+        </field>
+    </record>
+
+    <record id="helpdesk_team_share_wizard_action" model="ir.actions.act_window">
+        <field name="name">Share Helpdesk Team</field>
+        <field name="res_model">helpdesk.team.share.wizard</field>
+        <field name="view_mode">form</field>
+        <field name="target">new</field>
+        <field name="context">{'dialog_size': 'medium'}</field>
+    </record>
+
+</odoo>

+ 112 - 0
wizard/helpdesk_workflow_template_apply_wizard.py

@@ -0,0 +1,112 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models, _
+
+
+class HelpdeskWorkflowTemplateApplyWizard(models.TransientModel):
+    _name = 'helpdesk.workflow.template.apply.wizard'
+    _description = 'Apply Workflow Template Wizard'
+
+    team_id = fields.Many2one(
+        'helpdesk.team',
+        string='Team',
+        required=True,
+        readonly=True
+    )
+    workflow_template_id = fields.Many2one(
+        'helpdesk.workflow.template',
+        string='Workflow Template',
+        required=True,
+        domain="[('active', '=', True)]"
+    )
+    replace_existing = fields.Boolean(
+        string='Replace Existing Stages and SLAs',
+        default=False,
+        help='If checked, existing stages and SLAs will be removed before applying the template'
+    )
+    stage_count = fields.Integer(
+        string='Stages to Create',
+        compute='_compute_counts',
+        store=False
+    )
+    sla_count = fields.Integer(
+        string='SLA Policies to Create',
+        compute='_compute_counts',
+        store=False
+    )
+    existing_stage_count = fields.Integer(
+        string='Existing Stages',
+        compute='_compute_counts',
+        store=False
+    )
+    existing_sla_count = fields.Integer(
+        string='Existing SLA Policies',
+        compute='_compute_counts',
+        store=False
+    )
+
+    @api.depends('workflow_template_id', 'team_id')
+    def _compute_counts(self):
+        for wizard in self:
+            if wizard.workflow_template_id:
+                wizard.stage_count = len(wizard.workflow_template_id.stage_template_ids)
+                wizard.sla_count = len(wizard.workflow_template_id.sla_template_ids)
+            else:
+                wizard.stage_count = 0
+                wizard.sla_count = 0
+            
+            if wizard.team_id:
+                wizard.existing_stage_count = len(wizard.team_id.stage_ids)
+                wizard.existing_sla_count = len(
+                    self.env['helpdesk.sla'].search([('team_id', '=', wizard.team_id.id)])
+                )
+            else:
+                wizard.existing_stage_count = 0
+                wizard.existing_sla_count = 0
+
+    @api.model
+    def default_get(self, fields_list):
+        """Set default team from context"""
+        defaults = super().default_get(fields_list)
+        if 'team_id' in fields_list and 'active_id' in self.env.context:
+            defaults['team_id'] = self.env.context['active_id']
+        if 'workflow_template_id' in fields_list and defaults.get('team_id'):
+            team = self.env['helpdesk.team'].browse(defaults['team_id'])
+            if team.workflow_template_id:
+                defaults['workflow_template_id'] = team.workflow_template_id.id
+        return defaults
+
+    def action_apply_template(self):
+        """Apply the workflow template to the team"""
+        self.ensure_one()
+        
+        if not self.workflow_template_id:
+            raise ValueError(_("Please select a workflow template"))
+        
+        if not self.team_id:
+            raise ValueError(_("Team is required"))
+        
+        team = self.team_id
+        
+        # Replace existing if requested
+        if self.replace_existing:
+            # Remove existing SLAs
+            existing_slas = self.env['helpdesk.sla'].search([('team_id', '=', team.id)])
+            existing_slas.unlink()
+            
+            # Remove existing stages (only if they belong only to this team)
+            for stage in team.stage_ids:
+                if len(stage.team_ids) == 1 and stage.team_ids.id == team.id:
+                    stage.unlink()
+                else:
+                    # Just remove from this team
+                    team.stage_ids = [(3, stage.id)]
+        
+        # Set template on team
+        team.workflow_template_id = self.workflow_template_id
+        
+        # Apply template
+        result = team.apply_workflow_template()
+        
+        return result
+

+ 62 - 0
wizard/helpdesk_workflow_template_apply_wizard_views.xml

@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <record id="helpdesk_workflow_template_apply_wizard_view_form" model="ir.ui.view">
+        <field name="name">helpdesk.workflow.template.apply.wizard.form</field>
+        <field name="model">helpdesk.workflow.template.apply.wizard</field>
+        <field name="arch" type="xml">
+            <form string="Apply Workflow Template">
+                <sheet>
+                    <group>
+                        <group>
+                            <field name="team_id" readonly="1"/>
+                            <field name="workflow_template_id" options="{'no_create': True}"/>
+                        </group>
+                        <group>
+                            <field name="replace_existing"/>
+                        </group>
+                    </group>
+                    
+                    <group string="Summary" class="mt-4">
+                        <group string="Template Content">
+                            <label for="stage_count" string="Stages:"/>
+                            <div class="text-muted">
+                                <field name="stage_count" readonly="1"/>
+                            </div>
+                            <label for="sla_count" string="SLA Policies:"/>
+                            <div class="text-muted">
+                                <field name="sla_count" readonly="1"/>
+                            </div>
+                        </group>
+                        <group string="Current Team">
+                            <label for="existing_stage_count" string="Existing Stages:"/>
+                            <div class="text-muted">
+                                <field name="existing_stage_count" readonly="1"/>
+                            </div>
+                            <label for="existing_sla_count" string="Existing SLA Policies:"/>
+                            <div class="text-muted">
+                                <field name="existing_sla_count" readonly="1"/>
+                            </div>
+                        </group>
+                    </group>
+                    
+                    <div class="alert alert-warning" role="alert" invisible="not replace_existing">
+                        <strong>Warning:</strong> This will remove all existing stages and SLA policies for this team.
+                    </div>
+                </sheet>
+                <footer>
+                    <button name="action_apply_template" string="Apply Template" type="object" class="btn-primary"/>
+                    <button string="Cancel" class="btn-secondary" special="cancel"/>
+                </footer>
+            </form>
+        </field>
+    </record>
+
+    <record id="helpdesk_workflow_template_apply_wizard_action" model="ir.actions.act_window">
+        <field name="name">Apply Workflow Template</field>
+        <field name="res_model">helpdesk.workflow.template.apply.wizard</field>
+        <field name="view_mode">form</field>
+        <field name="target">new</field>
+        <field name="binding_model_id" ref="helpdesk.model_helpdesk_team"/>
+        <field name="binding_view_types">form</field>
+    </record>
+</odoo>