[I18N] other: automatic update
Auto-generated commit based on local changes.
This commit is contained in:
parent
f586a60704
commit
8c54caad84
|
|
@ -1,4 +1,3 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
#from . import controllers
|
from . import models
|
||||||
from . import models
|
|
||||||
|
|
@ -1,34 +1,77 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
{
|
{
|
||||||
'name': "System Board",
|
'name': "System Dashboard",
|
||||||
|
'summary': "Configurable dashboard for employee self-service and manager approvals",
|
||||||
'summary': """
|
|
||||||
This module allows you to configure dashboard for different states or stages for specific model.""",
|
|
||||||
|
|
||||||
'description': """
|
'description': """
|
||||||
This module allows you to configure dashboard for different users
|
System Dashboard Classic
|
||||||
add state and its corresponding group which it will be visible ,
|
========================
|
||||||
depends on logged in user they can see records if it is on one state that they has access on
|
|
||||||
there also a self service screen which employees can see their request in any model.""",
|
|
||||||
|
|
||||||
|
A comprehensive dashboard module that provides:
|
||||||
|
|
||||||
|
* **Self-Service Portal**: Employees can view and manage their own requests
|
||||||
|
(leaves, expenses, timesheets, etc.)
|
||||||
|
|
||||||
|
* **Manager Dashboard**: Managers can see pending approvals with state/stage
|
||||||
|
filtering based on user groups
|
||||||
|
|
||||||
|
* **Configurable Services**: Add any Odoo model as a dashboard service card
|
||||||
|
with custom actions and views
|
||||||
|
|
||||||
|
* **Attendance Integration**: Built-in check-in/check-out functionality
|
||||||
|
(requires 'attendances' module)
|
||||||
|
|
||||||
|
* **Theme Customization**: Configurable colors and visibility settings
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
-------------
|
||||||
|
- Dynamic state/stage loading from any model
|
||||||
|
- Group-based visibility for approval cards
|
||||||
|
- Self-service mode for employee-facing services
|
||||||
|
- Real-time attendance with confetti celebration
|
||||||
|
- Responsive design with RTL support
|
||||||
|
""",
|
||||||
|
|
||||||
'author': "Expert Co. Ltd., Sudan Team",
|
'author': "Expert Co. Ltd., Sudan Team",
|
||||||
# 'website': "http://www.yourcompany.com",
|
'category': 'Human Resources/Dashboard',
|
||||||
|
'version': '14.0.1.0.0',
|
||||||
'category': 'Uncategorized',
|
'license': 'LGPL-3',
|
||||||
'version': '0.1',
|
'application': True,
|
||||||
'application':True,
|
|
||||||
|
# Required Dependencies
|
||||||
# any module necessary for this one to work correctly
|
'depends': [
|
||||||
'depends': ['base'],
|
#'hr_base', MUST BE FIRST! exp_hr_payroll uses its groups but doesn't depend on it
|
||||||
|
'base', # Core Odoo
|
||||||
# always loaded
|
'resource', # Work calendar for attendance hours
|
||||||
|
'web', # Dashboard assets & QWeb client actions
|
||||||
|
'employee_requests',
|
||||||
|
'hr_holidays_public',
|
||||||
|
'hr_timesheet',
|
||||||
|
'exp_official_mission',
|
||||||
|
'odex_mobile',
|
||||||
|
],
|
||||||
|
|
||||||
|
# Optional Dependencies (soft - checked at runtime):
|
||||||
|
# - attendances: For check-in/check-out functionality (attendance.attendance model)
|
||||||
|
# - odoo_dynamic_workflow: For dynamic workflow states integration
|
||||||
|
# - hr_holidays: For leave management integration
|
||||||
|
# - hr_payroll: For payslip integration
|
||||||
|
# - hr_timesheet: For timesheet integration (account.analytic.line)
|
||||||
|
|
||||||
|
# Data files
|
||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'security/security.xml',
|
'security/security.xml',
|
||||||
'views/system_dashboard.xml',
|
'views/system_dashboard.xml',
|
||||||
'views/config.xml',
|
'views/config.xml',
|
||||||
|
'views/dashboard_settings.xml',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
# QWeb templates
|
||||||
'qweb': [
|
'qweb': [
|
||||||
"static/src/xml/*.xml",
|
'static/src/xml/*.xml',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
# Assets are loaded via views/system_dashboard.xml
|
||||||
|
'installable': True,
|
||||||
|
'auto_install': False,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
#from . import controllers
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
# # -*- 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.tools.safe_eval import safe_eval
|
|
||||||
|
|
||||||
|
|
||||||
# class BaseDashboard(http.Controller):
|
|
||||||
|
|
||||||
# @http.route(['/system/dashboard'], type='http', auth="user", website=True)
|
|
||||||
# def system_dashboard(self, **kw):
|
|
||||||
# values = []
|
|
||||||
# base = request.env['base.dashbord'].sudo().search([])
|
|
||||||
|
|
||||||
# for models in base:
|
|
||||||
# for model in models:
|
|
||||||
# for line in model.board_ids:
|
|
||||||
# if request.uid in line.group_id.users.ids:
|
|
||||||
# state_click = safe_eval(line.state)
|
|
||||||
# state_follow = safe_eval(line.state)
|
|
||||||
|
|
||||||
# #get only state from list of list ex:[['state', '=', 'confrim']] return draft,so it become record to confrim
|
|
||||||
# state = state_click[-1][-1]
|
|
||||||
# #get state to follow ,which is not in curent state so replace = with !=
|
|
||||||
# state_follow [-1][-2] = '!='
|
|
||||||
|
|
||||||
# state_to_click = request.env[line.model_name].sudo().search_count(state_click)
|
|
||||||
# state_to_follow = request.env[line.model_name].sudo().search_count(state_follow)
|
|
||||||
# values.append({
|
|
||||||
# 'type':'user', # to differentiate between user has record to click on or employee has requests to see
|
|
||||||
# 'name':line.model_id.name, # name in card ex: Leave
|
|
||||||
# 'user':request.env.user, # user data on user card,use user.name to get his name
|
|
||||||
# 'model': line.model_name,# to be passed to js as field res_model
|
|
||||||
# 'count_state_click':state_to_click, #count of state to click on card
|
|
||||||
# 'count_state_follow':state_to_follow,#count of state to follow on card
|
|
||||||
# 'state': 'records to ' + '' + state, # title of card ex:records to confirm
|
|
||||||
# 'domain_to_click': state_click, # to be passed to js for his records on field called domain on action window
|
|
||||||
# 'domain_to_follow':state_follow , # to be passed to js for records to follow up on field called domain on action window
|
|
||||||
# })
|
|
||||||
|
|
||||||
# #if user has no record to click but needs to create new records or see his requests
|
|
||||||
# else:
|
|
||||||
# values.append({
|
|
||||||
# 'type':'service',
|
|
||||||
# 'name':line.model_id.name,
|
|
||||||
# 'user':request.env.user,
|
|
||||||
# 'model': line.model_name,
|
|
||||||
|
|
||||||
# })
|
|
||||||
|
|
||||||
|
|
||||||
# print("values========================",values)
|
|
||||||
# return request.render("system_dashboard.portal_template", {'values':values})
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<odoo>
|
|
||||||
<!-- <data noupdate="1">
|
|
||||||
|
|
||||||
|
|
||||||
<record id="sale_draft" model="base.dashbord">
|
|
||||||
<field name="model_id" ref="sale.model_sale_order"/>
|
|
||||||
<field name="model_name">sale.order</field>
|
|
||||||
|
|
||||||
</record>
|
|
||||||
<record id="sale_draft_line" model="base.dashbord.line">
|
|
||||||
<field name="board_id" ref="system_dashboard.sale_draft"/>
|
|
||||||
<field name="state">draft</field>
|
|
||||||
<field name="group_id" ref="base.group_user"/>
|
|
||||||
<field name="model_name">sale.order</field>
|
|
||||||
|
|
||||||
</record>
|
|
||||||
|
|
||||||
|
|
||||||
<record id="hr_holidays_confirm" model="base.dashbord">
|
|
||||||
<field name="model_id" ref="hr_holidays.model_hr_holidays"/>
|
|
||||||
<field name="model_name">hr.holidays</field>
|
|
||||||
|
|
||||||
</record>
|
|
||||||
<record id="hr_holidays_confirm_line" model="base.dashbord.line">
|
|
||||||
<field name="board_id" ref="system_dashboard.hr_holidays_confirm"/>
|
|
||||||
<field name="state">confirm</field>
|
|
||||||
<field name="group_id" ref="hr_holidays.group_hr_holidays_manager"/>
|
|
||||||
<field name="model_name">hr.holidays</field>
|
|
||||||
|
|
||||||
</record>
|
|
||||||
|
|
||||||
|
|
||||||
<record id="hr_holidays_validate" model="base.dashbord">
|
|
||||||
<field name="model_id" ref="hr_holidays.model_hr_holidays"/>
|
|
||||||
<field name="model_name">hr.holidays</field>
|
|
||||||
|
|
||||||
</record>
|
|
||||||
<record id="hr_holidays_validate_line" model="base.dashbord.line">
|
|
||||||
<field name="board_id" ref="system_dashboard.hr_holidays_validate"/>
|
|
||||||
<field name="state">validate</field>
|
|
||||||
<field name="group_id" ref="hr_holidays.group_hr_holidays_manager"/>
|
|
||||||
<field name="model_name">hr.holidays</field>
|
|
||||||
|
|
||||||
</record>
|
|
||||||
<record id="hr_holidays_draft_line" model="base.dashbord.line">
|
|
||||||
<field name="board_id" ref="system_dashboard.hr_holidays_validate"/>
|
|
||||||
<field name="state">draft</field>
|
|
||||||
<field name="group_id" ref="hr_holidays.group_hr_holidays_manager"/>
|
|
||||||
<field name="model_name">hr.holidays</field>
|
|
||||||
|
|
||||||
</record> -->
|
|
||||||
|
|
||||||
</data>
|
|
||||||
</odoo>
|
|
||||||
|
|
@ -1,30 +1,5 @@
|
||||||
<odoo>
|
<odoo>
|
||||||
<data>
|
<data>
|
||||||
<!-- -->
|
<!-- Demo data placeholder - no demo records currently defined -->
|
||||||
<!-- <record id="object0" model="cash_flow.cash_flow"> -->
|
|
||||||
<!-- <field name="name">Object 0</field> -->
|
|
||||||
<!-- <field name="value">0</field> -->
|
|
||||||
<!-- </record> -->
|
|
||||||
<!-- -->
|
|
||||||
<!-- <record id="object1" model="cash_flow.cash_flow"> -->
|
|
||||||
<!-- <field name="name">Object 1</field> -->
|
|
||||||
<!-- <field name="value">10</field> -->
|
|
||||||
<!-- </record> -->
|
|
||||||
<!-- -->
|
|
||||||
<!-- <record id="object2" model="cash_flow.cash_flow"> -->
|
|
||||||
<!-- <field name="name">Object 2</field> -->
|
|
||||||
<!-- <field name="value">20</field> -->
|
|
||||||
<!-- </record> -->
|
|
||||||
<!-- -->
|
|
||||||
<!-- <record id="object3" model="cash_flow.cash_flow"> -->
|
|
||||||
<!-- <field name="name">Object 3</field> -->
|
|
||||||
<!-- <field name="value">30</field> -->
|
|
||||||
<!-- </record> -->
|
|
||||||
<!-- -->
|
|
||||||
<!-- <record id="object4" model="cash_flow.cash_flow"> -->
|
|
||||||
<!-- <field name="name">Object 4</field> -->
|
|
||||||
<!-- <field name="value">40</field> -->
|
|
||||||
<!-- </record> -->
|
|
||||||
<!-- -->
|
|
||||||
</data>
|
</data>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|
@ -14,7 +14,8 @@ msgstr ""
|
||||||
"MIME-Version: 1.0\n"
|
"MIME-Version: 1.0\n"
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
|
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
|
||||||
|
"&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
|
||||||
"X-Generator: Poedit 3.3.2\n"
|
"X-Generator: Poedit 3.3.2\n"
|
||||||
|
|
||||||
#. module: system_dashboard_classic
|
#. module: system_dashboard_classic
|
||||||
|
|
@ -61,8 +62,8 @@ msgstr "الحضور والانصراف"
|
||||||
#: model:ir.actions.act_window,name:system_dashboard_classic.base_dashboard_action
|
#: model:ir.actions.act_window,name:system_dashboard_classic.base_dashboard_action
|
||||||
#: model:ir.model,name:system_dashboard_classic.model_base_dashbord
|
#: model:ir.model,name:system_dashboard_classic.model_base_dashbord
|
||||||
#: model:ir.ui.menu,name:system_dashboard_classic.base_dashboard
|
#: model:ir.ui.menu,name:system_dashboard_classic.base_dashboard
|
||||||
msgid "Base Dashboard"
|
msgid "Dashboard Builder"
|
||||||
msgstr "لوحة المعلومات الاساسية"
|
msgstr "تصميم الداشبورد"
|
||||||
|
|
||||||
#. module: system_dashboard_classic
|
#. module: system_dashboard_classic
|
||||||
#: model:ir.model.fields,field_description:system_dashboard_classic.field_base_dashbord_line__board_id
|
#: model:ir.model.fields,field_description:system_dashboard_classic.field_base_dashbord_line__board_id
|
||||||
|
|
@ -81,8 +82,8 @@ msgstr "اسم النموذج"
|
||||||
|
|
||||||
#. module: system_dashboard_classic
|
#. module: system_dashboard_classic
|
||||||
#: model:ir.ui.menu,name:system_dashboard_classic.base_dashboard_root
|
#: model:ir.ui.menu,name:system_dashboard_classic.base_dashboard_root
|
||||||
msgid "Configrutions"
|
msgid "Configuration"
|
||||||
msgstr "الاعدادات"
|
msgstr "الإعدادات"
|
||||||
|
|
||||||
#. module: system_dashboard_classic
|
#. module: system_dashboard_classic
|
||||||
#: model_terms:ir.actions.act_window,help:system_dashboard_classic.approval_screen
|
#: model_terms:ir.actions.act_window,help:system_dashboard_classic.approval_screen
|
||||||
|
|
@ -227,11 +228,11 @@ msgstr "قسيمة راتب"
|
||||||
#: code:addons/system_dashboard_classic/static/src/xml/system_dashboard.xml:0
|
#: code:addons/system_dashboard_classic/static/src/xml/system_dashboard.xml:0
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Salary Slips"
|
msgid "Salary Slips"
|
||||||
msgstr "قسيمة الراتب"
|
msgstr "قسائم الراتب"
|
||||||
|
|
||||||
#. module: system_dashboard_classic
|
#. module: system_dashboard_classic
|
||||||
#: model:ir.ui.menu,name:system_dashboard_classic.menu_self_service_service
|
#: model:ir.ui.menu,name:system_dashboard_classic.menu_self_service_service
|
||||||
msgid "Self Service Screen"
|
msgid "Self Service"
|
||||||
msgstr "الخدمة الذاتية"
|
msgstr "الخدمة الذاتية"
|
||||||
|
|
||||||
#. module: system_dashboard_classic
|
#. module: system_dashboard_classic
|
||||||
|
|
@ -246,6 +247,7 @@ msgstr "بدون تأثير مالي؟"
|
||||||
|
|
||||||
#. module: system_dashboard_classic
|
#. module: system_dashboard_classic
|
||||||
#: model:ir.model.fields,field_description:system_dashboard_classic.field_base_dashbord__sequence
|
#: model:ir.model.fields,field_description:system_dashboard_classic.field_base_dashbord__sequence
|
||||||
|
#: model:ir.model.fields,field_description:system_dashboard_classic.field_base_dashbord_line__sequence
|
||||||
msgid "sequence"
|
msgid "sequence"
|
||||||
msgstr "التسلسل"
|
msgstr "التسلسل"
|
||||||
|
|
||||||
|
|
@ -277,13 +279,9 @@ msgstr "الحالة"
|
||||||
#: model:ir.actions.act_window,name:system_dashboard_classic.approval_screen
|
#: model:ir.actions.act_window,name:system_dashboard_classic.approval_screen
|
||||||
#: model:ir.actions.act_window,name:system_dashboard_classic.self_service_dashboard
|
#: model:ir.actions.act_window,name:system_dashboard_classic.self_service_dashboard
|
||||||
#: model:ir.model,name:system_dashboard_classic.model_system_dashboard_classic_dashboard
|
#: model:ir.model,name:system_dashboard_classic.model_system_dashboard_classic_dashboard
|
||||||
msgid "System Dashboard"
|
|
||||||
msgstr "لوحة المعلومات"
|
|
||||||
|
|
||||||
#. module: system_dashboard_classic
|
|
||||||
#: model:ir.ui.menu,name:system_dashboard_classic.system_dashboard_classic_menu
|
#: model:ir.ui.menu,name:system_dashboard_classic.system_dashboard_classic_menu
|
||||||
msgid "System Dashboard"
|
msgid "Dashboard"
|
||||||
msgstr "لوحة الخدمة الذاتية"
|
msgstr "لوحة المعلومات"
|
||||||
|
|
||||||
#. module: system_dashboard_classic
|
#. module: system_dashboard_classic
|
||||||
#: code:addons/system_dashboard_classic/models/config.py:0
|
#: code:addons/system_dashboard_classic/models/config.py:0
|
||||||
|
|
@ -403,11 +401,6 @@ msgstr ""
|
||||||
msgid "records to "
|
msgid "records to "
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. module: system_dashboard_classic
|
|
||||||
#: model:ir.model.fields,field_description:system_dashboard_classic.field_base_dashbord_line__sequence
|
|
||||||
msgid "sequence"
|
|
||||||
msgstr "التسلسل"
|
|
||||||
|
|
||||||
#. module: system_dashboard_classic
|
#. module: system_dashboard_classic
|
||||||
#: model:ir.model,name:system_dashboard_classic.model_stage_stage
|
#: model:ir.model,name:system_dashboard_classic.model_stage_stage
|
||||||
msgid "stage.stage"
|
msgid "stage.stage"
|
||||||
|
|
@ -437,4 +430,147 @@ msgstr "حذف المراحل"
|
||||||
#. module: system_dashboard_classic
|
#. module: system_dashboard_classic
|
||||||
#: model:ir.model.fields,field_description:system_dashboard_classic.field_base_dashbord__search_field
|
#: model:ir.model.fields,field_description:system_dashboard_classic.field_base_dashbord__search_field
|
||||||
msgid "Search field"
|
msgid "Search field"
|
||||||
msgstr "حقل البحث"
|
msgstr "حقل البحث"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#. openerp-web
|
||||||
|
#: code:addons/system_dashboard_classic/static/src/xml/self_service_dashboard.xml:0
|
||||||
|
#: code:addons/system_dashboard_classic/static/src/xml/system_dashboard.xml:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Monthly Attendance"
|
||||||
|
msgstr "الحضور الشهري"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#. openerp-web
|
||||||
|
#: code:addons/system_dashboard_classic/static/src/xml/self_service_dashboard.xml:0
|
||||||
|
#: code:addons/system_dashboard_classic/static/src/xml/system_dashboard.xml:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Timesheet"
|
||||||
|
msgstr "الجداول الزمنية"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model:ir.model.fields,field_description:system_dashboard_classic.field_res_config_settings__dashboard_primary_color
|
||||||
|
msgid "Primary Color"
|
||||||
|
msgstr "اللون الأساسي"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model:ir.model.fields,field_description:system_dashboard_classic.field_res_config_settings__dashboard_secondary_color
|
||||||
|
msgid "Secondary Color"
|
||||||
|
msgstr "اللون الثانوي"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model:ir.model.fields,field_description:system_dashboard_classic.field_res_config_settings__dashboard_success_color
|
||||||
|
msgid "Success Color"
|
||||||
|
msgstr "لون النجاح/الاتصال"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model:ir.model.fields,field_description:system_dashboard_classic.field_res_config_settings__dashboard_warning_color
|
||||||
|
msgid "Warning Color"
|
||||||
|
msgstr "لون التنبيه/المتبقي"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#. openerp-web
|
||||||
|
#: code:addons/system_dashboard_classic/static/src/js/system_dashboard.js:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Remaining"
|
||||||
|
msgstr "المتبقي"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#. openerp-web
|
||||||
|
#: code:addons/system_dashboard_classic/static/src/js/system_dashboard.js:0
|
||||||
|
#, python-format
|
||||||
|
msgid "Total"
|
||||||
|
msgstr "الإجمالي"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model:ir.model.fields,field_description:system_dashboard_classic.field_res_config_settings__dashboard_show_attendance_section
|
||||||
|
msgid "Show Attendance Section"
|
||||||
|
msgstr "إظهار قسم الحضور والانصراف"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model:ir.actions.act_window,name:system_dashboard_classic.action_dashboard_configuration
|
||||||
|
#: model:ir.ui.menu,name:system_dashboard_classic.menu_dashboard_configuration
|
||||||
|
msgid "General Settings"
|
||||||
|
msgstr "الإعدادات العامة"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
|
||||||
|
msgid "Model Configuration"
|
||||||
|
msgstr "إعدادات النموذج"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
|
||||||
|
msgid "Display Options"
|
||||||
|
msgstr "خيارات العرض"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
|
||||||
|
msgid "Employee Filter Configuration"
|
||||||
|
msgstr "إعدادات فلترة الموظف"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
|
||||||
|
msgid "Advanced View Settings"
|
||||||
|
msgstr "إعدادات العرض المتقدمة"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
|
||||||
|
msgid "State/Stage Configuration"
|
||||||
|
msgstr "إعدادات الحالات/المراحل"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
|
||||||
|
msgid "Load Model States"
|
||||||
|
msgstr "تحميل حالات النموذج"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
|
||||||
|
msgid "Refresh States"
|
||||||
|
msgstr "تحديث الحالات"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
|
||||||
|
msgid "Remove All States"
|
||||||
|
msgstr "حذف جميع الحالات"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
|
||||||
|
msgid "Service Name"
|
||||||
|
msgstr "اسم الخدمة"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
|
||||||
|
msgid "Lower numbers appear first"
|
||||||
|
msgstr "الأرقام الأصغر تظهر أولاً"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
|
||||||
|
msgid "Enable for employee self-service cards"
|
||||||
|
msgstr "تفعيل لبطاقات الخدمة الذاتية للموظف"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
|
||||||
|
msgid "Mark if this service has no financial impact"
|
||||||
|
msgstr "حدد إذا كانت هذه الخدمة بدون تأثير مالي"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
|
||||||
|
msgid "The action to open when clicking this card"
|
||||||
|
msgstr "الإجراء الذي يفتح عند النقر على هذه البطاقة"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
|
||||||
|
msgid "Create your first dashboard service"
|
||||||
|
msgstr "أنشئ أول خدمة في الداشبورد"
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
|
||||||
|
msgid ""
|
||||||
|
"Configure which Odoo models appear as service cards on the employee "
|
||||||
|
"dashboard."
|
||||||
|
msgstr "حدد أي نماذج Odoo تظهر كبطاقات خدمات في لوحة تحكم الموظف."
|
||||||
|
|
||||||
|
#. module: system_dashboard_classic
|
||||||
|
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_tree
|
||||||
|
msgid "Dashboard Services"
|
||||||
|
msgstr "خدمات الداشبورد"
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
from . import config
|
from . import config
|
||||||
from . import models
|
from . import models
|
||||||
|
from . import res_users
|
||||||
|
|
@ -3,7 +3,7 @@ from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
class BaseDashboard(models.Model):
|
class BaseDashboard(models.Model):
|
||||||
_name = 'base.dashbord'
|
_name = 'base.dashbord'
|
||||||
_description = 'Base Dashboard'
|
_description = 'Dashboard Builder'
|
||||||
_order = 'sequence'
|
_order = 'sequence'
|
||||||
|
|
||||||
sequence = fields.Integer()
|
sequence = fields.Integer()
|
||||||
|
|
@ -29,6 +29,39 @@ class BaseDashboard(models.Model):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
icon_type = fields.Selection(
|
||||||
|
[('image', 'Image'), ('icon', 'Icon Library')],
|
||||||
|
string='Icon Type',
|
||||||
|
default='image',
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
icon_name = fields.Char(
|
||||||
|
string='Icon Class',
|
||||||
|
help="FontAwesome class (e.g., 'fa-plane', 'fa-users'). See https://fontawesome.com/v4/icons/"
|
||||||
|
)
|
||||||
|
|
||||||
|
icon_preview_html = fields.Html(compute='_compute_icon_preview', string="Icon/Image Preview")
|
||||||
|
|
||||||
|
@api.depends('icon_type', 'icon_name', 'card_image')
|
||||||
|
def _compute_icon_preview(self):
|
||||||
|
for record in self:
|
||||||
|
if record.icon_type == 'icon' and record.icon_name:
|
||||||
|
# Dynamic Icon Preview - Class based sizing
|
||||||
|
record.icon_preview_html = f'<div class="dashboard-icon-preview-box icon-mode"><i class="fa {record.icon_name}"></i></div>'
|
||||||
|
elif record.icon_type == 'image' and record.card_image:
|
||||||
|
# Actual Image Preview
|
||||||
|
# CRITICAL: Use bin_size=False to ensure we get DATA not SIZE TEXT (e.g. "25 KB")
|
||||||
|
try:
|
||||||
|
img_rec = record.with_context(bin_size=False)
|
||||||
|
image_data = img_rec.card_image.decode('utf-8') if isinstance(img_rec.card_image, bytes) else img_rec.card_image
|
||||||
|
record.icon_preview_html = f'<div class="dashboard-icon-preview-box image-mode"><img src="data:image/png;base64,{image_data}"/></div>'
|
||||||
|
except Exception:
|
||||||
|
record.icon_preview_html = '<div class="dashboard-icon-preview-box error-mode"><i class="fa fa-exclamation-triangle"></i></div>'
|
||||||
|
else:
|
||||||
|
# Default Placeholder
|
||||||
|
record.icon_preview_html = '<div class="dashboard-icon-preview-box default-mode"><i class="fa fa-th-large"></i></div>'
|
||||||
|
|
||||||
card_image = fields.Binary(
|
card_image = fields.Binary(
|
||||||
string='Card Image',
|
string='Card Image',
|
||||||
)
|
)
|
||||||
|
|
@ -431,3 +464,208 @@ class StageStage(models.Model):
|
||||||
form_view_id = fields.Many2one('ir.ui.view', string='Form View',readonly=True)
|
form_view_id = fields.Many2one('ir.ui.view', string='Form View',readonly=True)
|
||||||
list_view_id = fields.Many2one('ir.ui.view', string='List View',readonly=True)
|
list_view_id = fields.Many2one('ir.ui.view', string='List View',readonly=True)
|
||||||
action_id = fields.Many2one('ir.actions.act_window', string='Action',readonly=True)
|
action_id = fields.Many2one('ir.actions.act_window', string='Action',readonly=True)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardConfigSettings(models.TransientModel):
|
||||||
|
"""Dashboard Theme Settings - Configurable colors from UI"""
|
||||||
|
_inherit = 'res.config.settings'
|
||||||
|
_description = 'Dashboard Configuration Settings'
|
||||||
|
|
||||||
|
# Dashboard Theme Colors
|
||||||
|
dashboard_primary_color = fields.Char(
|
||||||
|
string='Primary Color',
|
||||||
|
config_parameter='system_dashboard_classic.primary_color',
|
||||||
|
default='#0891b2',
|
||||||
|
help='Main accent color for the dashboard (e.g., #0891b2)'
|
||||||
|
)
|
||||||
|
|
||||||
|
dashboard_secondary_color = fields.Char(
|
||||||
|
string='Secondary Color',
|
||||||
|
config_parameter='system_dashboard_classic.secondary_color',
|
||||||
|
default='#1e293b',
|
||||||
|
help='Secondary color for headers and dark elements (e.g., #1e293b)'
|
||||||
|
)
|
||||||
|
|
||||||
|
dashboard_success_color = fields.Char(
|
||||||
|
string='Success Color',
|
||||||
|
config_parameter='system_dashboard_classic.success_color',
|
||||||
|
default='#10b981',
|
||||||
|
help='Success/Online status color (e.g., #10b981)'
|
||||||
|
)
|
||||||
|
|
||||||
|
dashboard_warning_color = fields.Char(
|
||||||
|
string='Warning Color',
|
||||||
|
config_parameter='system_dashboard_classic.warning_color',
|
||||||
|
default='#f59e0b',
|
||||||
|
help='Warning/Remaining balance color (e.g., #f59e0b)'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dashboard Statistics Visibility Settings
|
||||||
|
# NOTE: We DON'T use config_parameter here because Odoo's default behavior
|
||||||
|
# converts any non-empty string to True via bool(). Instead, we manually
|
||||||
|
# handle storage in get_values/set_values.
|
||||||
|
dashboard_show_annual_leave = fields.Boolean(
|
||||||
|
string='Show Annual Leave',
|
||||||
|
default=True,
|
||||||
|
help='Show/Hide Annual Leave statistics card in dashboard header'
|
||||||
|
)
|
||||||
|
|
||||||
|
dashboard_show_salary_slips = fields.Boolean(
|
||||||
|
string='Show Salary Slips',
|
||||||
|
default=True,
|
||||||
|
help='Show/Hide Salary Slips statistics card in dashboard header'
|
||||||
|
)
|
||||||
|
|
||||||
|
dashboard_show_timesheet = fields.Boolean(
|
||||||
|
string='Show Weekly Timesheet',
|
||||||
|
default=True,
|
||||||
|
help='Show/Hide Weekly Timesheet statistics card in dashboard header'
|
||||||
|
)
|
||||||
|
|
||||||
|
dashboard_show_attendance_hours = fields.Boolean(
|
||||||
|
string='Show Attendance Hours',
|
||||||
|
default=True,
|
||||||
|
help='Show/Hide Attendance Hours statistics card in dashboard header'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attendance Check-in/out Section
|
||||||
|
dashboard_show_attendance_section = fields.Boolean(
|
||||||
|
string='Show Attendance Section',
|
||||||
|
default=True,
|
||||||
|
help='Show/Hide the Attendance Check-in/out section in dashboard header. When hidden, statistics cards will expand to fill the space.'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enable/Disable Check-in/out Button
|
||||||
|
dashboard_enable_attendance_button = fields.Boolean(
|
||||||
|
string='Enable Check-in/out Button',
|
||||||
|
default=False,
|
||||||
|
help='Enable/Disable the Check-in/Check-out button functionality. When disabled, the button will appear but will not function.'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Chart Type Selection Fields
|
||||||
|
dashboard_annual_leave_chart_type = fields.Selection([
|
||||||
|
('donut', 'Donut'),
|
||||||
|
('pie', 'Pie'),
|
||||||
|
], default='donut', string='Annual Leave Chart',
|
||||||
|
config_parameter='system_dashboard_classic.annual_leave_chart_type')
|
||||||
|
|
||||||
|
dashboard_salary_slips_chart_type = fields.Selection([
|
||||||
|
('donut', 'Donut'),
|
||||||
|
('pie', 'Pie'),
|
||||||
|
], default='donut', string='Salary Slips Chart',
|
||||||
|
config_parameter='system_dashboard_classic.salary_slips_chart_type')
|
||||||
|
|
||||||
|
dashboard_timesheet_chart_type = fields.Selection([
|
||||||
|
('donut', 'Donut'),
|
||||||
|
('pie', 'Pie'),
|
||||||
|
], default='donut', string='Weekly Timesheet Chart',
|
||||||
|
config_parameter='system_dashboard_classic.timesheet_chart_type')
|
||||||
|
|
||||||
|
dashboard_attendance_hours_chart_type = fields.Selection([
|
||||||
|
('donut', 'Donut'),
|
||||||
|
('pie', 'Pie'),
|
||||||
|
], default='donut', string='Attendance Hours Chart',
|
||||||
|
config_parameter='system_dashboard_classic.attendance_hours_chart_type')
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_dashboard_colors(self):
|
||||||
|
"""API method to get dashboard colors for JavaScript"""
|
||||||
|
ICPSudo = self.env['ir.config_parameter'].sudo()
|
||||||
|
return {
|
||||||
|
'primary': ICPSudo.get_param('system_dashboard_classic.primary_color', '#0891b2'),
|
||||||
|
'secondary': ICPSudo.get_param('system_dashboard_classic.secondary_color', '#1e293b'),
|
||||||
|
'success': ICPSudo.get_param('system_dashboard_classic.success_color', '#10b981'),
|
||||||
|
'warning': ICPSudo.get_param('system_dashboard_classic.warning_color', '#f59e0b'),
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_values(self):
|
||||||
|
"""Override to properly load Boolean visibility settings from ir.config_parameter
|
||||||
|
|
||||||
|
We store these as explicit strings ('True'/'False') and convert them back to
|
||||||
|
Python booleans here.
|
||||||
|
"""
|
||||||
|
res = super(DashboardConfigSettings, self).get_values()
|
||||||
|
|
||||||
|
ICPSudo = self.env['ir.config_parameter'].sudo()
|
||||||
|
|
||||||
|
# Helper to safely get boolean value from string storage
|
||||||
|
def get_bool_param(key, default='True'):
|
||||||
|
value = ICPSudo.get_param(key, default)
|
||||||
|
return value == 'True'
|
||||||
|
|
||||||
|
res.update(
|
||||||
|
dashboard_show_annual_leave=get_bool_param('system_dashboard_classic.show_annual_leave'),
|
||||||
|
dashboard_show_salary_slips=get_bool_param('system_dashboard_classic.show_salary_slips'),
|
||||||
|
dashboard_show_timesheet=get_bool_param('system_dashboard_classic.show_timesheet'),
|
||||||
|
dashboard_show_attendance_hours=get_bool_param('system_dashboard_classic.show_attendance_hours'),
|
||||||
|
dashboard_show_attendance_section=get_bool_param('system_dashboard_classic.show_attendance_section'),
|
||||||
|
dashboard_enable_attendance_button=get_bool_param('system_dashboard_classic.enable_attendance_button', 'False'),
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
|
||||||
|
def set_values(self):
|
||||||
|
"""Override to explicitly store Boolean visibility settings as strings
|
||||||
|
|
||||||
|
We store as 'True' or 'False' strings to prevent Odoo from deleting
|
||||||
|
the parameter when value is False.
|
||||||
|
"""
|
||||||
|
res = super(DashboardConfigSettings, self).set_values()
|
||||||
|
|
||||||
|
# Explicitly store visibility settings as string values
|
||||||
|
ICPSudo = self.env['ir.config_parameter'].sudo()
|
||||||
|
ICPSudo.set_param('system_dashboard_classic.show_annual_leave',
|
||||||
|
'True' if self.dashboard_show_annual_leave else 'False')
|
||||||
|
ICPSudo.set_param('system_dashboard_classic.show_salary_slips',
|
||||||
|
'True' if self.dashboard_show_salary_slips else 'False')
|
||||||
|
ICPSudo.set_param('system_dashboard_classic.show_timesheet',
|
||||||
|
'True' if self.dashboard_show_timesheet else 'False')
|
||||||
|
ICPSudo.set_param('system_dashboard_classic.show_attendance_hours',
|
||||||
|
'True' if self.dashboard_show_attendance_hours else 'False')
|
||||||
|
ICPSudo.set_param('system_dashboard_classic.show_attendance_section',
|
||||||
|
'True' if self.dashboard_show_attendance_section else 'False')
|
||||||
|
ICPSudo.set_param('system_dashboard_classic.enable_attendance_button',
|
||||||
|
'True' if self.dashboard_enable_attendance_button else 'False')
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_stats_visibility(self):
|
||||||
|
"""API method to get statistics visibility settings for JavaScript
|
||||||
|
|
||||||
|
Returns dict with boolean values for each visibility setting.
|
||||||
|
Default is True (show) if parameter doesn't exist.
|
||||||
|
"""
|
||||||
|
ICPSudo = self.env['ir.config_parameter'].sudo()
|
||||||
|
|
||||||
|
# Helper to safely get boolean value
|
||||||
|
def get_bool_param(key, default='True'):
|
||||||
|
value = ICPSudo.get_param(key, default)
|
||||||
|
# Handle various possible values
|
||||||
|
if value in ('True', 'true', True, '1', 1):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
return {
|
||||||
|
'show_annual_leave': get_bool_param('system_dashboard_classic.show_annual_leave'),
|
||||||
|
'show_salary_slips': get_bool_param('system_dashboard_classic.show_salary_slips'),
|
||||||
|
'show_timesheet': get_bool_param('system_dashboard_classic.show_timesheet'),
|
||||||
|
'show_attendance_hours': get_bool_param('system_dashboard_classic.show_attendance_hours'),
|
||||||
|
'show_attendance_section': get_bool_param('system_dashboard_classic.show_attendance_section'),
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_chart_types(self):
|
||||||
|
"""API method to get chart type settings for JavaScript
|
||||||
|
|
||||||
|
Returns dict with chart type for each card.
|
||||||
|
Default is 'donut' if parameter doesn't exist.
|
||||||
|
"""
|
||||||
|
ICPSudo = self.env['ir.config_parameter'].sudo()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'annual_leave_chart': ICPSudo.get_param('system_dashboard_classic.annual_leave_chart_type', 'donut'),
|
||||||
|
'salary_slips_chart': ICPSudo.get_param('system_dashboard_classic.salary_slips_chart_type', 'donut'),
|
||||||
|
'timesheet_chart': ICPSudo.get_param('system_dashboard_classic.timesheet_chart_type', 'donut'),
|
||||||
|
'attendance_hours_chart': ICPSudo.get_param('system_dashboard_classic.attendance_hours_chart_type', 'donut'),
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
from odoo import models, fields, api, _
|
from odoo import models, fields, api, _
|
||||||
from odoo.exceptions import ValidationError , AccessError
|
from odoo.exceptions import AccessError, UserError
|
||||||
from odoo.tools.safe_eval import safe_eval
|
|
||||||
import ast
|
import ast
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from dateutil.relativedelta import relativedelta, SA, SU, MO
|
from dateutil.relativedelta import relativedelta, SA, SU, MO
|
||||||
import calendar
|
|
||||||
import pytz
|
import pytz
|
||||||
|
from math import radians, sin, cos, sqrt, asin
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -31,8 +30,56 @@ class SystemDashboard(models.Model):
|
||||||
|
|
||||||
# employee and user date and vars defined to be used inside this method
|
# employee and user date and vars defined to be used inside this method
|
||||||
values = {'user': [], 'timesheet': [], 'leaves': [], 'payroll': [], 'attendance': [], 'employee': [],
|
values = {'user': [], 'timesheet': [], 'leaves': [], 'payroll': [], 'attendance': [], 'employee': [],
|
||||||
'cards': []}
|
'cards': [], 'attendance_hours': [], 'chart_types': {}, 'card_orders': {}}
|
||||||
base = self.env['base.dashbord'].search([])
|
base = self.env['base.dashbord'].search([])
|
||||||
|
|
||||||
|
# Load chart type settings
|
||||||
|
ICPSudo = self.env['ir.config_parameter'].sudo()
|
||||||
|
values['chart_types'] = {
|
||||||
|
'annual_leave': ICPSudo.get_param('system_dashboard_classic.annual_leave_chart_type', 'donut'),
|
||||||
|
'salary_slips': ICPSudo.get_param('system_dashboard_classic.salary_slips_chart_type', 'donut'),
|
||||||
|
'timesheet': ICPSudo.get_param('system_dashboard_classic.timesheet_chart_type', 'donut'),
|
||||||
|
'attendance_hours': ICPSudo.get_param('system_dashboard_classic.attendance_hours_chart_type', 'donut'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Load chart colors from settings (match the totals styling)
|
||||||
|
# primary = Total/Remaining color (teal), warning = Left amount color (amber)
|
||||||
|
values['chart_colors'] = {
|
||||||
|
'primary': ICPSudo.get_param('system_dashboard_classic.primary_color', '#0891b2'),
|
||||||
|
'warning': ICPSudo.get_param('system_dashboard_classic.warning_color', '#f59e0b'),
|
||||||
|
'secondary': ICPSudo.get_param('system_dashboard_classic.secondary_color', '#1e293b'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Load attendance button setting (disabled by default)
|
||||||
|
values['enable_attendance_button'] = ICPSudo.get_param('system_dashboard_classic.enable_attendance_button', 'False') == 'True'
|
||||||
|
|
||||||
|
# Load stats visibility settings (to prevent fadeIn from overriding hidden state)
|
||||||
|
def get_bool_param(key, default='True'):
|
||||||
|
value = ICPSudo.get_param(key, default)
|
||||||
|
return value in ('True', 'true', True, '1', 1)
|
||||||
|
|
||||||
|
values['stats_visibility'] = {
|
||||||
|
'show_annual_leave': get_bool_param('system_dashboard_classic.show_annual_leave'),
|
||||||
|
'show_salary_slips': get_bool_param('system_dashboard_classic.show_salary_slips'),
|
||||||
|
'show_timesheet': get_bool_param('system_dashboard_classic.show_timesheet'),
|
||||||
|
'show_attendance_hours': get_bool_param('system_dashboard_classic.show_attendance_hours'),
|
||||||
|
'show_attendance_section': get_bool_param('system_dashboard_classic.show_attendance_section'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize celebration data (birthday and work anniversary)
|
||||||
|
values['celebration'] = {
|
||||||
|
'is_birthday': False,
|
||||||
|
'is_anniversary': False,
|
||||||
|
'anniversary_years': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize gender-aware data for personalized experience
|
||||||
|
values['gender_info'] = {
|
||||||
|
'gender': 'male', # default
|
||||||
|
'honorific': 'أستاذ', # default male
|
||||||
|
'pronoun_you': 'ك', # default male (لديك)
|
||||||
|
'verb_suffix': 'تَ', # default male (قمتَ)
|
||||||
|
}
|
||||||
|
|
||||||
user = self.env.user
|
user = self.env.user
|
||||||
user_id = self.env['res.users'].sudo().search_read(
|
user_id = self.env['res.users'].sudo().search_read(
|
||||||
|
|
@ -43,46 +90,116 @@ class SystemDashboard(models.Model):
|
||||||
employee_object = self.env['hr.employee'].sudo().search(
|
employee_object = self.env['hr.employee'].sudo().search(
|
||||||
[('user_id', '=', user.id)], limit=1)
|
[('user_id', '=', user.id)], limit=1)
|
||||||
|
|
||||||
job_english = employee_object.job_id.english_name
|
if hasattr(employee_object.job_id, 'english_name') and employee_object.job_id.english_name:
|
||||||
|
job_english = employee_object.job_id.english_name
|
||||||
|
else:
|
||||||
|
job_english = employee_object.job_id.name or ''
|
||||||
|
|
||||||
t_date = date.today()
|
# ==========================================
|
||||||
|
# CELEBRATION DETECTION: Birthday & Anniversary
|
||||||
|
# ==========================================
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
if employee_object:
|
||||||
|
# ==========================================
|
||||||
|
# GENDER-AWARE PERSONALIZATION
|
||||||
|
# ==========================================
|
||||||
|
# hr.employee has 'gender' field with values: 'male', 'female', 'other'
|
||||||
|
employee_gender = getattr(employee_object, 'gender', 'male') or 'male'
|
||||||
|
if employee_gender == 'female':
|
||||||
|
values['gender_info'] = {
|
||||||
|
'gender': 'female',
|
||||||
|
'honorific': 'أستاذة', # Ms./Mrs.
|
||||||
|
'pronoun_you': 'كِ', # لديكِ (feminine you)
|
||||||
|
'verb_suffix': 'تِ', # قمتِ (feminine past tense)
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
values['gender_info'] = {
|
||||||
|
'gender': 'male',
|
||||||
|
'honorific': 'أستاذ', # Mr.
|
||||||
|
'pronoun_you': 'ك', # لديك (masculine you)
|
||||||
|
'verb_suffix': 'تَ', # قمتَ (masculine past tense)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for BIRTHDAY (compare month and day only)
|
||||||
|
birthday = getattr(employee_object, 'birthday', None)
|
||||||
|
if birthday:
|
||||||
|
if birthday.month == today.month and birthday.day == today.day:
|
||||||
|
values['celebration']['is_birthday'] = True
|
||||||
|
|
||||||
|
# Check for WORK ANNIVERSARY (first contract start date)
|
||||||
|
# Using contract_id.date_start from hr.contract
|
||||||
|
joining_date = None
|
||||||
|
if employee_object.contract_id and employee_object.contract_id.date_start:
|
||||||
|
joining_date = employee_object.contract_id.date_start
|
||||||
|
|
||||||
|
if joining_date:
|
||||||
|
# Compare month and day
|
||||||
|
if joining_date.month == today.month and joining_date.day == today.day:
|
||||||
|
# Calculate years of service
|
||||||
|
years = today.year - joining_date.year
|
||||||
|
if years > 0: # Only celebrate if at least 1 year
|
||||||
|
values['celebration']['is_anniversary'] = True
|
||||||
|
values['celebration']['anniversary_years'] = years
|
||||||
|
|
||||||
|
t_date = today # Use same date object
|
||||||
attendance_date = {}
|
attendance_date = {}
|
||||||
leaves_data = {}
|
leaves_data = {}
|
||||||
payroll_data = {}
|
payroll_data = {}
|
||||||
timesheet_data = {}
|
timesheet_data = {}
|
||||||
|
attendance_hours_data = {}
|
||||||
###################################################
|
###################################################
|
||||||
|
|
||||||
# check whether last action sign in or out and its date
|
# check whether last action sign in or out and its date
|
||||||
is_hr_attendance_module = self.env['ir.module.module'].sudo().search([('name', '=', 'hr_attendance')])
|
is_hr_attendance_module = self.env['ir.module.module'].sudo().search([('name', '=', 'hr_attendance')])
|
||||||
if is_hr_attendance_module and is_hr_attendance_module.state == 'installed':
|
if is_hr_attendance_module and is_hr_attendance_module.state == 'installed':
|
||||||
attendance = self.env['attendance.attendance'].sudo().search(
|
# ATTENDANCE LOGIC IMPROVEMENT:
|
||||||
[('employee_id', '=', employee_object.id), ('action_date', '=', t_date)])
|
# Fetch the absolute latest record regardless of date (e.g. yesterday, mobile app entry)
|
||||||
is_attendance = True
|
# This ensures we show the REAL status.
|
||||||
if not attendance:
|
last_attendance = self.env['attendance.attendance'].sudo().search(
|
||||||
is_attendance = False
|
[('employee_id', '=', employee_object.id)], limit=1, order="name desc")
|
||||||
if attendance and attendance[-1].action == 'sign_out':
|
|
||||||
is_attendance = False
|
is_attendance = False
|
||||||
|
if last_attendance and last_attendance.action == 'sign_in':
|
||||||
|
is_attendance = True
|
||||||
|
|
||||||
user_tz = pytz.timezone(self.env.context.get('tz', 'Asia/Riyadh') or self.env.user.tz)
|
user_tz = pytz.timezone(self.env.context.get('tz', 'Asia/Riyadh') or self.env.user.tz)
|
||||||
if attendance:
|
time_in_timezone = False
|
||||||
time_object = fields.Datetime.from_string(attendance[-1].name)
|
|
||||||
|
if last_attendance:
|
||||||
|
# Use 'name' (datetime) for the time display
|
||||||
|
time_object = fields.Datetime.from_string(last_attendance.name)
|
||||||
time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz)
|
time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz)
|
||||||
|
|
||||||
attendance_date.update({'is_attendance': is_attendance,
|
attendance_date.update({'is_attendance': is_attendance,
|
||||||
'time': time_in_timezone if attendance else False
|
'time': time_in_timezone
|
||||||
})
|
})
|
||||||
# if noc is found case shoud be handeld
|
# if noc is found case shoud be handeld
|
||||||
###############################################
|
###############################################
|
||||||
|
|
||||||
# compute leaves taken and remaing leaves
|
# compute leaves taken and remaing leaves
|
||||||
is_leave_module = self.env['ir.module.module'].sudo().search([('name', '=', 'hr_holidays_community')])
|
is_leave_module = self.env['ir.module.module'].sudo().search([('name', '=', 'hr_holidays_community')])
|
||||||
if is_leave_module and is_leave_module.state == 'installed':
|
is_leave_installed = bool(is_leave_module and is_leave_module.state == 'installed')
|
||||||
|
|
||||||
|
# Always set default values and module status
|
||||||
|
leaves_data.update({
|
||||||
|
'taken': 0,
|
||||||
|
'remaining_leaves': 0,
|
||||||
|
'is_module_installed': is_leave_installed
|
||||||
|
})
|
||||||
|
|
||||||
|
if is_leave_installed:
|
||||||
|
# FIX: Removed limit=1 to consider ALL valid allocations (e.g. current year + carried over)
|
||||||
leaves = self.env['hr.holidays'].sudo().search(
|
leaves = self.env['hr.holidays'].sudo().search(
|
||||||
[('employee_id', '=', employee_object.id), ('holiday_status_id.leave_type', '=', 'annual'),
|
[('employee_id', '=', employee_object.id), ('holiday_status_id.leave_type', '=', 'annual'),
|
||||||
('type', '=', 'add'), ('check_allocation_view', '=', 'balance')], limit=1)
|
('type', '=', 'add'), ('check_allocation_view', '=', 'balance')])
|
||||||
taken = leaves.leaves_taken
|
|
||||||
remaining_leaves = leaves.remaining_leaves
|
# Sum up valid allocations
|
||||||
|
taken = sum(l.leaves_taken for l in leaves)
|
||||||
|
remaining_leaves = sum(l.remaining_leaves for l in leaves)
|
||||||
|
|
||||||
leaves_data.update({'taken': taken,
|
leaves_data.update({'taken': taken,
|
||||||
'remaining_leaves': remaining_leaves
|
'remaining_leaves': remaining_leaves
|
||||||
|
|
||||||
})
|
})
|
||||||
###################################################
|
###################################################
|
||||||
|
|
||||||
|
|
@ -91,17 +208,47 @@ class SystemDashboard(models.Model):
|
||||||
last_day = date(date.today().year, 12, 31)
|
last_day = date(date.today().year, 12, 31)
|
||||||
|
|
||||||
is_payslip_module = self.env['ir.module.module'].sudo().search([('name', '=', 'exp_hr_payroll')])
|
is_payslip_module = self.env['ir.module.module'].sudo().search([('name', '=', 'exp_hr_payroll')])
|
||||||
if is_payslip_module and is_payslip_module.state == 'installed':
|
is_payslip_installed = bool(is_payslip_module and is_payslip_module.state == 'installed')
|
||||||
payslip = self.env['hr.payslip'].sudo().search_count(
|
|
||||||
|
# Always set default values and module status
|
||||||
|
payroll_data.update({
|
||||||
|
'taken': 0,
|
||||||
|
'payslip_remaining': 0,
|
||||||
|
'is_module_installed': is_payslip_installed
|
||||||
|
})
|
||||||
|
|
||||||
|
if is_payslip_installed:
|
||||||
|
payslip_count = self.env['hr.payslip'].sudo().search_count(
|
||||||
[('employee_id', '=', employee_object.id), ('date_from', '>=', first_day), ('date_to', '<=', last_day)])
|
[('employee_id', '=', employee_object.id), ('date_from', '>=', first_day), ('date_to', '<=', last_day)])
|
||||||
payroll_data.update({'taken': payslip,
|
|
||||||
'payslip_remaining': 12 - payslip
|
# FIX: Calculate expected slips based on contract start date
|
||||||
|
# If joined mid-year, they shouldn't expect 12 slips
|
||||||
|
contract = self.env['hr.contract'].sudo().search([('employee_id', '=', employee_object.id), ('state', '=', 'open')], limit=1)
|
||||||
|
expected_slips = 12
|
||||||
|
if contract and contract.date_start and contract.date_start.year == date.today().year:
|
||||||
|
# If joined this year, expected slips = months from start date to Dec
|
||||||
|
expected_slips = 12 - contract.date_start.month + 1
|
||||||
|
|
||||||
|
# Ensure we don't show negative remaining
|
||||||
|
remaining_slips = max(0, expected_slips - payslip_count)
|
||||||
|
|
||||||
|
payroll_data.update({'taken': payslip_count,
|
||||||
|
'payslip_remaining': remaining_slips
|
||||||
})
|
})
|
||||||
##############################################
|
##############################################
|
||||||
|
|
||||||
# compute timesheet taken and remaing timesheet
|
# compute timesheet taken and remaing timesheet
|
||||||
is_analytic_module = self.env['ir.module.module'].sudo().search([('name', '=', 'analytic')])
|
is_analytic_module = self.env['ir.module.module'].sudo().search([('name', '=', 'analytic')])
|
||||||
if is_analytic_module and is_analytic_module.state == 'installed':
|
is_timesheet_installed = bool(is_analytic_module and is_analytic_module.state == 'installed')
|
||||||
|
|
||||||
|
# Always set default values and module status
|
||||||
|
timesheet_data.update({
|
||||||
|
'taken': 0,
|
||||||
|
'timesheet_remaining': 0,
|
||||||
|
'is_module_installed': is_timesheet_installed
|
||||||
|
})
|
||||||
|
|
||||||
|
if is_timesheet_installed:
|
||||||
calender = employee_object.resource_calendar_id
|
calender = employee_object.resource_calendar_id
|
||||||
days_off_name = []
|
days_off_name = []
|
||||||
days_special_name = []
|
days_special_name = []
|
||||||
|
|
@ -161,25 +308,47 @@ class SystemDashboard(models.Model):
|
||||||
star_of_week = SA
|
star_of_week = SA
|
||||||
|
|
||||||
# calcultion of all working hours and return done working hours and remaining
|
# calcultion of all working hours and return done working hours and remaining
|
||||||
lenght_days_off = len(days_off_name)
|
# ATTENDANCE/TIMESHEET LOGIC IMPROVEMENT:
|
||||||
lenght_special_days_off = len(days_special_name)
|
# Replaced manual calculation with standard Odoo calendar method
|
||||||
lenght_work_days = (days_of_week - lenght_days_off) - lenght_special_days_off
|
# This ensures Public Holidays (Global Leaves) are correctly handled
|
||||||
total_wroking_hours = (working_hours * lenght_work_days) + sepcial_working_hours
|
|
||||||
domain = [('employee_id', '=', employee_object.id), '&', (
|
# Determine start/end of week logic (kept from original code to maintain week start preference)
|
||||||
'date', '>=', (date.today() + relativedelta(weeks=-1, days=1, weekday=star_of_week)).strftime('%Y-%m-%d')),
|
# star_of_week is determined above (SU, SA, MO)
|
||||||
('date', '<=', (date.today() + relativedelta(weekday=6)).strftime('%Y-%m-%d')), ]
|
|
||||||
timesheet = self.env['account.analytic.line'].sudo().search(domain)
|
start_date = date.today() + relativedelta(weeks=-1, days=1, weekday=star_of_week)
|
||||||
day_name_list = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', ]
|
end_date = start_date + relativedelta(days=6) # Ensure strictly 7 days week range
|
||||||
done_hours = 0
|
# Note: start_date is calculated based on star_of_week logic (e.g. last Saturday)
|
||||||
for sheet in timesheet:
|
|
||||||
day = datetime.strptime(str(sheet.date), '%Y-%m-%d').weekday()
|
total_wroking_hours = 0
|
||||||
day_name = day_name_list[day]
|
if employee_object.resource_calendar_id:
|
||||||
if day_name not in days_off_name:
|
# Use standard Odoo method
|
||||||
done_hours = done_hours + sheet.unit_amount
|
total_wroking_hours = employee_object.resource_calendar_id.get_work_hours_count(
|
||||||
|
datetime.combine(start_date, datetime.min.time()),
|
||||||
|
datetime.combine(end_date, datetime.max.time()),
|
||||||
|
compute_leaves=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fallback manual calculation (if calendar missing)
|
||||||
|
# Restore variables needed for calculation
|
||||||
|
lenght_days_off = len(days_off_name)
|
||||||
|
lenght_special_days_off = len(days_special_name)
|
||||||
|
lenght_work_days = (days_of_week - lenght_days_off) - lenght_special_days_off
|
||||||
|
total_wroking_hours = (working_hours * lenght_work_days) + sepcial_working_hours
|
||||||
|
|
||||||
don_hours = sum(item.unit_amount for item in timesheet)
|
domain = [('employee_id', '=', employee_object.id),
|
||||||
timesheet_data.update({'taken': don_hours,
|
('date', '>=', start_date),
|
||||||
'timesheet_remaining': total_wroking_hours - don_hours
|
('date', '<=', end_date)]
|
||||||
|
|
||||||
|
timesheet = self.env['account.analytic.line'].sudo().search(domain)
|
||||||
|
|
||||||
|
# Filter days off manually? Odoo search already filters by date.
|
||||||
|
# Original code filtered 'day_name not in days_off_name'.
|
||||||
|
# If standard timesheet is used, entries on off-days are usually overtime or valid.
|
||||||
|
# We will sum ALL valid timesheet entries in the period.
|
||||||
|
done_hours = sum(sheet.unit_amount for sheet in timesheet)
|
||||||
|
|
||||||
|
timesheet_data.update({'taken': done_hours,
|
||||||
|
'timesheet_remaining': max(0, total_wroking_hours - done_hours)
|
||||||
})
|
})
|
||||||
|
|
||||||
##############################################
|
##############################################
|
||||||
|
|
@ -198,23 +367,29 @@ class SystemDashboard(models.Model):
|
||||||
if self.is_user(line.group_ids,
|
if self.is_user(line.group_ids,
|
||||||
user): # call method to return if user is in one of the groups in current line
|
user): # call method to return if user is in one of the groups in current line
|
||||||
# static vars for the card
|
# static vars for the card
|
||||||
# TODO: find a way to fix translation,
|
# FIX: Fetch explicit translations for bilingual support
|
||||||
# the filed is name is translateable,
|
# model.name returns current user lang, so we force context
|
||||||
# but its not loaded when change lang,
|
card_name_ar = model.with_context(lang='ar_001').name or model.with_context(lang='ar_SY').name or model.name
|
||||||
# so we handel it by searching on translation object.
|
card_name_en = model.with_context(lang='en_US').name or model.name
|
||||||
model_name_to_serach = model.name if model.name else model.model_id.name
|
|
||||||
#value = self.env['ir.translation'].sudo().search([('source', '=', model_name_to_serach)], limit=1).value
|
# Fallback if model name is empty (use model description)
|
||||||
value = "text"
|
if not card_name_ar:
|
||||||
card_name = model.name if model.name else model.model_id.name
|
card_name_ar = model.model_id.with_context(lang='ar_001').name or model.model_id.name
|
||||||
# if self.env.user.lang == 'en_US':
|
if not card_name_en:
|
||||||
# card_name = model.name if model.name else model.model_id.name
|
card_name_en = model.model_id.with_context(lang='en_US').name or model.model_id.name
|
||||||
# else:
|
|
||||||
# card_name = value
|
mod['name'] = card_name_ar if self.env.user.lang in ['ar_001', 'ar_SY'] else card_name_en
|
||||||
mod['name'] = card_name
|
mod['name_arabic'] = card_name_ar
|
||||||
mod['name_arabic'] = card_name
|
mod['name_english'] = card_name_en
|
||||||
mod['name_english'] = card_name
|
|
||||||
mod['model'] = model.model_name
|
mod['model'] = model.model_name
|
||||||
mod['image'] = model.card_image
|
mod['image'] = model.card_image
|
||||||
|
mod['icon_type'] = model.icon_type
|
||||||
|
mod['icon_name'] = model.icon_name
|
||||||
|
|
||||||
|
# Default Icon Logic
|
||||||
|
if not mod['image'] and not mod['icon_name']:
|
||||||
|
mod['icon_type'] = 'icon'
|
||||||
|
mod['icon_name'] = 'fa-th-large'
|
||||||
|
|
||||||
# var used in domain to serach with either state or state
|
# var used in domain to serach with either state or state
|
||||||
state_or_stage = 'state' if line.state_id else 'stage_id'
|
state_or_stage = 'state' if line.state_id else 'stage_id'
|
||||||
|
|
@ -374,7 +549,14 @@ class SystemDashboard(models.Model):
|
||||||
#if 'user_id' in mod._fields:
|
#if 'user_id' in mod._fields:
|
||||||
# service_action_domain.append(('user_id', '=', user.id))
|
# service_action_domain.append(('user_id', '=', user.id))
|
||||||
# service_action_domain.append(('employee_id.user_id','=',user.id))
|
# service_action_domain.append(('employee_id.user_id','=',user.id))
|
||||||
# value = self.env['ir.translation'].sudo().search([('source', '=', model.name)], limit=1).value
|
# Default Icon Logic
|
||||||
|
if not model.card_image and not model.icon_name:
|
||||||
|
model_icon_type = 'icon'
|
||||||
|
model_icon_name = 'fa-th-large'
|
||||||
|
else:
|
||||||
|
model_icon_type = model.icon_type
|
||||||
|
model_icon_name = model.icon_name
|
||||||
|
|
||||||
values['cards'].append({
|
values['cards'].append({
|
||||||
'type': 'selfs',
|
'type': 'selfs',
|
||||||
'name': card_name,
|
'name': card_name,
|
||||||
|
|
@ -383,6 +565,8 @@ class SystemDashboard(models.Model):
|
||||||
'model': model.model_name,
|
'model': model.model_name,
|
||||||
'state_count': self.env[model.model_name].search_count(service_action_domain),
|
'state_count': self.env[model.model_name].search_count(service_action_domain),
|
||||||
'image': model.card_image,
|
'image': model.card_image,
|
||||||
|
'icon_type': model_icon_type,
|
||||||
|
'icon_name': model_icon_name,
|
||||||
'form_view': model.form_view_id.id,
|
'form_view': model.form_view_id.id,
|
||||||
'list_view': model.list_view_id.id,
|
'list_view': model.list_view_id.id,
|
||||||
'js_domain': service_action_domain,
|
'js_domain': service_action_domain,
|
||||||
|
|
@ -399,74 +583,291 @@ class SystemDashboard(models.Model):
|
||||||
values['payroll'].append(payroll_data)
|
values['payroll'].append(payroll_data)
|
||||||
values['attendance'].append(attendance_date)
|
values['attendance'].append(attendance_date)
|
||||||
values['timesheet'].append(timesheet_data)
|
values['timesheet'].append(timesheet_data)
|
||||||
|
|
||||||
|
# Compute attendance hours from hr.attendance.transaction
|
||||||
|
try:
|
||||||
|
AttendanceTransaction = self.env['hr.attendance.transaction'].sudo()
|
||||||
|
# Get current month's attendance transactions for the employee
|
||||||
|
first_day_of_month = date(t_date.year, t_date.month, 1)
|
||||||
|
attendance_txns = AttendanceTransaction.search([
|
||||||
|
('employee_id', '=', employee_object.id),
|
||||||
|
('date', '>=', first_day_of_month),
|
||||||
|
('date', '<=', t_date)
|
||||||
|
])
|
||||||
|
# ATTENDANCE LOGIC IMPROVEMENT:
|
||||||
|
# Calculate planned hours from the Resource Calendar (Work Schedule)
|
||||||
|
# This ensures we count days where the employee was ABSENT (no transaction)
|
||||||
|
# as missed hours, providing a true compliance percentage.
|
||||||
|
if employee_object.resource_calendar_id:
|
||||||
|
# Use standard Odoo method to get expected working hours for the period
|
||||||
|
# Use standard Odoo method to get expected working hours for the period
|
||||||
|
# from start of month to today (Month-to-Date)
|
||||||
|
# compute_related ensures we consider resource specificities if needed
|
||||||
|
# Important: Convert dates to datetime as required by get_work_hours_count
|
||||||
|
plan_hours_total = employee_object.resource_calendar_id.get_work_hours_count(
|
||||||
|
datetime.combine(first_day_of_month, datetime.min.time()),
|
||||||
|
datetime.combine(t_date, datetime.max.time()),
|
||||||
|
compute_leaves=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fallback to sum of transactions if no calendar (though rare for active employees)
|
||||||
|
plan_hours_total = sum(txn.plan_hours for txn in attendance_txns)
|
||||||
|
|
||||||
|
official_hours_total = sum(txn.official_hours for txn in attendance_txns)
|
||||||
|
attendance_hours_data.update({
|
||||||
|
'plan_hours': round(plan_hours_total, 2),
|
||||||
|
'official_hours': round(official_hours_total, 2),
|
||||||
|
'is_module_installed': True # Module is installed if we got here
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
attendance_hours_data.update({
|
||||||
|
'plan_hours': 0,
|
||||||
|
'official_hours': 0,
|
||||||
|
'is_module_installed': False # Module not installed or error
|
||||||
|
})
|
||||||
|
|
||||||
|
values['attendance_hours'].append(attendance_hours_data)
|
||||||
|
|
||||||
values['job_english'] = job_english
|
values['job_english'] = job_english
|
||||||
|
|
||||||
|
# Load user's saved card order preferences
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
card_orders_json = self.env.user.dashboard_card_orders or '{}'
|
||||||
|
values['card_orders'] = json.loads(card_orders_json)
|
||||||
|
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||||
|
values['card_orders'] = {}
|
||||||
|
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
def _haversine_distance(self, lat1, lon1, lat2, lon2):
|
||||||
|
"""
|
||||||
|
Calculate the great-circle distance between two GPS points using Haversine formula.
|
||||||
|
|
||||||
|
@param lat1, lon1: First point coordinates in degrees
|
||||||
|
@param lat2, lon2: Second point coordinates in degrees
|
||||||
|
@return: Distance in meters
|
||||||
|
"""
|
||||||
|
R = 6371000 # Earth's radius in meters
|
||||||
|
|
||||||
|
# Convert to radians
|
||||||
|
lat1_rad, lon1_rad = radians(lat1), radians(lon1)
|
||||||
|
lat2_rad, lon2_rad = radians(lat2), radians(lon2)
|
||||||
|
|
||||||
|
# Differences
|
||||||
|
dlat = lat2_rad - lat1_rad
|
||||||
|
dlon = lon2_rad - lon1_rad
|
||||||
|
|
||||||
|
# Haversine formula
|
||||||
|
a = sin(dlat / 2) ** 2 + cos(lat1_rad) * cos(lat2_rad) * sin(dlon / 2) ** 2
|
||||||
|
c = 2 * asin(sqrt(a))
|
||||||
|
|
||||||
|
return R * c
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def checkin_checkout(self):
|
def checkin_checkout(self, latitude=None, longitude=None):
|
||||||
|
"""
|
||||||
|
Check-in or Check-out with zone-based geolocation validation.
|
||||||
|
|
||||||
|
@param latitude: User's current latitude from browser geolocation
|
||||||
|
@param longitude: User's current longitude from browser geolocation
|
||||||
|
@return: Dict with is_attendance, time, and any error info
|
||||||
|
"""
|
||||||
ctx = self._context
|
ctx = self._context
|
||||||
attendance_status = ctx.get('check', False)
|
|
||||||
t_date = date.today()
|
t_date = date.today()
|
||||||
user = self.env.user
|
user = self.env.user
|
||||||
employee_object = self.env['hr.employee'].sudo().search([('user_id', '=', user.id)], limit=1)
|
employee_object = self.env['hr.employee'].sudo().search([('user_id', '=', user.id)], limit=1)
|
||||||
|
|
||||||
vals_in = {'employee_id': employee_object.id,'action': 'sign_in','action_type': 'manual'}
|
if not employee_object:
|
||||||
vals_out = {'employee_id': employee_object.id,'action': 'sign_out','action_type': 'manual'}
|
is_arabic = 'ar' in self._context.get('lang', 'en_US')
|
||||||
|
msg = "عذراً، لم يتم العثور على ملف وظيفي مرتبط بحسابك.\nيرجى مراجعة إدارة الموارد البشرية." if is_arabic else "Sorry, no employee profile found linked to your account.\nPlease contact HR department."
|
||||||
|
return {'error': True, 'message': msg}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ZONE VALIDATION - Check if employee is within allowed zone
|
||||||
|
# ============================================================
|
||||||
|
AttendanceZone = self.env['attendance.zone'].sudo()
|
||||||
|
|
||||||
|
# Find employee's assigned zones
|
||||||
|
employee_zones = AttendanceZone.search([('employee_ids', 'in', employee_object.id)])
|
||||||
|
|
||||||
|
# Check for general zone (applies to all employees)
|
||||||
|
general_zone = employee_zones.filtered(lambda z: z.general)
|
||||||
|
|
||||||
|
# If employee has a general zone, allow from anywhere (no location check needed)
|
||||||
|
zone_validation_required = not bool(general_zone)
|
||||||
|
|
||||||
|
if zone_validation_required:
|
||||||
|
is_arabic = 'ar' in self._context.get('lang', 'en_US')
|
||||||
|
|
||||||
|
# Location is required for non-general zones
|
||||||
|
if not latitude or not longitude:
|
||||||
|
msg = "يتطلب النظام الوصول إلى موقعك الجغرافي لتسجيل الحضور.\nيرجى تفعيل صلاحية الموقع في المتصفح والمحاولة مرة أخرى." if is_arabic else "System requires access to your location for attendance.\nPlease enable location permission in your browser and try again."
|
||||||
|
return {'error': True, 'message': msg}
|
||||||
|
|
||||||
|
# If no zones assigned at all
|
||||||
|
if not employee_zones:
|
||||||
|
msg = "لم يتم تعيين منطقة حضور خاصة بك.\nيرجى التواصل مع إدارة النظام." if is_arabic else "No specific attendance zone assigned to you.\nPlease contact system administration."
|
||||||
|
return {'error': True, 'message': msg}
|
||||||
|
|
||||||
|
# Check if user is within any of their assigned zones
|
||||||
|
is_in_zone = False
|
||||||
|
closest_zone_distance = float('inf')
|
||||||
|
allowed_range = 0
|
||||||
|
valid_zones_count = 0 # Track zones with valid coordinates
|
||||||
|
|
||||||
|
for zone in employee_zones:
|
||||||
|
if not zone.latitude or not zone.longitude or not zone.allowed_range:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
zone_lat = float(zone.latitude)
|
||||||
|
zone_lon = float(zone.longitude)
|
||||||
|
zone_range = float(zone.allowed_range)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
valid_zones_count += 1
|
||||||
|
|
||||||
|
# Calculate distance from user to zone center
|
||||||
|
distance = self._haversine_distance(latitude, longitude, zone_lat, zone_lon)
|
||||||
|
|
||||||
|
if distance <= zone_range:
|
||||||
|
is_in_zone = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Track closest zone for error message
|
||||||
|
if distance < closest_zone_distance:
|
||||||
|
closest_zone_distance = distance
|
||||||
|
allowed_range = zone_range
|
||||||
|
|
||||||
|
if not is_in_zone:
|
||||||
|
# Edge case: all zones have invalid coordinates
|
||||||
|
if valid_zones_count == 0:
|
||||||
|
msg = "بيانات منطقة الحضور غير مكتملة.\nيرجى التواصل مع إدارة النظام لتحديث الإحداثيات." if is_arabic else "Attendance zone data is incomplete.\nPlease contact system administration to update coordinates."
|
||||||
|
return {'error': True, 'message': msg}
|
||||||
|
|
||||||
|
# Calculate how far outside the zone user is
|
||||||
|
distance_outside = int(closest_zone_distance - allowed_range)
|
||||||
|
|
||||||
|
# Gender-aware Arabic message
|
||||||
|
# تتواجد (male) / تتواجدين (female)
|
||||||
|
# لتتمكن (male) / لتتمكني (female)
|
||||||
|
employee_gender = getattr(employee_object, 'gender', 'male') or 'male'
|
||||||
|
if employee_gender == 'female':
|
||||||
|
ar_msg = "عذراً، أنتِ تتواجدين خارج نطاق الحضور المسموح.\nيرجى الاقتراب مسافة %s متر تقريباً أو أكثر لتتمكني من التسجيل."
|
||||||
|
else:
|
||||||
|
ar_msg = "عذراً، أنت تتواجد خارج نطاق الحضور المسموح.\nيرجى الاقتراب مسافة %s متر تقريباً أو أكثر لتتمكن من التسجيل."
|
||||||
|
|
||||||
|
msg_formats = {
|
||||||
|
'ar': ar_msg,
|
||||||
|
'en': "Sorry, you are outside the allowed attendance zone.\nPlease move approximately %s meters or more closer to be able to check in."
|
||||||
|
}
|
||||||
|
msg = (msg_formats['ar'] if is_arabic else msg_formats['en']) % distance_outside
|
||||||
|
return {
|
||||||
|
'error': True,
|
||||||
|
'message': msg
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ATTENDANCE CREATION (Zone validation passed)
|
||||||
|
# ============================================================
|
||||||
|
vals_in = {
|
||||||
|
'employee_id': employee_object.id,
|
||||||
|
'action': 'sign_in',
|
||||||
|
'action_type': 'manual',
|
||||||
|
'latitude': str(latitude) if latitude else False,
|
||||||
|
'longitude': str(longitude) if longitude else False,
|
||||||
|
}
|
||||||
|
vals_out = {
|
||||||
|
'employee_id': employee_object.id,
|
||||||
|
'action': 'sign_out',
|
||||||
|
'action_type': 'manual',
|
||||||
|
'latitude': str(latitude) if latitude else False,
|
||||||
|
'longitude': str(longitude) if longitude else False,
|
||||||
|
}
|
||||||
|
|
||||||
# check last attendance record before do any action
|
|
||||||
Attendance = self.env['attendance.attendance']
|
Attendance = self.env['attendance.attendance']
|
||||||
is_attendance = False
|
|
||||||
|
|
||||||
# get last attendance record
|
# Robust Logic: Always fetch the ABSOLUTE last record regardless of date
|
||||||
last_attendance = self.env['attendance.attendance'].sudo().search(
|
last_attendance = self.env['attendance.attendance'].sudo().search(
|
||||||
[('employee_id', '=', employee_object.id), ('action_date', '=', t_date)], limit=1, order="id desc")
|
[('employee_id', '=', employee_object.id)], limit=1, order="name desc")
|
||||||
|
|
||||||
user_tz = pytz.timezone(self.env.context.get('tz', 'Asia/Riyadh') or self.env.user.tz)
|
user_tz = pytz.timezone(self.env.context.get('tz', 'Asia/Riyadh') or self.env.user.tz)
|
||||||
|
|
||||||
|
# Determine next action based on DB state
|
||||||
|
if last_attendance and last_attendance.action == 'sign_in':
|
||||||
|
# User is currently Checked In -> Action: Sign Out
|
||||||
|
result = Attendance.create(vals_out)
|
||||||
|
is_attendance = False
|
||||||
|
else:
|
||||||
|
# User is Checked Out (or no record) -> Action: Sign In
|
||||||
|
result = Attendance.create(vals_in)
|
||||||
|
is_attendance = True
|
||||||
|
|
||||||
|
# Fetch the NEW last record to return accurate time
|
||||||
|
last_attendance = self.env['attendance.attendance'].sudo().search(
|
||||||
|
[('employee_id', '=', employee_object.id)], limit=1, order="name desc")
|
||||||
|
|
||||||
if last_attendance:
|
if last_attendance:
|
||||||
time_object = fields.Datetime.from_string(last_attendance.name)
|
time_object = fields.Datetime.from_string(last_attendance.name)
|
||||||
time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz)
|
time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz)
|
||||||
|
|
||||||
# check current attendance status
|
|
||||||
# (True == user already signed in --> create new record with signout action)
|
|
||||||
# (Fasle == user already signed out --> create new record with signin action)
|
|
||||||
if attendance_status:
|
|
||||||
is_attendance = False
|
|
||||||
# check last attendance record action
|
|
||||||
if last_attendance:
|
|
||||||
if last_attendance.action == 'sign_in':
|
|
||||||
result = Attendance.create(vals_out) # create signout record
|
|
||||||
last_attendance = self.env['attendance.attendance'].sudo().search(
|
|
||||||
[('employee_id', '=', employee_object.id), ('action_date', '=', t_date)], limit=1, order="id desc")
|
|
||||||
time_object = fields.Datetime.from_string(last_attendance.name)
|
|
||||||
time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz)
|
|
||||||
elif last_attendance.action == 'sign_out':
|
|
||||||
is_attendance = False
|
|
||||||
else:
|
|
||||||
is_attendance = True
|
|
||||||
else:
|
else:
|
||||||
is_attendance = True
|
time_in_timezone = False
|
||||||
# check last attendance record action
|
|
||||||
if last_attendance:
|
|
||||||
if last_attendance.action == 'sign_out':
|
|
||||||
result = Attendance.create(vals_in) # create signin record
|
|
||||||
last_attendance = self.env['attendance.attendance'].sudo().search(
|
|
||||||
[('employee_id', '=', employee_object.id), ('action_date', '=', t_date)], limit=1, order="id desc")
|
|
||||||
time_object = fields.Datetime.from_string(last_attendance.name)
|
|
||||||
time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz)
|
|
||||||
elif last_attendance.action == 'sign_in':
|
|
||||||
is_attendance = True
|
|
||||||
else:
|
|
||||||
is_attendance = False
|
|
||||||
else:
|
|
||||||
result = Attendance.create(vals_in) # create signin record
|
|
||||||
last_attendance = self.env['attendance.attendance'].sudo().search(
|
|
||||||
[('employee_id', '=', employee_object.id), ('action_date', '=', t_date)], limit=1, order="id desc")
|
|
||||||
time_object = fields.Datetime.from_string(last_attendance.name)
|
|
||||||
time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz)
|
|
||||||
|
|
||||||
attendance_response = {
|
attendance_response = {
|
||||||
'is_attendance': is_attendance,
|
'is_attendance': is_attendance,
|
||||||
'time': time_in_timezone if last_attendance else False
|
'time': time_in_timezone
|
||||||
}
|
}
|
||||||
|
|
||||||
return attendance_response
|
return attendance_response
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def save_card_order(self, order_type, card_ids):
|
||||||
|
"""
|
||||||
|
Save card order preference for current user.
|
||||||
|
Stores in res.users.dashboard_card_orders as JSON.
|
||||||
|
|
||||||
|
@param order_type: String key identifying which cards (e.g., 'service', 'approve', 'track', 'stats')
|
||||||
|
@param card_ids: List of card identifiers in desired order
|
||||||
|
@return: Boolean success status
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
user = self.env.user
|
||||||
|
|
||||||
|
# Get current orders or initialize empty dict
|
||||||
|
try:
|
||||||
|
current_orders = json.loads(user.dashboard_card_orders or '{}')
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
current_orders = {}
|
||||||
|
|
||||||
|
# Update the specific order type
|
||||||
|
current_orders[order_type] = card_ids
|
||||||
|
|
||||||
|
# Save back to user
|
||||||
|
user.sudo().write({
|
||||||
|
'dashboard_card_orders': json.dumps(current_orders)
|
||||||
|
})
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_card_order(self, order_type=None):
|
||||||
|
"""
|
||||||
|
Get card order preferences for current user.
|
||||||
|
|
||||||
|
@param order_type: Optional string key for specific order type.
|
||||||
|
If None, returns all orders.
|
||||||
|
@return: List of card IDs for specific type, or dict of all orders
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
user = self.env.user
|
||||||
|
|
||||||
|
try:
|
||||||
|
all_orders = json.loads(user.dashboard_card_orders or '{}')
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
all_orders = {}
|
||||||
|
|
||||||
|
if order_type:
|
||||||
|
return all_orders.get(order_type, [])
|
||||||
|
return all_orders
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class ResUsersCardOrder(models.Model):
|
||||||
|
"""
|
||||||
|
Extend res.users to store dashboard card order preferences.
|
||||||
|
This allows card ordering to persist across devices/sessions.
|
||||||
|
"""
|
||||||
|
_inherit = 'res.users'
|
||||||
|
|
||||||
|
dashboard_card_orders = fields.Text(
|
||||||
|
string='Dashboard Card Orders',
|
||||||
|
default='{}',
|
||||||
|
help='JSON storage for dashboard card ordering preferences'
|
||||||
|
)
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
|
|
||||||
<odoo>
|
|
||||||
<data>
|
|
||||||
|
|
||||||
<record model="ir.module.category" id="system_board_management">
|
|
||||||
<field name="name">System Dashboard</field>
|
|
||||||
<field name="description">User access level for System Board module</field>
|
|
||||||
<field name="sequence">20</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Groups
|
|
||||||
-->
|
|
||||||
<record id="system_board_group_manager" model="res.groups">
|
|
||||||
<field name="name">System Dashboard Manager</field>
|
|
||||||
<field name="category_id" ref="system_board_management"/>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<record id="system_board_group_configurations" model="res.groups">
|
|
||||||
<field name="name">System Dashboard Configurations</field>
|
|
||||||
<field name="category_id" ref="system_board_management"/>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
</data>
|
|
||||||
</odoo>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 21 KiB |
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!-- Login Icon - Arrow pointing IN (opposite of logout) -->
|
||||||
|
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 384 384" style="enable-background:new 0 0 384 384;" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path style="fill:#003056;" d="M341.333,0H42.667C19.093,0,0,19.093,0,42.667V128h42.667V42.667h298.667v298.667H42.667V256H0v85.333
|
||||||
|
C0,364.907,19.093,384,42.667,384h298.667C364.907,384,384,364.907,384,341.333V42.667C384,19.093,364.907,0,341.333,0z"/>
|
||||||
|
<polygon style="fill:#003056;" points="232.853,115.52 202.667,85.333 96,192 202.667,298.667 232.853,268.48 177.707,213.333 384,213.333 384,170.667
|
||||||
|
177.707,170.667 "/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 792 B |
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB |
|
|
@ -0,0 +1,351 @@
|
||||||
|
/**
|
||||||
|
* Dashboard Genius Enhancements v3
|
||||||
|
* =================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
odoo.define('system_dashboard_classic.genius_enhancements', function(require) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var ajax = require('web.ajax');
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
// Initial run after short delay
|
||||||
|
setTimeout(initGeniusEnhancements, 800);
|
||||||
|
|
||||||
|
// Run again after longer delay to catch late-loaded content
|
||||||
|
setTimeout(initGeniusEnhancements, 2000);
|
||||||
|
|
||||||
|
// Run multiple times to catch late-loaded content
|
||||||
|
var checkCount = 0;
|
||||||
|
var checkInterval = setInterval(function() {
|
||||||
|
fixEmptyEmployeeCode();
|
||||||
|
// Search bar is now handled by SystemDashboardView widget
|
||||||
|
checkCount++;
|
||||||
|
if (checkCount >= 5) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
function initGeniusEnhancements() {
|
||||||
|
fixEmptyEmployeeCode();
|
||||||
|
// Search bar is now handled by SystemDashboardView widget in self_service.js
|
||||||
|
transformSelfServicesTab();
|
||||||
|
loadCustomColors();
|
||||||
|
loadStatsVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix empty employee code display - runs multiple times
|
||||||
|
* Uses genius-hidden class instead of hide() for CSS priority
|
||||||
|
*/
|
||||||
|
function fixEmptyEmployeeCode() {
|
||||||
|
$('p.fn-id').each(function() {
|
||||||
|
var el = $(this);
|
||||||
|
var idText = el.text().trim();
|
||||||
|
|
||||||
|
// Hide if empty, just "/", "False", "false", undefined, etc.
|
||||||
|
if (!idText ||
|
||||||
|
idText === '' ||
|
||||||
|
idText === '/' ||
|
||||||
|
idText === 'undefined' ||
|
||||||
|
idText === 'null' ||
|
||||||
|
idText === 'false' ||
|
||||||
|
idText === 'False' ||
|
||||||
|
idText === 'None') {
|
||||||
|
el.addClass('genius-hidden');
|
||||||
|
} else {
|
||||||
|
el.removeClass('genius-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add search bar
|
||||||
|
*/
|
||||||
|
function addSearchBar() {
|
||||||
|
var navButtons = $('.dashboard-nav-buttons');
|
||||||
|
|
||||||
|
if ($('.genius-search-container').length > 0 || navButtons.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RTL/LTR placeholder translation
|
||||||
|
var searchPlaceholder = $('body').hasClass('o_rtl')
|
||||||
|
? 'ابحث عن خدمة...'
|
||||||
|
: 'Search for a service...';
|
||||||
|
|
||||||
|
var searchHtml = '<div class="genius-search-container">' +
|
||||||
|
'<div class="genius-search-wrapper">' +
|
||||||
|
'<i class="fa fa-search genius-search-icon"></i>' +
|
||||||
|
'<input type="text" class="genius-search-input" id="geniusServiceSearch" placeholder="' + searchPlaceholder + '" autocomplete="off">' +
|
||||||
|
'<span class="genius-search-clear" id="geniusClearSearch" style="display:none;">×</span>' +
|
||||||
|
'</div></div>';
|
||||||
|
|
||||||
|
navButtons.append(searchHtml);
|
||||||
|
|
||||||
|
$('#geniusServiceSearch').on('input', function() {
|
||||||
|
var term = $(this).val().toLowerCase().trim();
|
||||||
|
$('#geniusClearSearch').toggle(term.length > 0);
|
||||||
|
filterCards(term);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#geniusClearSearch').on('click', function() {
|
||||||
|
$('#geniusServiceSearch').val('');
|
||||||
|
$(this).hide();
|
||||||
|
filterCards('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterCards(term) {
|
||||||
|
var cards = $('.card3, .card2');
|
||||||
|
if (term === '') {
|
||||||
|
cards.removeClass('genius-hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cards.each(function() {
|
||||||
|
var card = $(this);
|
||||||
|
var text = card.find('h4, h3, td').text().toLowerCase();
|
||||||
|
card.toggleClass('genius-hidden', !text.includes(term));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformSelfServicesTab() {
|
||||||
|
var tabs = $('.dashboard-nav-buttons .nav-tabs li');
|
||||||
|
if (tabs.length === 1) {
|
||||||
|
tabs.first().addClass('genius-single-tab');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load ALL custom colors from database and apply as CSS variables
|
||||||
|
* Generates light/dark variants automatically for complete theming
|
||||||
|
*/
|
||||||
|
function loadCustomColors() {
|
||||||
|
var ICP = 'ir.config_parameter';
|
||||||
|
var prefix = 'system_dashboard_classic.';
|
||||||
|
|
||||||
|
// Define all color parameters with their CSS variable names
|
||||||
|
var colorParams = [
|
||||||
|
{ param: 'primary_color', cssVar: '--dash-primary', defaultColor: '#0891b2' },
|
||||||
|
{ param: 'secondary_color', cssVar: '--dash-secondary', defaultColor: '#1e293b' },
|
||||||
|
{ param: 'success_color', cssVar: '--dash-success', defaultColor: '#10b981' },
|
||||||
|
{ param: 'warning_color', cssVar: '--dash-warning', defaultColor: '#f59e0b' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Load each color
|
||||||
|
colorParams.forEach(function(item) {
|
||||||
|
ajax.jsonRpc('/web/dataset/call_kw', 'call', {
|
||||||
|
model: ICP,
|
||||||
|
method: 'get_param',
|
||||||
|
args: [prefix + item.param],
|
||||||
|
kwargs: {}
|
||||||
|
}).then(function(color) {
|
||||||
|
if (color && color.match(/^#[0-9A-Fa-f]{6}$/)) {
|
||||||
|
applyColorWithVariants(item.cssVar, color);
|
||||||
|
// For primary color, also generate SVG icon filter
|
||||||
|
if (item.cssVar === '--dash-primary') {
|
||||||
|
generateIconFilter(color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(function() {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate CSS filter to colorize SVG icons to match target color
|
||||||
|
* Algorithm: First convert to black, then use filter combination
|
||||||
|
* Based on: https://stackoverflow.com/questions/42966641/
|
||||||
|
* @param {string} hexColor - The target color in hex format
|
||||||
|
*/
|
||||||
|
function generateIconFilter(hexColor) {
|
||||||
|
var rgb = hexToRgb(hexColor);
|
||||||
|
if (!rgb) return;
|
||||||
|
|
||||||
|
// Calculate HSL from RGB
|
||||||
|
var r = rgb.r / 255, g = rgb.g / 255, b = rgb.b / 255;
|
||||||
|
var max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||||
|
var h = 0, s = 0, l = (max + min) / 2;
|
||||||
|
|
||||||
|
if (max !== min) {
|
||||||
|
var d = max - min;
|
||||||
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||||
|
else if (max === g) h = ((b - r) / d + 2) / 6;
|
||||||
|
else h = ((r - g) / d + 4) / 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to degrees and percentages
|
||||||
|
var hue = Math.round(h * 360);
|
||||||
|
var saturation = Math.round(s * 100);
|
||||||
|
var lightness = Math.round(l * 100);
|
||||||
|
|
||||||
|
// Build filter: first make it black, then colorize
|
||||||
|
// invert(1) makes black -> white, then hue-rotate to target hue
|
||||||
|
// Or we can use sepia(1) to get yellow base, then hue-rotate
|
||||||
|
|
||||||
|
// Calculate filter values
|
||||||
|
var invert = lightness < 50 ? 0 : 1;
|
||||||
|
var sepia = 1;
|
||||||
|
var saturate = saturation / 10; // Scale to reasonable value
|
||||||
|
var hueRotate = hue;
|
||||||
|
var brightness = lightness / 50; // Normal is 1 (50%)
|
||||||
|
|
||||||
|
// Generate filter string - start with brightness(0) saturate(100%) to get black
|
||||||
|
var filterValue = 'brightness(0) saturate(100%) invert(' + (lightness > 50 ? '1' : '0.5') + ') ' +
|
||||||
|
'sepia(1) saturate(' + Math.max(2, saturate).toFixed(0) + ') ' +
|
||||||
|
'hue-rotate(' + hueRotate + 'deg) ' +
|
||||||
|
'brightness(' + brightness.toFixed(2) + ')';
|
||||||
|
|
||||||
|
// Apply as CSS variable
|
||||||
|
document.documentElement.style.setProperty('--dash-icon-filter', filterValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a color and generate light/dark/RGB variants automatically
|
||||||
|
* @param {string} cssVarName - CSS variable name (e.g., '--dash-primary')
|
||||||
|
* @param {string} hexColor - Hex color value (e.g., '#0891b2')
|
||||||
|
*/
|
||||||
|
function applyColorWithVariants(cssVarName, hexColor) {
|
||||||
|
var root = document.documentElement.style;
|
||||||
|
|
||||||
|
// Apply base color
|
||||||
|
root.setProperty(cssVarName, hexColor);
|
||||||
|
|
||||||
|
// Generate RGB values for rgba() usage in CSS
|
||||||
|
var rgb = hexToRgb(hexColor);
|
||||||
|
if (rgb) {
|
||||||
|
root.setProperty(cssVarName + '-rgb', rgb.r + ', ' + rgb.g + ', ' + rgb.b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate and apply light variant (+25% lighter)
|
||||||
|
var lightColor = adjustColor(hexColor, 25);
|
||||||
|
root.setProperty(cssVarName + '-light', lightColor);
|
||||||
|
|
||||||
|
// Generate and apply dark variant (-20% darker)
|
||||||
|
var darkColor = adjustColor(hexColor, -20);
|
||||||
|
root.setProperty(cssVarName + '-dark', darkColor);
|
||||||
|
|
||||||
|
// For secondary color, also update header gradient
|
||||||
|
if (cssVarName === '--dash-secondary') {
|
||||||
|
root.setProperty('--dash-secondary-light', lightColor);
|
||||||
|
// Update header background directly
|
||||||
|
updateHeaderGradient(hexColor, lightColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert hex color to RGB object
|
||||||
|
* @param {string} hex - Hex color
|
||||||
|
* @returns {Object} RGB object with r, g, b properties
|
||||||
|
*/
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
hex = hex.replace(/^#/, '');
|
||||||
|
if (hex.length !== 6) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
r: parseInt(hex.substring(0, 2), 16),
|
||||||
|
g: parseInt(hex.substring(2, 4), 16),
|
||||||
|
b: parseInt(hex.substring(4, 6), 16)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adjust color brightness
|
||||||
|
* @param {string} hex - Hex color
|
||||||
|
* @param {number} percent - Percentage to adjust (-100 to +100)
|
||||||
|
* @returns {string} Adjusted hex color
|
||||||
|
*/
|
||||||
|
function adjustColor(hex, percent) {
|
||||||
|
// Remove # if present
|
||||||
|
hex = hex.replace(/^#/, '');
|
||||||
|
|
||||||
|
// Parse RGB values
|
||||||
|
var r = parseInt(hex.substring(0, 2), 16);
|
||||||
|
var g = parseInt(hex.substring(2, 4), 16);
|
||||||
|
var b = parseInt(hex.substring(4, 6), 16);
|
||||||
|
|
||||||
|
// Adjust brightness
|
||||||
|
r = Math.min(255, Math.max(0, r + Math.round(r * percent / 100)));
|
||||||
|
g = Math.min(255, Math.max(0, g + Math.round(g * percent / 100)));
|
||||||
|
b = Math.min(255, Math.max(0, b + Math.round(b * percent / 100)));
|
||||||
|
|
||||||
|
// Convert back to hex
|
||||||
|
return '#' + [r, g, b].map(function(x) {
|
||||||
|
var hex = x.toString(16);
|
||||||
|
return hex.length === 1 ? '0' + hex : hex;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update header gradient with new colors
|
||||||
|
*/
|
||||||
|
function updateHeaderGradient(baseColor, lightColor) {
|
||||||
|
var header = document.querySelector('.dashboard-container .dashboard-header');
|
||||||
|
if (header) {
|
||||||
|
header.style.background = 'linear-gradient(135deg, ' + baseColor + ' 0%, ' + lightColor + ' 100%)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load statistics visibility settings from database and apply
|
||||||
|
* Hides/shows the 3 stats cards based on configuration
|
||||||
|
*/
|
||||||
|
function loadStatsVisibility() {
|
||||||
|
ajax.jsonRpc('/web/dataset/call_kw', 'call', {
|
||||||
|
model: 'res.config.settings',
|
||||||
|
method: 'get_stats_visibility',
|
||||||
|
args: [],
|
||||||
|
kwargs: {}
|
||||||
|
}).then(function(visibility) {
|
||||||
|
// Annual Leave card
|
||||||
|
if (!visibility.show_annual_leave) {
|
||||||
|
$('#leave-section').addClass('genius-hidden');
|
||||||
|
} else {
|
||||||
|
$('#leave-section').removeClass('genius-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Salary Slips card
|
||||||
|
if (!visibility.show_salary_slips) {
|
||||||
|
$('#salary-section').addClass('genius-hidden');
|
||||||
|
} else {
|
||||||
|
$('#salary-section').removeClass('genius-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekly Timesheet card
|
||||||
|
if (!visibility.show_timesheet) {
|
||||||
|
$('#timesheet-section').addClass('genius-hidden');
|
||||||
|
} else {
|
||||||
|
$('#timesheet-section').removeClass('genius-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attendance Hours card
|
||||||
|
if (!visibility.show_attendance_hours) {
|
||||||
|
$('#attendance-hours-section').addClass('genius-hidden');
|
||||||
|
} else {
|
||||||
|
$('#attendance-hours-section').removeClass('genius-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attendance Check-in/out Section (right side panel)
|
||||||
|
if (!visibility.show_attendance_section) {
|
||||||
|
// Hide the attendance section
|
||||||
|
$('.dashboard-attendance-section').addClass('genius-hidden');
|
||||||
|
// Expand the charts section to full width (from col-md-10 to col-md-12)
|
||||||
|
$('.dashboard-charts-section').removeClass('col-md-10').addClass('col-md-12');
|
||||||
|
// Add class for extra CSS control
|
||||||
|
$('.dashboard-user-statistics-section').addClass('attendance-hidden');
|
||||||
|
} else {
|
||||||
|
// Show attendance section
|
||||||
|
$('.dashboard-attendance-section').removeClass('genius-hidden');
|
||||||
|
// Restore charts section width
|
||||||
|
$('.dashboard-charts-section').removeClass('col-md-12').addClass('col-md-10');
|
||||||
|
$('.dashboard-user-statistics-section').removeClass('attendance-hidden');
|
||||||
|
}
|
||||||
|
}).catch(function() {
|
||||||
|
// Silently handle visibility settings error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init: initGeniusEnhancements };
|
||||||
|
});
|
||||||
|
|
@ -1224,7 +1224,7 @@ __webpack_require__.r(__webpack_exports__);
|
||||||
|
|
||||||
var initTooltip = (bindedElement, data, i) => {
|
var initTooltip = (bindedElement, data, i) => {
|
||||||
var div = bindedElement.append("g").attr("class", "pc-tooltip").html(function () {
|
var div = bindedElement.append("g").attr("class", "pc-tooltip").html(function () {
|
||||||
var tooltipContent = "" + data[i].label + "," + data[i].value;
|
var tooltipContent = "" + data[i].label;
|
||||||
return tooltipContent;
|
return tooltipContent;
|
||||||
});
|
});
|
||||||
positionTooltip(bindedElement);
|
positionTooltip(bindedElement);
|
||||||
|
|
|
||||||
|
|
@ -1,609 +0,0 @@
|
||||||
odoo.define('system_dashboard_classic.dashboard', function(require) {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
var core = require('web.core');
|
|
||||||
var session = require('web.session');
|
|
||||||
var ajax = require('web.ajax');
|
|
||||||
var ActionManager = require('web.ActionManager');
|
|
||||||
var view_registry = require('web.view_registry');
|
|
||||||
var Widget = require('web.Widget');
|
|
||||||
var AbstractAction = require('web.AbstractAction');
|
|
||||||
|
|
||||||
var QWeb = core.qweb;
|
|
||||||
|
|
||||||
var _t = core._t;
|
|
||||||
var _lt = core._lt;
|
|
||||||
var functions = [];
|
|
||||||
window.click_actions = [];
|
|
||||||
|
|
||||||
var SystemDashboardView = AbstractAction.extend({
|
|
||||||
template: 'system_dashboard_classic.dashboard',
|
|
||||||
init: function(parent, context) {
|
|
||||||
this._super(parent, context);
|
|
||||||
var data = [];
|
|
||||||
var self = this;
|
|
||||||
window.selfOb = this;
|
|
||||||
if (context.tag == 'system_dashboard_classic.dashboard') {
|
|
||||||
self._rpc({
|
|
||||||
model: 'system_dashboard_classic.dashboard',
|
|
||||||
method: 'get_data',
|
|
||||||
}, []).then(function(result) {
|
|
||||||
console.log(result)
|
|
||||||
window.resultX = result;
|
|
||||||
if (result.employee !== undefined && result.employee !== '' && result.employee[0].length !== 0) {
|
|
||||||
$('#main-cards').css('display', 'flex');
|
|
||||||
$('.fn-id,.fn-department,.fn-job').show();
|
|
||||||
// SET EMPLOYEE DATA
|
|
||||||
if (result.employee[0][0] !== undefined) {
|
|
||||||
$(".img-box").css('background-image', 'url(data:image/png;base64,' + result.employee[0][0].image_128 + ')')
|
|
||||||
$("p.fn-section").html(result.employee[0][0].name);
|
|
||||||
$("p.fn-id").html(result.employee[0][0].emp_no);
|
|
||||||
if (result.employee[0][0].job_id.length !== 0) {
|
|
||||||
$("p.fn-job").html(result.employee[0][0].job_id[1]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$(".img-box").css('background-image', 'url(data:image/png;base64,' + result.user[0][0].image_128 + ')')
|
|
||||||
$("p.fn-section").html(result.user[0][0].display_name);
|
|
||||||
$("p.fn-id").html(result.user[0][0].id);
|
|
||||||
}
|
|
||||||
// SET MAIN CARD DATA (LEAVES - TIMESHEET - PAYSLIPS)
|
|
||||||
$(".leaves_count span.value").html(parseFloat(result['employee'][0][0].remaining_leaves).toFixed(2));
|
|
||||||
$(".timesheet_count span.value").html(result['employee'][0][0].timesheet_count);
|
|
||||||
$(".paylips_count span.value").html(result['employee'][0][0].payslip_count);
|
|
||||||
// LEAVES DO ACTION
|
|
||||||
$("#leaves_btn").on('click', function(event) {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
return self.do_action({
|
|
||||||
name: _t("Leaves"),
|
|
||||||
type: 'ir.actions.act_window',
|
|
||||||
res_model: 'hr.holidays',
|
|
||||||
src_model: 'hr.employee',
|
|
||||||
view_mode: 'tree,form',
|
|
||||||
view_type: 'form',
|
|
||||||
views: [
|
|
||||||
[false, 'list'],
|
|
||||||
[false, 'form']
|
|
||||||
],
|
|
||||||
context: {
|
|
||||||
'search_default_employee_id': [result['employee'][0][0].id],
|
|
||||||
'default_employee_id': result['employee'][0][0].id,
|
|
||||||
},
|
|
||||||
domain: [
|
|
||||||
['holiday_type', '=', 'employee'],
|
|
||||||
['holiday_status_id.limit', '=', false],
|
|
||||||
['state', '!=', 'refuse']
|
|
||||||
],
|
|
||||||
target: 'main',
|
|
||||||
flags:{
|
|
||||||
reload: true,
|
|
||||||
}
|
|
||||||
}, { on_reverse_breadcrumb: self.on_reverse_breadcrumb })
|
|
||||||
});
|
|
||||||
|
|
||||||
// TIMESHEET DO ACTION
|
|
||||||
$("#timesheet_btn").on('click', function(event) {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
return self.do_action({
|
|
||||||
name: _t("Timesheets"),
|
|
||||||
type: 'ir.actions.act_window',
|
|
||||||
res_model: 'account.analytic.line',
|
|
||||||
view_mode: 'tree,form',
|
|
||||||
view_type: 'form',
|
|
||||||
views: [
|
|
||||||
[false, 'list'],
|
|
||||||
[false, 'form']
|
|
||||||
],
|
|
||||||
context: {
|
|
||||||
'search_default_employee_id': [result['employee'][0][0].id],
|
|
||||||
},
|
|
||||||
domain: [
|
|
||||||
['user_id', '=', result['user'][0]]
|
|
||||||
],
|
|
||||||
target: 'main',
|
|
||||||
flags:{
|
|
||||||
reload: true,
|
|
||||||
}
|
|
||||||
}, { on_reverse_breadcrumb: self.on_reverse_breadcrumb })
|
|
||||||
});
|
|
||||||
|
|
||||||
// PAYSLIPS DO ACTION
|
|
||||||
$("#paylips_btn").on('click', function(event) {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
return self.do_action({
|
|
||||||
name: _t("Payslips"),
|
|
||||||
type: 'ir.actions.act_window',
|
|
||||||
res_model: 'hr.payslip',
|
|
||||||
view_mode: 'tree,form',
|
|
||||||
view_type: 'form',
|
|
||||||
views: [
|
|
||||||
[false, 'list'],
|
|
||||||
[false, 'form']
|
|
||||||
],
|
|
||||||
context: {
|
|
||||||
'search_default_employee_id': [result['employee'][0][0].id],
|
|
||||||
'default_employee_id': result['employee'][0][0].id,
|
|
||||||
},
|
|
||||||
target: 'main',
|
|
||||||
flags:{
|
|
||||||
reload: true,
|
|
||||||
}
|
|
||||||
}, { on_reverse_breadcrumb: self.on_reverse_breadcrumb })
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (result.user !== undefined && result.user !== '' && result.user[0].length !== 0) {
|
|
||||||
// SET EMPLOYEE DATA
|
|
||||||
$(".img-box").css('background-image', 'url(data:image/png;base64,' + result.user[0][0].image_128 + ')')
|
|
||||||
$("p.fn-section").html(result.user[0][0].name);
|
|
||||||
$("p.fn-id").html('ID: ' + result.user[0][0].id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamic Cards (DO ACTIONS)
|
|
||||||
if (result.cards !== undefined && result.cards.length > 0) {
|
|
||||||
$.each(result.cards, function(index) {
|
|
||||||
if (result.cards[index].type == "approve") {
|
|
||||||
window.card_data = result.cards[index];
|
|
||||||
var card_title = (card_data.name_english.replace(/\s/g, '').toLocaleLowerCase());
|
|
||||||
var btn = 'click button#' + (card_title + index);
|
|
||||||
// BUILD APPROVE CARD
|
|
||||||
window.card = buildCard(result.cards[index], index, 'table1');
|
|
||||||
$(card).find('.card-header h4 span').html(_t(result.cards[index].name));
|
|
||||||
// BUILD FOLLOW CARD
|
|
||||||
window.card2 = buildCard(result.cards[index], index, 'table2');
|
|
||||||
$(card2).find('.card-header h4 span').html(_t(result.cards[index].name));
|
|
||||||
// APPENDING APPROVE CARD TO APPROVE TAB/SECTION
|
|
||||||
self.$el.find('div.card-section1').append(card);
|
|
||||||
// APPENDING FOLLOW CARD TO FOLLOW TAB/SECTION
|
|
||||||
self.$el.find('div#details .card-section').append(card2);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// TRIGGERING BUTTONS IN THE APPROVE & FOLLOW CARD TO SHOW DETAILS
|
|
||||||
self.$el.find('tr[data-target="record-button"]').on('click', function(event) {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
// GET DATA OF BUTTON TO HANDEL IT
|
|
||||||
var model = $(this).attr('data-model');
|
|
||||||
var name = $(this).attr('data-name');
|
|
||||||
var domain = $(this).attr('data-domain');
|
|
||||||
var form_view = parseInt($(this).attr('form-view'));
|
|
||||||
var list_view = parseInt($(this).attr('list-view'));
|
|
||||||
var type = $(this).attr('data-type');
|
|
||||||
|
|
||||||
// CHECK DOMAIN
|
|
||||||
if (domain === undefined || domain === '') {
|
|
||||||
domain = false;
|
|
||||||
} else {
|
|
||||||
domain = JSON.parse(domain);
|
|
||||||
}
|
|
||||||
// CHECK FORM VIEW VALUE
|
|
||||||
if (isNaN(form_view)) {
|
|
||||||
form_view = false;
|
|
||||||
}
|
|
||||||
// CHECK LIST VIEW VALUE
|
|
||||||
if (isNaN(list_view)) {
|
|
||||||
list_view = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
//FIRING DO ACTION
|
|
||||||
return self.do_action({
|
|
||||||
name: _t(name),
|
|
||||||
type: 'ir.actions.act_window',
|
|
||||||
res_model: model,
|
|
||||||
view_mode: 'tree,form',
|
|
||||||
view_type: 'list',
|
|
||||||
views: [
|
|
||||||
[list_view, 'list'],
|
|
||||||
[form_view, 'form']
|
|
||||||
],
|
|
||||||
domain: domain,
|
|
||||||
target: 'main',
|
|
||||||
flags:{
|
|
||||||
reload: true,
|
|
||||||
}
|
|
||||||
}, { on_reverse_breadcrumb: function() { return self.reload(); } })
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
// Chart Settings
|
|
||||||
setTimeout(function() {
|
|
||||||
window.check = false;
|
|
||||||
//result.attendance[0].is_attendance = true;
|
|
||||||
var name = "";
|
|
||||||
if (result.employee[0][0] !== undefined) {
|
|
||||||
name = result.employee[0][0].name;
|
|
||||||
} else {
|
|
||||||
name = result.user[0][0].display_name;
|
|
||||||
}
|
|
||||||
if (result.attendance !== undefined) {
|
|
||||||
setupAttendanceArea(result.attendance[0], name);
|
|
||||||
// check button submission
|
|
||||||
$('button#check_button').on('click', function(event) {
|
|
||||||
//var check = result.attendance[0].is_attendance; //false or true
|
|
||||||
self._rpc({
|
|
||||||
model: 'system_dashboard_classic.dashboard',
|
|
||||||
method: 'checkin_checkout',
|
|
||||||
context: { 'check': check },
|
|
||||||
}, []).then(function(result) {
|
|
||||||
$('.last-checkin-section').html('');
|
|
||||||
$('.attendance-img-section').html('');
|
|
||||||
$('#check_button').html('');
|
|
||||||
setupAttendanceArea(result, resultX.employee[0][0].name);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
$('.attendance-section-body').hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Charts Labels
|
|
||||||
if ($('body').hasClass('o_rtl') === true) {
|
|
||||||
var total_leaves_title = "الرصيد الكلي ";
|
|
||||||
var remaining_leaves_title = "رصيد الأيام المتبقية ";
|
|
||||||
var taken_leaves_title = "رصيد الأيام المستنفذة ";
|
|
||||||
|
|
||||||
var total_payroll_title = "إجمالي القسائم السنوية";
|
|
||||||
var remaining_payroll_title = "قسائم الراتب المتبقية ";
|
|
||||||
var taken_payroll_title = "القسائم المستنفذة ";
|
|
||||||
|
|
||||||
var total_timesheet_title = "إجمالي ساعات الأسبوع ";
|
|
||||||
var remaining_timesheet_title = "الساعات المتبقية ";
|
|
||||||
var taken_timesheet_title = "الساعات المنجزة ";
|
|
||||||
} else {
|
|
||||||
var total_leaves_title = "Total Balance ";
|
|
||||||
var remaining_leaves_title = "Left Balance ";
|
|
||||||
var taken_leaves_title = "Total remaining days ";
|
|
||||||
|
|
||||||
var total_payroll_title = "Total annual slips";
|
|
||||||
var remaining_payroll_title = "Remaining salary slips ";
|
|
||||||
var taken_payroll_title = "Left salary slips ";
|
|
||||||
|
|
||||||
var total_timesheet_title = "Total hours of week";
|
|
||||||
var remaining_timesheet_title = "Remaining hours ";
|
|
||||||
var taken_timesheet_title = "Left hours ";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.leaves !== undefined) {
|
|
||||||
if (result.leaves[0].taken > 0 || result.leaves[0].remaining_leaves > 0) {
|
|
||||||
var total_leaves = (result.leaves[0].taken + result.leaves[0].remaining_leaves);
|
|
||||||
var remaining_leaves_percent = ((result.leaves[0].remaining_leaves / total_leaves) * 100).toFixed(2);
|
|
||||||
var taken_leaves_percent = ((result.leaves[0].taken / total_leaves) * 100).toFixed(2);
|
|
||||||
//set data
|
|
||||||
$('.leave-total-amount').html(total_leaves_title + " " + Math.round(parseFloat(result.leaves[0].taken + result.leaves[0].remaining_leaves)));
|
|
||||||
$('.leave-left-amount').html(remaining_leaves_title + " " + Math.round(parseFloat(result.leaves[0].remaining_leaves)));
|
|
||||||
if ($('body').hasClass('o_rtl') === true) {
|
|
||||||
$('.leave-data-percent').html('%' + taken_leaves_percent);
|
|
||||||
} else {
|
|
||||||
$('.leave-data-percent').html(taken_leaves_percent + '%');
|
|
||||||
}
|
|
||||||
$('#chartContainer').html('');
|
|
||||||
pluscharts.draw({
|
|
||||||
drawOn: "#chartContainer",
|
|
||||||
type: "donut",
|
|
||||||
dataset: {
|
|
||||||
data: [{
|
|
||||||
label: remaining_leaves_title,
|
|
||||||
value: ((result.leaves[0].taken / total_leaves) * 100).toFixed(2)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: taken_leaves_title,
|
|
||||||
value: ((result.leaves[0].remaining_leaves / total_leaves) * 100).toFixed(2)
|
|
||||||
}
|
|
||||||
],
|
|
||||||
backgroundColor: ["#003056", "#2ead97"],
|
|
||||||
borderColor: "#ffffff",
|
|
||||||
borderWidth: 0,
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
width: 15,
|
|
||||||
text: {
|
|
||||||
display: false,
|
|
||||||
color: "#f6f6f6"
|
|
||||||
},
|
|
||||||
legends: {
|
|
||||||
display: false,
|
|
||||||
width: 20,
|
|
||||||
height: 20
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
width: '140', //give 'container' if you want width and height of initiated container
|
|
||||||
height: '140'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// display section
|
|
||||||
$('#leave-section').fadeIn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.payroll !== undefined) {
|
|
||||||
if (result.payroll[0].taken > 0 || result.payroll[0].payslip_remaining > 0) {
|
|
||||||
// Configure Salary Slips Chart
|
|
||||||
var total_payroll = (result.payroll[0].taken + result.payroll[0].payslip_remaining);
|
|
||||||
var remaining_payroll_percent = ((result.payroll[0].payslip_remaining / total_payroll) * 100).toFixed(2);
|
|
||||||
var taken_payroll_percent = ((result.payroll[0].taken / total_payroll) * 100).toFixed(2);
|
|
||||||
$('.payroll-total-amount').html(total_payroll_title + " " + parseFloat(total_payroll));
|
|
||||||
$('.payroll-left-amount').html(remaining_payroll_title + " " + parseFloat(result.payroll[0].payslip_remaining));
|
|
||||||
if ($('body').hasClass('o_rtl') === true) {
|
|
||||||
$('.payroll-data-percent').html('%' + taken_payroll_percent);
|
|
||||||
} else {
|
|
||||||
$('.payroll-data-percent').html(taken_payroll_percent + '%');
|
|
||||||
}
|
|
||||||
$('#chartPaylips').html('');
|
|
||||||
pluscharts.draw({
|
|
||||||
drawOn: "#chartPaylips",
|
|
||||||
type: "donut",
|
|
||||||
dataset: {
|
|
||||||
data: [{
|
|
||||||
label: remaining_payroll_title,
|
|
||||||
value: ((result.payroll[0].payslip_remaining / total_payroll) * 100).toFixed(2)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: taken_payroll_title,
|
|
||||||
value: ((result.payroll[0].taken / total_payroll) * 100).toFixed(2)
|
|
||||||
}
|
|
||||||
],
|
|
||||||
backgroundColor: ["#003056", "#2ead97"],
|
|
||||||
borderColor: "#ffffff",
|
|
||||||
borderWidth: 0,
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
width: 15,
|
|
||||||
text: {
|
|
||||||
display: false,
|
|
||||||
color: "#f6f6f6"
|
|
||||||
},
|
|
||||||
legends: {
|
|
||||||
display: false,
|
|
||||||
width: 20,
|
|
||||||
height: 20
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
width: '140', //give 'container' if you want width and height of initiated container
|
|
||||||
height: '140'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// display section
|
|
||||||
$('#salary-section').fadeIn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.timesheet !== undefined) {
|
|
||||||
if (result.timesheet[0].taken > 0 || result.timesheet[0].timesheet_remaining > 0) {
|
|
||||||
// Configure Weekly Timesheet Chart
|
|
||||||
var total_timesheet = (result.timesheet[0].taken + result.timesheet[0].timesheet_remaining);
|
|
||||||
var remaining_timesheet_percent = ((result.timesheet[0].timesheet_remaining / total_timesheet) * 100).toFixed(2);
|
|
||||||
var taken_timesheet_percent = ((result.timesheet[0].taken / total_timesheet) * 100).toFixed(2);
|
|
||||||
$('.timesheet-total-amount').html(total_timesheet_title + " " + parseFloat(total_timesheet));
|
|
||||||
$('.timesheet-left-amount').html(remaining_timesheet_title + " " + parseFloat(result.timesheet[0].timesheet_remaining));
|
|
||||||
$('.timesheet-data-percent').html(taken_timesheet_percent + '%');
|
|
||||||
if ($('body').hasClass('o_rtl') === true) {
|
|
||||||
$('.timesheet-data-percent').html('%' + taken_timesheet_percent);
|
|
||||||
} else {
|
|
||||||
$('.timesheet-data-percent').html(taken_timesheet_percent + '%');
|
|
||||||
}
|
|
||||||
$('#chartTimesheet').html('');
|
|
||||||
pluscharts.draw({
|
|
||||||
drawOn: "#chartTimesheet",
|
|
||||||
type: "donut",
|
|
||||||
dataset: {
|
|
||||||
data: [{
|
|
||||||
label: taken_timesheet_title,
|
|
||||||
value: ((result.timesheet[0].taken / total_timesheet) * 100).toFixed(2)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: remaining_timesheet_title,
|
|
||||||
value: ((result.timesheet[0].timesheet_remaining / total_timesheet)).toFixed(2)
|
|
||||||
}
|
|
||||||
],
|
|
||||||
backgroundColor: ["#003056", "#2ead97"],
|
|
||||||
borderColor: "#ffffff",
|
|
||||||
borderWidth: 0,
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
width: 15,
|
|
||||||
text: {
|
|
||||||
display: false,
|
|
||||||
color: "#f6f6f6"
|
|
||||||
},
|
|
||||||
legends: {
|
|
||||||
display: false,
|
|
||||||
width: 20,
|
|
||||||
height: 20
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
width: '140', //give 'container' if you want width and height of initiated container
|
|
||||||
height: '140'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// display section
|
|
||||||
$('#timesheet-section').fadeIn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// hide charts loader
|
|
||||||
$('.charts-over-layer').fadeOut();
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
})
|
|
||||||
// .done(function() {
|
|
||||||
// self.render();
|
|
||||||
// self.href = window.location.href;
|
|
||||||
// });
|
|
||||||
}
|
|
||||||
var attrs = [{
|
|
||||||
value: 50,
|
|
||||||
label: 'Total Amount 30',
|
|
||||||
color: '#003056'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 50,
|
|
||||||
label: 'Left Amount',
|
|
||||||
color: '#2ead97'
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
function explodePie(e) {
|
|
||||||
if (typeof(e.dataSeries.dataPoints[e.dataPointIndex].exploded) === "undefined" || !e.dataSeries.dataPoints[e.dataPointIndex].exploded) {
|
|
||||||
e.dataSeries.dataPoints[e.dataPointIndex].exploded = true;
|
|
||||||
} else {
|
|
||||||
e.dataSeries.dataPoints[e.dataPointIndex].exploded = false;
|
|
||||||
}
|
|
||||||
e.chart.render();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
willStart: function() {
|
|
||||||
return $.when(ajax.loadLibs(this), this._super());
|
|
||||||
},
|
|
||||||
start: function() {
|
|
||||||
var self = this;
|
|
||||||
return this._super();
|
|
||||||
},
|
|
||||||
render: function() {
|
|
||||||
var super_render = this._super;
|
|
||||||
$(".o_control_panel").addClass("o_hidden");
|
|
||||||
var self = this;
|
|
||||||
},
|
|
||||||
reload: function() {
|
|
||||||
window.location.href = this.href;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
core.action_registry.add('system_dashboard_classic.dashboard', SystemDashboardView);
|
|
||||||
return SystemDashboardView
|
|
||||||
});
|
|
||||||
|
|
||||||
window.text = [{
|
|
||||||
'btn': {
|
|
||||||
'ar': 'مشاهدة التفاصيل <i class="mdi mdi-chevron-double-left"></i>',
|
|
||||||
'en': 'Show Details <i class="mdi mdi-chevron-double-right"></i>'
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
|
|
||||||
/*
|
|
||||||
NAME: buildCard
|
|
||||||
DESC: Using to create (APPROVE/FOLLOW) card base on sent data and it return DOM OBJECT
|
|
||||||
RETURN: DOM OBJECT
|
|
||||||
*/
|
|
||||||
function buildCard(data, index, card_type) {
|
|
||||||
var card = document.createElement('div');
|
|
||||||
var card_container = document.createElement('div');
|
|
||||||
var card_header = document.createElement('div');
|
|
||||||
var card_body = document.createElement('div');
|
|
||||||
|
|
||||||
$(card).addClass('col-md-3 col-sm-6 col-xs-12 card2');
|
|
||||||
$(card_container).addClass('col-md-12 col-sm-12 col-xs-12 card-container');
|
|
||||||
|
|
||||||
//card header
|
|
||||||
var h4 = document.createElement('h4');
|
|
||||||
$(card_header).addClass('col-md-12 col-sm-12 col-xs-12 card-header');
|
|
||||||
$(h4).html('<img src="data:image/png;base64,' + data.image + '"/> <span>' + data.name + '</span>').attr('title', data.name);
|
|
||||||
$(card_header).append(h4);
|
|
||||||
$(card_container).append(card_header);
|
|
||||||
|
|
||||||
// card body
|
|
||||||
$(card_body).addClass('col-md-12 col-sm-12 col-xs-12 card-body');
|
|
||||||
if (card_type == "table1") {
|
|
||||||
var table = buildTableApprove(data);
|
|
||||||
} else if (card_type == "table2") {
|
|
||||||
var table = buildTableFollow(data);
|
|
||||||
}
|
|
||||||
$(card_body).append(table);
|
|
||||||
$(card_container).append(card_body);
|
|
||||||
|
|
||||||
// card
|
|
||||||
$(card).append(card_container);
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
NAME: buildTableApprove
|
|
||||||
DESC: Using to create (APPROVE) rows/buttons base on sent data and it return DOM OBJECT
|
|
||||||
RETURN: DOM OBJECT
|
|
||||||
*/
|
|
||||||
function buildTableApprove(data) {
|
|
||||||
var table = document.createElement('table');
|
|
||||||
var tbody = document.createElement('tbody');
|
|
||||||
$(table).addClass('table');
|
|
||||||
if (data.lines.length !== undefined && data.lines.length > 0) {
|
|
||||||
$.each(data.lines, function(index) {
|
|
||||||
var tr = document.createElement('tr');
|
|
||||||
var td1 = document.createElement('td');
|
|
||||||
var td2 = document.createElement('td');
|
|
||||||
$(td1).html(data.lines[index].state_approval);
|
|
||||||
$(td2).html('<div>' + data.lines[index].count_state_click + '</div>');
|
|
||||||
$(tr).attr('data-target', 'record-button').attr('data-model', data.model).attr('data-name', data.name).
|
|
||||||
attr('data-domain', JSON.stringify(data.lines[index].domain_click)).attr('form-view', data.lines[index].form_view).
|
|
||||||
attr('list-view', data.lines[index].list_view).attr('data-action', JSON.stringify(data.lines[index].action_domain));
|
|
||||||
$(tr).append(td1).append(td2);
|
|
||||||
$(tbody).append(tr);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
$(table).append(tbody);
|
|
||||||
return table;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
NAME: buildTableFollow
|
|
||||||
DESC: Using to create (FOLLOW) rows/buttons base on sent data and it return DOM OBJECT
|
|
||||||
RETURN: DOM OBJECT
|
|
||||||
*/
|
|
||||||
function buildTableFollow(data) {
|
|
||||||
var table = document.createElement('table');
|
|
||||||
var tbody = document.createElement('tbody');
|
|
||||||
$(table).addClass('table');
|
|
||||||
if (data.lines.length !== undefined && data.lines.length > 0) {
|
|
||||||
$.each(data.lines, function(index) {
|
|
||||||
var tr = document.createElement('tr');
|
|
||||||
var td1 = document.createElement('td');
|
|
||||||
var td2 = document.createElement('td');
|
|
||||||
$(td1).html(data.lines[index].state_folow);
|
|
||||||
$(td2).html('<div>' + data.lines[index].count_state_follow + '</div>');
|
|
||||||
$(tr).attr('data-target', 'record-button').attr('data-type', 'follow').attr('data-model', data.model).attr('data-name', data.name).
|
|
||||||
attr('data-domain', JSON.stringify(data.lines[index].domain_follow)).attr('form-view', data.lines[index].form_view).
|
|
||||||
attr('list-view', data.lines[index].list_view)
|
|
||||||
$(tr).append(td1).append(td2);
|
|
||||||
// to append
|
|
||||||
//$(tbody).append(tr);
|
|
||||||
// to override
|
|
||||||
$(tbody).html(tr)
|
|
||||||
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// to append
|
|
||||||
$(table).append(tbody);
|
|
||||||
return table;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupAttendanceArea(data, name) {
|
|
||||||
window.check = data.is_attendance;
|
|
||||||
var button = '';
|
|
||||||
if ($('body').hasClass('o_rtl')) {
|
|
||||||
var checkout_title = "زمن تسجيل اخر دخول"
|
|
||||||
var checkin_title = ",مرحباً بك"
|
|
||||||
var checkin_button = "تسجيل الحضور";
|
|
||||||
var checkout_button = "تسجيل الخروج";
|
|
||||||
} else {
|
|
||||||
var checkout_title = "Last check in"
|
|
||||||
var checkin_title = "Welcome";
|
|
||||||
var checkin_button = "Check In"
|
|
||||||
var checkout_button = "Check Out";
|
|
||||||
}
|
|
||||||
if (data.is_attendance === true) {
|
|
||||||
var check = checkout_title + " " + data.time;
|
|
||||||
var image = '<img class="img-logout" src="/system_dashboard_classic/static/src/icons/logout.svg"/>';
|
|
||||||
$('#check_button').html(checkout_button).removeClass('checkin-btn').addClass('checkout-btn');
|
|
||||||
$('.last-checkin-section').html(check);
|
|
||||||
$('.attendance-img-section').html(image);
|
|
||||||
} else {
|
|
||||||
var check = checkin_title + " " + name + ",";
|
|
||||||
var image = '<img class="img-login" src="/system_dashboard_classic/static/src/icons/logout.svg"/>';
|
|
||||||
$('#check_button').html(checkin_button).removeClass('checkout-btn').addClass('checkin-btn');
|
|
||||||
$('.last-checkin-section').html(check);
|
|
||||||
$('.attendance-img-section').html(image);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,151 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
(function(global) {
|
|
||||||
var Samples = global.Samples || (global.Samples = {});
|
|
||||||
var Color = global.Color;
|
|
||||||
|
|
||||||
function fallback(/* values ... */) {
|
|
||||||
var ilen = arguments.length;
|
|
||||||
var i = 0;
|
|
||||||
var v;
|
|
||||||
|
|
||||||
for (; i < ilen; ++i) {
|
|
||||||
v = arguments[i];
|
|
||||||
if (v !== undefined) {
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Samples.COLORS = [
|
|
||||||
'#FF3784',
|
|
||||||
'#36A2EB',
|
|
||||||
'#4BC0C0',
|
|
||||||
'#F77825',
|
|
||||||
'#9966FF',
|
|
||||||
'#00A8C6',
|
|
||||||
'#379F7A',
|
|
||||||
'#CC2738',
|
|
||||||
'#8B628A',
|
|
||||||
'#8FBE00',
|
|
||||||
'#606060'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Adapted from http://indiegamr.com/generate-repeatable-random-numbers-in-js/
|
|
||||||
Samples.srand = function(seed) {
|
|
||||||
this._seed = seed;
|
|
||||||
};
|
|
||||||
|
|
||||||
Samples.rand = function(min, max) {
|
|
||||||
var seed = this._seed;
|
|
||||||
min = min === undefined ? 0 : min;
|
|
||||||
max = max === undefined ? 1 : max;
|
|
||||||
this._seed = (seed * 9301 + 49297) % 233280;
|
|
||||||
return min + (this._seed / 233280) * (max - min);
|
|
||||||
};
|
|
||||||
|
|
||||||
Samples.numbers = function(config) {
|
|
||||||
var cfg = config || {};
|
|
||||||
var min = fallback(cfg.min, 0);
|
|
||||||
var max = fallback(cfg.max, 1);
|
|
||||||
var from = fallback(cfg.from, []);
|
|
||||||
var count = fallback(cfg.count, 8);
|
|
||||||
var decimals = fallback(cfg.decimals, 8);
|
|
||||||
var continuity = fallback(cfg.continuity, 1);
|
|
||||||
var dfactor = Math.pow(10, decimals) || 0;
|
|
||||||
var data = [];
|
|
||||||
var i, value;
|
|
||||||
|
|
||||||
for (i = 0; i < count; ++i) {
|
|
||||||
value = (from[i] || 0) + this.rand(min, max);
|
|
||||||
if (this.rand() <= continuity) {
|
|
||||||
data.push(Math.round(dfactor * value) / dfactor);
|
|
||||||
} else {
|
|
||||||
data.push(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
Samples.color = function(offset) {
|
|
||||||
var count = Samples.COLORS.length;
|
|
||||||
var index = offset === undefined ? ~~Samples.rand(0, count) : offset;
|
|
||||||
return Samples.COLORS[index % count];
|
|
||||||
};
|
|
||||||
|
|
||||||
Samples.colors = function(config) {
|
|
||||||
var cfg = config || {};
|
|
||||||
var color = cfg.color || Samples.color(0);
|
|
||||||
var count = cfg.count !== undefined ? cfg.count : 8;
|
|
||||||
var method = cfg.mode ? Color.prototype[cfg.mode] : null;
|
|
||||||
var values = [];
|
|
||||||
var i, f, v;
|
|
||||||
|
|
||||||
for (i = 0; i < count; ++i) {
|
|
||||||
f = i / count;
|
|
||||||
|
|
||||||
if (method) {
|
|
||||||
v = method.call(Color(color), f).rgbString();
|
|
||||||
} else {
|
|
||||||
v = Samples.color(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
values.push(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
return values;
|
|
||||||
};
|
|
||||||
|
|
||||||
Samples.transparentize = function(color, opacity) {
|
|
||||||
var alpha = opacity === undefined ? 0.5 : 1 - opacity;
|
|
||||||
return Color(color).alpha(alpha).rgbString();
|
|
||||||
};
|
|
||||||
|
|
||||||
// INITIALIZATION
|
|
||||||
|
|
||||||
Samples.srand(Date.now());
|
|
||||||
|
|
||||||
var root = (function() {
|
|
||||||
var scripts = document.getElementsByTagName('script');
|
|
||||||
var script = scripts[scripts.length - 1];
|
|
||||||
var path = script.src;
|
|
||||||
return path.substr(0, path.lastIndexOf('/') + 1);
|
|
||||||
}());
|
|
||||||
|
|
||||||
window.addEventListener('DOMContentLoaded', function load() {
|
|
||||||
window.removeEventListener('DOMContentLoaded', load, true);
|
|
||||||
var header = document.getElementById('header');
|
|
||||||
var info = global.SAMPLE_INFO;
|
|
||||||
|
|
||||||
if (header && info) {
|
|
||||||
var group = info.group;
|
|
||||||
var name = info.name;
|
|
||||||
var desc = info.desc;
|
|
||||||
|
|
||||||
document.title = name + (group ? ' / ' + group : '') + ' / chartjs-plugin-datalabels';
|
|
||||||
header.innerHTML =
|
|
||||||
'<div class="scope">' +
|
|
||||||
'<a href="' + root + 'index.html">« chartjs-plugin-datalabels</a>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="title">' +
|
|
||||||
'<span class="group">' + group + ' / </span>' +
|
|
||||||
'<span class="name">' + name + '</span>' +
|
|
||||||
(desc ? '<div class="desc">' + desc + '</div>' : '') +
|
|
||||||
'</div>';
|
|
||||||
}
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
// Google Analytics
|
|
||||||
/* eslint-disable */
|
|
||||||
if (['localhost', '127.0.0.1', ''].indexOf(document.location.hostname) === -1) {
|
|
||||||
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
|
|
||||||
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
|
|
||||||
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
|
|
||||||
})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
|
|
||||||
ga('create', 'UA-99068522-2', 'auto');
|
|
||||||
ga('send', 'pageview');
|
|
||||||
}
|
|
||||||
/* eslint-enable */
|
|
||||||
|
|
||||||
}(this));
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -113,53 +113,129 @@
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*CARD (2)*/
|
/*CARD (2) - Approval Cards - Enhanced */
|
||||||
.card2 {
|
.card2 {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
.card-container {
|
.card-container {
|
||||||
/* border: 1px solid $bg_card_button; */
|
border: none;
|
||||||
border: 1px solid #003056;
|
border-radius: var(--dashboard-border-radius, 16px);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--dashboard-shadow-md, 0 4px 20px rgba(0, 0, 0, 0.08));
|
||||||
|
transition: all var(--dashboard-transition-normal, 0.3s ease);
|
||||||
|
position: relative;
|
||||||
|
background: #ffffff;
|
||||||
|
|
||||||
|
/* Top accent bar on hover */
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--dashboard-accent, #667eea),
|
||||||
|
var(--dashboard-primary, #2ead97)
|
||||||
|
);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--dashboard-transition-normal, 0.3s ease);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-6px);
|
||||||
|
box-shadow: var(--dashboard-shadow-lg, 0 12px 40px rgba(0, 0, 0, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
background: $bg_card_header;
|
background: linear-gradient(
|
||||||
height: 50px;
|
135deg,
|
||||||
|
var(--dashboard-gradient-start, #0e3e34) 0%,
|
||||||
|
var(--dashboard-gradient-end, #00887e) 100%
|
||||||
|
);
|
||||||
|
height: 56px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
padding-left: 10px!important;
|
padding: 0 16px !important;
|
||||||
padding-right: 10px!important;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
/* Decorative glow */
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
right: -20%;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(
|
||||||
|
circle,
|
||||||
|
rgba(255, 255, 255, 0.12) 0%,
|
||||||
|
transparent 70%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
height: 34px;
|
height: 38px;
|
||||||
width: 34px;
|
width: 38px;
|
||||||
margin-right: 10px;
|
margin-right: 12px;
|
||||||
|
padding: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: transform var(--dashboard-transition-normal, 0.3s ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover img {
|
||||||
|
transform: scale(1.1) rotate(-5deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
line-height: 30px;
|
line-height: 1.3;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-weight: bold;
|
font-weight: 600;
|
||||||
padding: 0!important;
|
padding: 0 !important;
|
||||||
margin-top: 8px;
|
margin: 0;
|
||||||
margin-bottom: 8px;
|
font-size: 15px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
i {
|
i {
|
||||||
font-size: 24px;
|
font-size: 22px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.red {
|
&.red {
|
||||||
background: #ee0c21;
|
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.blue {
|
&.blue {
|
||||||
background: #2bb0ee;
|
background: linear-gradient(135deg, #2193b0 0%, #6dd5ed 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.green {
|
&.green {
|
||||||
background: #08bf17;
|
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-body {
|
.card-body {
|
||||||
|
|
@ -168,22 +244,48 @@
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--dashboard-primary, #2ead97);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--dashboard-primary-dark, #084e41);
|
||||||
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
tr {
|
tr {
|
||||||
border-top: none;
|
border-top: none;
|
||||||
border-bottom: 1px solid #003056;
|
border-bottom: 1px solid rgba(0, 48, 86, 0.08);
|
||||||
/* border-bottom: 1px solid $bg_card_button; */
|
transition: all var(--dashboard-transition-fast, 0.2s ease);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f8f8f8;
|
background: linear-gradient(
|
||||||
cursor: pointer;
|
90deg,
|
||||||
color: #06211a;
|
rgba(46, 173, 150, 0.08),
|
||||||
|
rgba(102, 126, 234, 0.05)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:nth-child(2n) {
|
&:nth-child(2n) {
|
||||||
background: #edf6fd;
|
background: rgba(237, 246, 253, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(2n):hover {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(46, 173, 150, 0.12),
|
||||||
|
rgba(102, 126, 234, 0.08)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
|
|
@ -192,71 +294,146 @@
|
||||||
|
|
||||||
td {
|
td {
|
||||||
border-top: none;
|
border-top: none;
|
||||||
line-height: 26px;
|
line-height: 1.6;
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
padding: 10px;
|
padding: 12px 14px;
|
||||||
|
color: var(--dashboard-text-primary, #2d3748);
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
|
||||||
div {
|
div {
|
||||||
/* background: #003056; */
|
background: linear-gradient(
|
||||||
background: $bg_card_header;
|
135deg,
|
||||||
height: 26px;
|
var(--dashboard-gradient-start, #0e3e34),
|
||||||
|
var(--dashboard-gradient-end, #00887e)
|
||||||
|
);
|
||||||
|
height: 28px;
|
||||||
|
width: 28px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 26px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
line-height: 26px;
|
line-height: 28px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: bold;
|
font-weight: 700;
|
||||||
float: right;
|
float: right;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
transition: all var(--dashboard-transition-normal, 0.3s ease);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 136, 126, 0.3);
|
||||||
}
|
}
|
||||||
i{
|
|
||||||
|
i {
|
||||||
|
transition: color var(--dashboard-transition-fast, 0.2s ease);
|
||||||
&:hover {
|
&:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #06211a;
|
color: var(--dashboard-primary, #2ead97);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tr:hover td:last-child div {
|
||||||
|
transform: scale(1.15);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 136, 126, 0.45);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*CARD (3)*/
|
/*CARD (3) - Self Service Cards - Enhanced */
|
||||||
.card3 {
|
.card3 {
|
||||||
padding: 0 5px;
|
padding: 0 8px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 24px;
|
||||||
|
|
||||||
.card-body {
|
.card-body {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
border-radius: var(--dashboard-border-radius, 16px);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--dashboard-shadow-md, 0 4px 20px rgba(0, 0, 0, 0.08));
|
||||||
|
transition: all var(--dashboard-transition-normal, 0.3s ease);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
/* Top accent bar on hover */
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--dashboard-accent, #667eea),
|
||||||
|
var(--dashboard-primary, #2ead97)
|
||||||
|
);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--dashboard-transition-normal, 0.3s ease);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-8px);
|
||||||
|
box-shadow: var(--dashboard-shadow-lg, 0 12px 40px rgba(0, 0, 0, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.box-1 {
|
.box-1 {
|
||||||
height: 250px;
|
height: 250px;
|
||||||
background: $bg_card;
|
background: linear-gradient(
|
||||||
|
145deg,
|
||||||
|
#ffffff 0%,
|
||||||
|
var(--dashboard-card-bg, #f4fefe) 100%
|
||||||
|
);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
transition: all .4s;
|
transition: all var(--dashboard-transition-normal, 0.3s ease);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 1px solid #00562e;
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
/* Decorative background circle */
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -30%;
|
||||||
|
right: -30%;
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(
|
||||||
|
circle,
|
||||||
|
rgba(46, 173, 150, 0.06) 0%,
|
||||||
|
transparent 70%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: transform var(--dashboard-transition-slow, 0.5s ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::before {
|
||||||
|
transform: scale(1.3);
|
||||||
|
}
|
||||||
|
|
||||||
&.red {
|
&.red {
|
||||||
background: #ee0c21;
|
background: linear-gradient(145deg, #fee2e2, #fecaca);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.blue {
|
&.blue {
|
||||||
background: #2bb0ee;
|
background: linear-gradient(145deg, #dbeafe, #bfdbfe);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.green {
|
&.green {
|
||||||
background: #08bf17;
|
background: linear-gradient(145deg, #dcfce7, #bbf7d0);
|
||||||
}
|
}
|
||||||
|
|
||||||
i {
|
i {
|
||||||
|
|
@ -264,52 +441,107 @@
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
h4 {
|
img {
|
||||||
margin-bottom: 0;
|
height: 70px;
|
||||||
font-size: 20px;
|
width: auto;
|
||||||
text-overflow: ellipsis;
|
margin-bottom: 8px;
|
||||||
overflow: hidden;
|
transition: all var(--dashboard-transition-normal, 0.3s ease);
|
||||||
white-space: nowrap;
|
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.1));
|
||||||
color: $bg_card_button;
|
}
|
||||||
/* color: #003056; */
|
|
||||||
padding-top: 10px!important;
|
&:hover img {
|
||||||
padding-bottom: 0!important;
|
transform: scale(1.15) rotate(-5deg);
|
||||||
|
filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.15));
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
font-size: 48px;
|
margin-top: 8px;
|
||||||
|
font-size: 52px;
|
||||||
|
font-weight: 800;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: #003056;
|
background: linear-gradient(
|
||||||
color: $bg_card_button;
|
135deg,
|
||||||
|
var(--dashboard-secondary, #003056),
|
||||||
|
var(--dashboard-primary, #2ead97)
|
||||||
|
);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
transition: transform var(--dashboard-transition-normal, 0.3s ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
&:hover h3 {
|
||||||
height: 60px;
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--dashboard-text-secondary, #718096);
|
||||||
|
padding-top: 10px !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.box-2 {
|
.box-2 {
|
||||||
background: $bg_card_button;
|
background: linear-gradient(
|
||||||
transition: all .4s;
|
135deg,
|
||||||
|
var(--dashboard-gradient-start, #0e3e34) 0%,
|
||||||
|
var(--dashboard-gradient-end, #00887e) 100%
|
||||||
|
);
|
||||||
|
transition: all var(--dashboard-transition-normal, 0.3s ease);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
line-height: 50px;
|
height: 56px;
|
||||||
|
line-height: 56px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
/* Subtle shine effect */
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.1),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
transition: left 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::before {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
i {
|
i {
|
||||||
font-size: 36px;
|
font-size: 28px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
transition: all .4s;
|
transition: all var(--dashboard-transition-normal, 0.3s ease);
|
||||||
float: right;
|
margin-left: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
float: left;
|
font-size: 15px;
|
||||||
font-size: 14px;
|
font-weight: 600;
|
||||||
color: #eee;
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
|
|
@ -322,13 +554,21 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #0c483e;
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--dashboard-primary-dark, #084e41) 0%,
|
||||||
|
var(--dashboard-gradient-start, #0e3e34) 100%
|
||||||
|
);
|
||||||
|
|
||||||
i {
|
i {
|
||||||
transform: rotate(45deg);
|
transform: rotate(90deg) scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
// CARD (1)
|
|
||||||
.card {
|
|
||||||
margin-top: 20px;
|
|
||||||
.card-body {
|
|
||||||
/*border: 1px solid #eee;*/
|
|
||||||
height: 200px;
|
|
||||||
padding: 0;
|
|
||||||
background: #ffffff;
|
|
||||||
|
|
||||||
.box-1 {
|
|
||||||
height: 100%;
|
|
||||||
background: #ee6414;
|
|
||||||
text-align: center;
|
|
||||||
vertical-align: middle;
|
|
||||||
line-height: 200px;
|
|
||||||
|
|
||||||
i {
|
|
||||||
font-size: 60px;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.red {
|
|
||||||
background: #ee0c21;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.blue {
|
|
||||||
background: #2bb0ee;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.green {
|
|
||||||
background: #08bf17;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.dark-blue {
|
|
||||||
background: #3e5d7f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.box-2 {
|
|
||||||
padding-right: 10px;
|
|
||||||
padding-left: 10px;
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-top: 5px;
|
|
||||||
margin-bottom: 0px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
background: #eeeeee;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.red {
|
|
||||||
background: #ee0c21;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.blue {
|
|
||||||
background: #2bb0ee;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.green {
|
|
||||||
background: #08bf17;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CARD (2)
|
|
||||||
.card2 {
|
|
||||||
margin-top: 20px;
|
|
||||||
|
|
||||||
.card-container {
|
|
||||||
/*border: 1px solid #eee;*/
|
|
||||||
height: 200px;
|
|
||||||
padding: 0;
|
|
||||||
background: #ffffff;
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
height: 50px;
|
|
||||||
background: #ee6414;
|
|
||||||
vertical-align: middle;
|
|
||||||
padding-left: 10px !important;
|
|
||||||
padding-right: 10px !important;
|
|
||||||
h4 {
|
|
||||||
line-height: 30px;
|
|
||||||
color: #ffffff;
|
|
||||||
font-weight: bold;
|
|
||||||
|
|
||||||
i {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.red {
|
|
||||||
background: #ee0c21;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blue {
|
|
||||||
background: #2bb0ee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.green {
|
|
||||||
background: #08bf17;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
padding: 5px 10px;
|
|
||||||
|
|
||||||
table {
|
|
||||||
tr {
|
|
||||||
border-top: none;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
border-top: none;
|
|
||||||
/* padding-left: 0; */
|
|
||||||
padding-right: 0;
|
|
||||||
&:last-child {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
i {
|
|
||||||
&:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
color: #06211a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RTL
|
|
||||||
.o_rtl {
|
|
||||||
.card2 {
|
|
||||||
padding-left: inherit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -95,7 +95,7 @@ $border-color_1: #2eac96;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
p {
|
p {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
h3 {
|
h3 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
|
@ -156,7 +156,6 @@ $border-color_1: #2eac96;
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
>a {
|
>a {
|
||||||
color: $color_nav;
|
color: $color_nav;
|
||||||
border: 1px solid $color_5 !important;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,77 +1,164 @@
|
||||||
$color_1: linear-gradient(270deg, rgb(14, 62, 52) 0%, rgb(0, 136, 126) 75%);
|
/* ============================================================
|
||||||
|
System Dashboard Classic - RTL Styles
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
This file contains RTL (Right-to-Left) overrides for Arabic/Hebrew
|
||||||
|
language support. Uses .o_rtl class added by Odoo.
|
||||||
|
|
||||||
|
IMPORTANT: This file must load AFTER all LTR styles.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
.o_rtl {
|
.o_rtl {
|
||||||
.card {
|
/* === Profile Section RTL === */
|
||||||
.card-body {
|
.profile-container {
|
||||||
.box-2 {
|
.pp-info-section {
|
||||||
.btn-group {
|
border: none;
|
||||||
position: absolute;
|
border-right: 1px solid #9f9f9f;
|
||||||
bottom: -50px;
|
padding: 10px;
|
||||||
left: 10px;
|
}
|
||||||
right: auto;
|
|
||||||
transition: all .4s;
|
/* Green status dot - flip to left side */
|
||||||
|
.pp-image-section::after {
|
||||||
|
right: auto !important;
|
||||||
|
left: 8px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Module Box / Statistics RTL === */
|
||||||
|
.module-box {
|
||||||
|
.module-box-container {
|
||||||
|
.module-body {
|
||||||
|
padding: 10px 15px 10px 10px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
transition: all .3s ease-in-out;
|
||||||
|
color: #8a8a8a;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 5px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #06211a;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-icon {
|
||||||
|
text-align: center;
|
||||||
|
height: 80px;
|
||||||
|
width: 80px;
|
||||||
|
line-height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
right: -20px;
|
||||||
|
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, .2),
|
||||||
|
0 6px 10px 0 rgba(0, 0, 0, .14),
|
||||||
|
0 1px 18px 0 rgba(0, 0, 0, .12) !important;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 45px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-icon.red { background: #ee451f; }
|
||||||
|
.module-icon.green { background: #14bf1f; }
|
||||||
|
.module-icon.yellow { background: #f9d700; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stat labels - reverse flex direction */
|
||||||
|
p span {
|
||||||
|
flex-direction: row-reverse !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.card3 {
|
|
||||||
padding: 0 5px;
|
/* === Card2 (Approval Cards) RTL === */
|
||||||
.card-body {
|
.card2 {
|
||||||
.box-1 {
|
padding-left: inherit;
|
||||||
h4 {
|
padding-right: 0;
|
||||||
margin-bottom: 0;
|
|
||||||
font-size: 16px;
|
.card-container .card-header {
|
||||||
text-overflow: ellipsis;
|
flex-direction: row-reverse !important;
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
img {
|
||||||
color: $color_1;
|
margin-right: 0 !important;
|
||||||
padding-top: 10px !important;
|
margin-left: 12px !important;
|
||||||
padding-bottom: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.box-2 {
|
|
||||||
&:hover {
|
h4 {
|
||||||
.btn-group {
|
text-align: right !important;
|
||||||
bottom: 10px;
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
i {
|
.card-container .card-body table tr td {
|
||||||
|
text-align: right;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
div {
|
||||||
float: left;
|
float: left;
|
||||||
}
|
}
|
||||||
span {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.card2 {
|
|
||||||
.card-container {
|
/* === Card3 (Service Cards) RTL === */
|
||||||
.card-header {
|
.card3 .card-body .box-2 {
|
||||||
img {
|
flex-direction: row-reverse !important;
|
||||||
margin-right: unset;
|
|
||||||
margin-left: 10px;
|
i {
|
||||||
}
|
margin-left: 0 !important;
|
||||||
}
|
margin-right: 8px !important;
|
||||||
.card-body {
|
|
||||||
table {
|
|
||||||
tr {
|
|
||||||
td {
|
|
||||||
&:last-child {
|
|
||||||
div {
|
|
||||||
float: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
padding-left: inherit;
|
|
||||||
}
|
}
|
||||||
.profile-container {
|
|
||||||
padding-right: 0;
|
/* === Tab Navigation RTL === */
|
||||||
|
.dashboard-nav-buttons {
|
||||||
|
flex-direction: row-reverse !important;
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
flex-direction: row-reverse !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.p0 {
|
|
||||||
padding: 0;
|
/* === Search Container RTL === */
|
||||||
|
.genius-search-container {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-right: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genius-search-icon {
|
||||||
|
left: auto !important;
|
||||||
|
right: 14px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genius-search-input {
|
||||||
|
padding: 10px 38px 10px 40px !important;
|
||||||
|
text-align: right !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genius-search-clear {
|
||||||
|
right: auto !important;
|
||||||
|
left: 14px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Attendance Section RTL === */
|
||||||
|
.dashboard-attendance-section {
|
||||||
|
border-left: none !important;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,59 @@
|
||||||
$bg_user_section: linear-gradient(270deg, rgb(14, 62, 52) 0%, rgb(0, 136, 126) 75%) !default;
|
/* ============================================================
|
||||||
$bg_user_statistics: #FFFFFF !default;
|
System Dashboard Classic - Theme Variables
|
||||||
$bg_checkin_btn: #2ead97 !default;
|
============================================================
|
||||||
$bg_checkin_btn_hover: #084e41 !default;
|
|
||||||
$bg_checkout_btn: #2ead97 !default;
|
MINIMAL COLOR SCHEME:
|
||||||
$bg_checkout_btn_hover: #084e41 !default;
|
- Primary: Teal (#0d9488)
|
||||||
$divd_border_color: #2eac96 !default;
|
- Secondary: Dark Blue (#1e293b)
|
||||||
$bg_dashboard_nav: #2ead97 !default;
|
|
||||||
$bg_dashboard_nav_hover: #084e41 !default;
|
To customize, override these CSS variables in your theme:
|
||||||
$color_nav: #0B2E59 !default;
|
:root {
|
||||||
$bg_card: #f4fefe !default;
|
--theme-primary: #YOUR_COLOR;
|
||||||
$bg_card_button: linear-gradient(270deg, #0e3e34 0%, #00887e 75%) !default;
|
--theme-secondary: #YOUR_COLOR;
|
||||||
$bg_card_header: linear-gradient(270deg, #0e3e34 0%, #00887e 75%) !default;
|
}
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* === THEME CONFIGURATION (Edit these to change the entire look) === */
|
||||||
|
:root {
|
||||||
|
/* Primary Colors - Main brand color */
|
||||||
|
--theme-primary: #0d9488;
|
||||||
|
--theme-primary-light: #14b8a6;
|
||||||
|
--theme-primary-dark: #0f766e;
|
||||||
|
|
||||||
|
/* Secondary Colors - For headers and dark elements */
|
||||||
|
--theme-secondary: #1e293b;
|
||||||
|
--theme-secondary-light: #334155;
|
||||||
|
|
||||||
|
/* Neutral Colors */
|
||||||
|
--theme-bg-light: #f8fafc;
|
||||||
|
--theme-bg-white: #ffffff;
|
||||||
|
--theme-border: #e2e8f0;
|
||||||
|
--theme-text: #475569;
|
||||||
|
--theme-text-light: #94a3b8;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--transition: 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === SCSS Variables (Backward Compatibility) === */
|
||||||
|
$bg_user_section: linear-gradient(135deg, #1e293b 0%, #334155 100%) !default;
|
||||||
|
$bg_user_statistics: #FFFFFF !default;
|
||||||
|
$bg_checkin_btn: #0d9488 !default;
|
||||||
|
$bg_checkin_btn_hover: #0f766e !default;
|
||||||
|
$bg_checkout_btn: #0d9488 !default;
|
||||||
|
$bg_checkout_btn_hover: #0f766e !default;
|
||||||
|
$divd_border_color: #0d9488 !default;
|
||||||
|
$bg_dashboard_nav: #0d9488 !default;
|
||||||
|
$bg_dashboard_nav_hover: #0f766e !default;
|
||||||
|
$color_nav: #1e293b !default;
|
||||||
|
$bg_card: #f8fafc !default;
|
||||||
|
$bg_card_button: linear-gradient(135deg, #1e293b 0%, #334155 100%) !default;
|
||||||
|
$bg_card_header: linear-gradient(135deg, #1e293b 0%, #334155 100%) !default;
|
||||||
|
|
||||||
@if variable-exists(sidebar_bg){
|
@if variable-exists(sidebar_bg){
|
||||||
$bg_user_section: $sidebar_bg;
|
$bg_user_section: $sidebar_bg;
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
<div class="lds-roller"><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div></div>
|
<div class="lds-roller"><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-12 col-sm-12 col-12 dashboard-module-charts" style="padding:0;">
|
<div class="col-md-12 col-sm-12 col-12 dashboard-module-charts" style="padding:0;">
|
||||||
<div class="col-md-4 col-sm-6 col-12 module-box" id="leave-section">
|
<div class="col-md-4 col-sm-6 col-12 module-box" id="leave-section" style="display:none;">
|
||||||
<div class="col-md-12 module-box-container">
|
<div class="col-md-12 module-box-container">
|
||||||
<p>
|
<p>
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -35,12 +35,17 @@
|
||||||
<i class="fa fa-circle"/> <span class="leave-left-amount"></span>
|
<i class="fa fa-circle"/> <span class="leave-left-amount"></span>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<div id="chartContainer"></div>
|
<div class="chart-wrapper">
|
||||||
<h4 class="leave-data-percent"></h4>
|
<div id="chartContainer"></div>
|
||||||
|
<div class="chart-center-text">
|
||||||
|
<span class="chart-center-value leave-center-value"></span>
|
||||||
|
<span class="chart-center-unit leave-center-unit"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<h3>Annual Leave</h3>
|
<h3>Annual Leave</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 col-sm-6 col-12 module-box" id="salary-section">
|
<div class="col-md-4 col-sm-6 col-12 module-box" id="salary-section" style="display:none;">
|
||||||
<div class="col-md-12 col-sm-12 col-12 module-box-container">
|
<div class="col-md-12 col-sm-12 col-12 module-box-container">
|
||||||
<p>
|
<p>
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -52,12 +57,17 @@
|
||||||
<i class="fa fa-circle"/> <span class="payroll-left-amount"></span>
|
<i class="fa fa-circle"/> <span class="payroll-left-amount"></span>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<div id="chartPaylips"></div>
|
<div class="chart-wrapper">
|
||||||
<h4 class="payroll-data-percent"></h4>
|
<div id="chartPaylips"></div>
|
||||||
|
<div class="chart-center-text">
|
||||||
|
<span class="chart-center-value payroll-center-value"></span>
|
||||||
|
<span class="chart-center-unit payroll-center-unit"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<h3>Salary Slips</h3>
|
<h3>Salary Slips</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 col-sm-6 col-12 module-box" id="timesheet-section">
|
<div class="col-md-4 col-sm-6 col-12 module-box" id="timesheet-section" style="display:none;">
|
||||||
<div class="col-md-12 col-sm-12 col-12 module-box-container">
|
<div class="col-md-12 col-sm-12 col-12 module-box-container">
|
||||||
<p>
|
<p>
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -66,14 +76,42 @@
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span>
|
<span>
|
||||||
<i class="fa fa-circle"/> <span class="timesheet-total-amount"></span>
|
<i class="fa fa-circle"/> <span class="timesheet-left-amount"></span>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<div id="chartTimesheet"></div>
|
<div class="chart-wrapper">
|
||||||
<h4 class="timesheet-data-percent"></h4>
|
<div id="chartTimesheet"></div>
|
||||||
|
<div class="chart-center-text">
|
||||||
|
<span class="chart-center-value timesheet-center-value"></span>
|
||||||
|
<span class="chart-center-unit timesheet-center-unit"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<h3>Weekly Timesheet</h3>
|
<h3>Weekly Timesheet</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Attendance Hours Card -->
|
||||||
|
<div class="col-md-4 col-sm-6 col-12 module-box" id="attendance-hours-section" style="display:none;">
|
||||||
|
<div class="col-md-12 col-sm-12 col-12 module-box-container">
|
||||||
|
<p>
|
||||||
|
<span>
|
||||||
|
<i class="fa fa-circle"/> <span class="attendance-plan-hours">0</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span>
|
||||||
|
<i class="fa fa-circle"/> <span class="attendance-official-hours">0</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div class="chart-wrapper">
|
||||||
|
<div id="chartAttendanceHours"></div>
|
||||||
|
<div class="chart-center-text">
|
||||||
|
<span class="chart-center-value attendance-center-value"></span>
|
||||||
|
<span class="chart-center-unit attendance-center-unit"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3>Monthly Attendance</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 col-sm-12 col-12 dashboard-attendance-section">
|
<div class="col-md-2 col-sm-12 col-12 dashboard-attendance-section">
|
||||||
|
|
@ -81,12 +119,10 @@
|
||||||
<div class="lds-roller"><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div></div>
|
<div class="lds-roller"><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-12 col-sm-12 col-12 attendance-section-body">
|
<div class="col-md-12 col-sm-12 col-12 attendance-section-body">
|
||||||
<h3>Attendance</h3>
|
<h3 class="attendance-title">Attendance</h3>
|
||||||
<p class="last-checkin-section"/>
|
<p class="last-checkin-section"/>
|
||||||
<p class="attendance-img-section"/>
|
<p class="attendance-img-section"/>
|
||||||
<!--p class="attendance-button-section">
|
<p class="last-checkin-info"/>
|
||||||
<button type="button" id="check_button" class="btn btn-danger"></button>
|
|
||||||
</p-->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -96,6 +132,9 @@
|
||||||
<div class="col-md-12 col-sm-12 col-12 dashboard-nav-buttons">
|
<div class="col-md-12 col-sm-12 col-12 dashboard-nav-buttons">
|
||||||
<ul class="nav nav-tabs">
|
<ul class="nav nav-tabs">
|
||||||
<li role="presentation" class="nav-item"><a href="#self_services" class="nav-link active" aria-controls="self_services" role="tab" data-toggle="tab">Self Services</a></li>
|
<li role="presentation" class="nav-item"><a href="#self_services" class="nav-link active" aria-controls="self_services" role="tab" data-toggle="tab">Self Services</a></li>
|
||||||
|
<!-- Approval Tabs - hidden by default, shown via JS if user has approve cards -->
|
||||||
|
<li role="presentation" class="nav-item approval-tab-item" style="display:none;"><a href="#to_approve" class="nav-link" aria-controls="to_approve" role="tab" data-toggle="tab">To Approve <span class="pending-count-badge" style="display:none;"></span></a></li>
|
||||||
|
<li role="presentation" class="nav-item approval-tab-item" style="display:none;"><a href="#to_track" class="nav-link" aria-controls="to_track" role="tab" data-toggle="tab">To Track</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<hr/>
|
<hr/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -104,6 +143,13 @@
|
||||||
<div role="tabpanel" class="tab-pane fade show active" id="self_services">
|
<div role="tabpanel" class="tab-pane fade show active" id="self_services">
|
||||||
<div class="col-md-12 col-12 d-flex flex-wrap card-section1" style="padding: 0 15px;"></div>
|
<div class="col-md-12 col-12 d-flex flex-wrap card-section1" style="padding: 0 15px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Approval Tab Panes -->
|
||||||
|
<div role="tabpanel" class="tab-pane fade" id="to_approve">
|
||||||
|
<div class="col-md-12 col-12 d-flex flex-wrap card-section-approve" style="padding: 0 15px;"></div>
|
||||||
|
</div>
|
||||||
|
<div role="tabpanel" class="tab-pane fade" id="to_track">
|
||||||
|
<div class="col-md-12 col-12 d-flex flex-wrap card-section-track" style="padding: 0 15px;"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,24 @@
|
||||||
<h3>Weekly Timesheet</h3>
|
<h3>Weekly Timesheet</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Attendance Hours Card -->
|
||||||
|
<div class="col-md-4 col-sm-6 col-12 module-box" id="attendance-hours-section">
|
||||||
|
<div class="col-md-12 col-sm-12 col-12 module-box-container">
|
||||||
|
<p>
|
||||||
|
<span>
|
||||||
|
<i class="fa fa-circle"/> <span class="attendance-plan-hours">0</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span>
|
||||||
|
<i class="fa fa-circle"/> <span class="attendance-official-hours">0</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div id="chartAttendanceHours"></div>
|
||||||
|
<h4 class="attendance-hours-percent"></h4>
|
||||||
|
<h3>Monthly Attendance</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 col-sm-12 col-12 dashboard-attendance-section">
|
<div class="col-md-2 col-sm-12 col-12 dashboard-attendance-section">
|
||||||
|
|
@ -84,9 +102,7 @@
|
||||||
<h3>Attendance</h3>
|
<h3>Attendance</h3>
|
||||||
<p class="last-checkin-section"/>
|
<p class="last-checkin-section"/>
|
||||||
<p class="attendance-img-section"/>
|
<p class="attendance-img-section"/>
|
||||||
<!--p class="attendance-button-section">
|
<p class="last-checkin-info"/>
|
||||||
<button type="button" id="check_button" class="btn btn-danger"></button>
|
|
||||||
</p-->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,84 +1,198 @@
|
||||||
<?xml version="1.0"?>
|
<?xml version="1.0"?>
|
||||||
<odoo>
|
<odoo>
|
||||||
|
<!-- Dashboard Builder - Enhanced Form View -->
|
||||||
<record id="view_base_dashboard_form" model="ir.ui.view">
|
<record id="view_base_dashboard_form" model="ir.ui.view">
|
||||||
<field name="name">Base Dashboard</field>
|
<field name="name">Dashboard Builder</field>
|
||||||
<field name="model">base.dashbord</field>
|
<field name="model">base.dashbord</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<form>
|
<form string="Dashboard Service Configuration">
|
||||||
<sheet>
|
<sheet>
|
||||||
|
<!-- Avatar / Icon Selection -->
|
||||||
|
<!-- Avatar / Icon Selection -->
|
||||||
|
<div class="oe_title">
|
||||||
|
<label for="icon_type" class="oe_edit_only"/>
|
||||||
|
<field name="icon_type" widget="radio" class="oe_edit_only" options="{'horizontal': true}"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Widget (Visible if 'image') - NOT REQUIRED -->
|
||||||
|
<field name="card_image" widget="image" class="oe_avatar"
|
||||||
|
options="{'preview_image': 'card_image'}"
|
||||||
|
attrs="{'invisible': [('icon_type', '=', 'icon')]}"/>
|
||||||
|
|
||||||
|
<!-- Icon Preview Widget (Visible if 'icon') -->
|
||||||
|
<field name="icon_preview_html" widget="html" class="oe_avatar"
|
||||||
|
style="padding:0; border:none; background:transparent;"
|
||||||
|
attrs="{'invisible': [('icon_type', '=', 'image')]}"/>
|
||||||
|
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1>
|
||||||
|
<field name="name" placeholder="Service Name" required="True"/>
|
||||||
|
</h1>
|
||||||
|
<div attrs="{'invisible': [('icon_type', '=', 'image')]}" class="o_row">
|
||||||
|
<!-- NOTE: Removed required attr to allow default fallback -->
|
||||||
|
<field name="icon_name" placeholder="fa-plane"/>
|
||||||
|
<a href="https://fontawesome.com/v4/icons/" target="_blank" class="btn btn-link" role="button">
|
||||||
|
<i class="fa fa-external-link"/> Browse Icons
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Configuration Groups -->
|
||||||
<group>
|
<group>
|
||||||
<field name="model_id" options="{'no_create_edit': True}" required="1"/>
|
<group string="Model Configuration">
|
||||||
|
<field name="model_id"
|
||||||
<field name="name"/>
|
options="{'no_create_edit': True}"
|
||||||
<field name="card_image" widget="image" style="width: 100px; height: 100px;"/>
|
required="1"/>
|
||||||
<field name="model_name" invisible="1" />
|
<field name="model_name" invisible="1"/>
|
||||||
<field name="form_view_id" options="{'no_create_edit': True}" domain="[('type','=','form'),('model','=',model_name)]" />
|
<field name="action_id"
|
||||||
<field name="list_view_id" options="{'no_create_edit': True}" domain="[('type','=','tree'),('model','=',model_name)]" />
|
options="{'no_create_edit': True}"
|
||||||
<field name="action_id" options="{'no_create_edit': True}" domain="[('res_model','=',model_name)]" required="1" />
|
domain="[('res_model','=',model_name)]"
|
||||||
<field name="is_self_service"/>
|
required="1"
|
||||||
<field name="is_financial_impact"/>
|
help="The action to open when clicking this card"/>
|
||||||
<field name="sequence"/>
|
</group>
|
||||||
<field name="search_field"/>
|
<group string="Display Options">
|
||||||
<field name="action_domain" invisible="1" />
|
<field name="sequence" help="Lower numbers appear first"/>
|
||||||
<field name="action_context" invisible="1"/>
|
<field name="is_self_service"
|
||||||
<field name="is_button" invisible="1"/>
|
help="Enable for employee self-service cards" widget="boolean_toggle"/>
|
||||||
<field name="is_stage" invisible="1"/>
|
<field name="is_financial_impact"
|
||||||
<field name="is_double" invisible="1"/>
|
help="Mark if this service has no financial impact" widget="boolean_toggle"/>
|
||||||
<field name="is_state" invisible="1"/>
|
</group>
|
||||||
|
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<group string="Employee Filter Configuration">
|
||||||
|
<field name="search_field"
|
||||||
|
placeholder="e.g., employee_id.user_id or user_id"
|
||||||
|
help="The field path used to filter records for current user. Examples: • 'employee_id.user_id' - For HR models (hr.leave, hr.expense, etc.) • 'user_id' - For models with direct user reference (purchase.order, etc.) • 'create_uid' - For records created by the user"/>
|
||||||
|
</group>
|
||||||
|
<group string="Advanced View Settings"
|
||||||
|
attrs="{'invisible': [('model_id', '=', False)]}">
|
||||||
|
<field name="form_view_id"
|
||||||
|
options="{'no_create_edit': True}"
|
||||||
|
domain="[('type','=','form'),('model','=',model_name)]"
|
||||||
|
help="Optional: Custom form view for this service"/>
|
||||||
|
<field name="list_view_id"
|
||||||
|
options="{'no_create_edit': True}"
|
||||||
|
domain="[('type','=','tree'),('model','=',model_name)]"
|
||||||
|
help="Optional: Custom list view for this service"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- Hidden computed fields -->
|
||||||
|
<field name="action_domain" invisible="1"/>
|
||||||
|
<field name="action_context" invisible="1"/>
|
||||||
|
<field name="is_button" invisible="1"/>
|
||||||
|
<field name="is_stage" invisible="1"/>
|
||||||
|
<field name="is_double" invisible="1"/>
|
||||||
|
<field name="is_state" invisible="1"/>
|
||||||
|
|
||||||
|
<!-- State/Stage Configuration Notebook -->
|
||||||
<notebook>
|
<notebook>
|
||||||
|
<page name="state_config" string="State/Stage Configuration">
|
||||||
<page name="Apply TO" string="Apply TO">
|
<!-- Initial state - show info and load button -->
|
||||||
<button name="compute_selection" string="Load Model State" type="object" class="oe_highlight" attrs="{'invisible': [('is_button', '=', True)]}"/>
|
<div attrs="{'invisible': [('is_button', '=', True)]}">
|
||||||
<div>
|
<div class="alert alert-info" role="alert">
|
||||||
<button name="update_selection" string="Updtae Model State" type="object" class="oe_highlight oe_inline" attrs="{'invisible': [('is_button', '=', False)]}"/>
|
<i class="fa fa-info-circle"/> Click the button below to detect available states/stages for the selected model.
|
||||||
<span style="margin-left: 10px;"/>
|
</div>
|
||||||
<button name="unlink_nodes" string="Unlink Nodes" type="object" class="btn btn-danger oe_inline" attrs="{'invisible': [('is_button', '=', False)]}"/>
|
<button name="compute_selection"
|
||||||
|
string="Load Model States"
|
||||||
|
type="object"
|
||||||
|
class="btn-primary"
|
||||||
|
icon="fa-download"/>
|
||||||
</div>
|
</div>
|
||||||
<field name="line_ids" attrs="{'invisible': [('is_button', '=', False)]}" context="{'default_model_name':model_name,'default_model_id':model_id}">
|
|
||||||
|
<!-- After loading - show action buttons above the list -->
|
||||||
|
<div class="d-flex mb-3" attrs="{'invisible': [('is_button', '=', False)]}">
|
||||||
|
<button name="update_selection"
|
||||||
|
string="Refresh States"
|
||||||
|
type="object"
|
||||||
|
class="btn-primary"
|
||||||
|
icon="fa-refresh"/>
|
||||||
|
<button name="unlink_nodes"
|
||||||
|
string="Remove All States"
|
||||||
|
type="object"
|
||||||
|
class="btn-danger ml-2"
|
||||||
|
icon="fa-trash"
|
||||||
|
confirm="Are you sure you want to remove all loaded states?"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<field name="line_ids"
|
||||||
|
attrs="{'invisible': [('is_button', '=', False)]}"
|
||||||
|
context="{'default_model_name':model_name,'default_model_id':model_id}">
|
||||||
<tree editable="bottom">
|
<tree editable="bottom">
|
||||||
<field name="sequence" widget="handle" />
|
<field name="sequence" widget="handle"/>
|
||||||
<field name="group_ids" widget="many2many_tags" options="{'no_quick_create': True}" required ="1" />
|
<field name="group_ids"
|
||||||
|
widget="many2many_tags"
|
||||||
|
options="{'no_quick_create': True, 'color_field': 'color'}"
|
||||||
|
required="1"
|
||||||
|
placeholder="Select user groups..."/>
|
||||||
<field name="model_name" invisible="1"/>
|
<field name="model_name" invisible="1"/>
|
||||||
<field name="model_id" invisible="1"/>
|
<field name="model_id" invisible="1"/>
|
||||||
<field name="state_id" attrs="{'column_invisible': [('parent.is_state', '=',False ),('parent.is_double', '=',False )]}" options="{'no_create': True, 'no_create_edit':True}" domain="[('model_id', '=', model_id)]" />
|
<field name="state_id"
|
||||||
<field name="stage_id" attrs="{'column_invisible': [('parent.is_stage', '=',False)]}" options="{'no_create': True, 'no_create_edit':True}" domain="[('model_id', '=', model_id)]"/>
|
attrs="{'column_invisible': [('parent.is_state', '=', False), ('parent.is_double', '=', False)]}"
|
||||||
|
options="{'no_create': True, 'no_create_edit': True}"
|
||||||
|
domain="[('model_id', '=', model_id)]"/>
|
||||||
|
<field name="stage_id"
|
||||||
|
attrs="{'column_invisible': [('parent.is_stage', '=', False)]}"
|
||||||
|
options="{'no_create': True, 'no_create_edit': True}"
|
||||||
|
domain="[('model_id', '=', model_id)]"/>
|
||||||
</tree>
|
</tree>
|
||||||
<!-- ,('parent.model_name', '!=','hr.holidays') required = "[('parent.is_stage', '=',True )]" required ="[('parent.is_stage', '=',False )]-->
|
|
||||||
|
|
||||||
</field>
|
</field>
|
||||||
</page>
|
</page>
|
||||||
|
|
||||||
</notebook>
|
</notebook>
|
||||||
</sheet>
|
</sheet>
|
||||||
</form>
|
</form>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<!-- Dashboard Builder - Enhanced Tree View -->
|
||||||
<record id="view_base_dashboard_tree" model="ir.ui.view">
|
<record id="view_base_dashboard_tree" model="ir.ui.view">
|
||||||
<field name="name">Base Dashboard</field>
|
<field name="name">Dashboard Builder</field>
|
||||||
<field name="model">base.dashbord</field>
|
<field name="model">base.dashbord</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<tree>
|
<tree string="Dashboard Services" default_order="sequence">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<!-- Unified Preview Column -->
|
||||||
|
<field name="icon_preview_html" widget="html" string="Icon/Image"/>
|
||||||
|
<field name="card_image" invisible="1"/> <!-- explicit card_image kept invisible if needed by logic -->
|
||||||
|
<field name="name"/>
|
||||||
<field name="model_id"/>
|
<field name="model_id"/>
|
||||||
|
<field name="is_self_service" widget="boolean_toggle"/>
|
||||||
|
<field name="action_id"/>
|
||||||
|
<field name="search_field"/>
|
||||||
</tree>
|
</tree>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<!-- Dashboard Builder Action -->
|
||||||
<record id="base_dashboard_action" model="ir.actions.act_window">
|
<record id="base_dashboard_action" model="ir.actions.act_window">
|
||||||
<field name="name">Base Dashboard</field>
|
<field name="name">Dashboard Builder</field>
|
||||||
<field name="res_model">base.dashbord</field>
|
<field name="res_model">base.dashbord</field>
|
||||||
<field name="type">ir.actions.act_window</field>
|
<field name="type">ir.actions.act_window</field>
|
||||||
<field name="context">{}</field>
|
<field name="context">{}</field>
|
||||||
<field name="view_mode">tree,form</field>
|
<field name="view_mode">tree,form</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Create your first dashboard service
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Configure which Odoo models appear as service cards on the employee dashboard.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<menuitem id="base_dashboard_root" parent="system_dashboard_classic_menu" name="Configrutions"
|
<!-- Menu Items -->
|
||||||
groups="system_dashboard_classic.system_board_group_configurations" sequence="-7"/>
|
<menuitem id="base_dashboard_root"
|
||||||
|
parent="system_dashboard_classic_menu"
|
||||||
|
name="Configuration"
|
||||||
|
groups="system_dashboard_classic.system_board_group_configurations"
|
||||||
|
sequence="-7"/>
|
||||||
|
|
||||||
<menuitem id="base_dashboard" parent="base_dashboard_root" name="Base Dashboard" action="base_dashboard_action"
|
<menuitem id="base_dashboard"
|
||||||
groups="system_dashboard_classic.system_board_group_configurations" sequence="-7" />
|
parent="base_dashboard_root"
|
||||||
|
name="Dashboard Builder"
|
||||||
|
action="base_dashboard_action"
|
||||||
|
groups="system_dashboard_classic.system_board_group_configurations"
|
||||||
|
sequence="-7"/>
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Dashboard Color Settings - Extends res.config.settings form view -->
|
||||||
|
<record id="res_config_settings_view_form_dashboard" model="ir.ui.view">
|
||||||
|
<field name="name">res.config.settings.view.form.dashboard.colors</field>
|
||||||
|
<field name="model">res.config.settings</field>
|
||||||
|
<field name="priority">99</field>
|
||||||
|
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//div[hasclass('settings')]" position="inside">
|
||||||
|
<div class="app_settings_block" data-string="Dashboard" string="Dashboard" data-key="system_dashboard_classic">
|
||||||
|
<h2>Dashboard Theme Settings</h2>
|
||||||
|
<div class="row mt16 o_settings_container">
|
||||||
|
<!-- Primary Color -->
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_left_pane">
|
||||||
|
<span class="fa-stack fa-lg">
|
||||||
|
<i class="fa fa-circle fa-stack-2x" style="color: #0891b2;"/>
|
||||||
|
<i class="fa fa-paint-brush fa-stack-1x fa-inverse"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<label for="dashboard_primary_color" string="Primary Color"/>
|
||||||
|
<div class="text-muted">
|
||||||
|
Main accent color for buttons, links, and highlights
|
||||||
|
</div>
|
||||||
|
<div class="content-group">
|
||||||
|
<div class="row mt8">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<field name="dashboard_primary_color" widget="color" placeholder="#0891b2"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Secondary Color -->
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_left_pane">
|
||||||
|
<span class="fa-stack fa-lg">
|
||||||
|
<i class="fa fa-circle fa-stack-2x" style="color: #1e293b;"/>
|
||||||
|
<i class="fa fa-header fa-stack-1x fa-inverse"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<label for="dashboard_secondary_color" string="Secondary Color"/>
|
||||||
|
<div class="text-muted">
|
||||||
|
Header background and dark elements color
|
||||||
|
</div>
|
||||||
|
<div class="content-group">
|
||||||
|
<div class="row mt8">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<field name="dashboard_secondary_color" widget="color" placeholder="#1e293b"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Success Color -->
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_left_pane">
|
||||||
|
<span class="fa-stack fa-lg">
|
||||||
|
<i class="fa fa-circle fa-stack-2x" style="color: #10b981;"/>
|
||||||
|
<i class="fa fa-check fa-stack-1x fa-inverse"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<label for="dashboard_success_color" string="Success / Online Color"/>
|
||||||
|
<div class="text-muted">
|
||||||
|
Online status indicator and success actions
|
||||||
|
</div>
|
||||||
|
<div class="content-group">
|
||||||
|
<div class="row mt8">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<field name="dashboard_success_color" widget="color" placeholder="#10b981"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Warning Color -->
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_left_pane">
|
||||||
|
<span class="fa-stack fa-lg">
|
||||||
|
<i class="fa fa-circle fa-stack-2x" style="color: #f59e0b;"/>
|
||||||
|
<i class="fa fa-exclamation fa-stack-1x fa-inverse"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<label for="dashboard_warning_color" string="Warning Color"/>
|
||||||
|
<div class="text-muted">
|
||||||
|
Remaining balance and warning indicators
|
||||||
|
</div>
|
||||||
|
<div class="content-group">
|
||||||
|
<div class="row mt8">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<field name="dashboard_warning_color" widget="color" placeholder="#f59e0b"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard Statistics Visibility -->
|
||||||
|
<h2 class="mt32">Dashboard Statistics Visibility</h2>
|
||||||
|
<div class="row mt16 o_settings_container">
|
||||||
|
<!-- Annual Leave Toggle + Chart Type -->
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_left_pane">
|
||||||
|
<field name="dashboard_show_annual_leave" widget="boolean_toggle"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<label for="dashboard_show_annual_leave" string="Show Annual Leave"/>
|
||||||
|
<div class="text-muted">
|
||||||
|
Display Annual Leave statistics card in the dashboard header
|
||||||
|
</div>
|
||||||
|
<div class="mt8" attrs="{'invisible': [('dashboard_show_annual_leave', '=', False)]}">
|
||||||
|
<label for="dashboard_annual_leave_chart_type" string="Chart Type"/>
|
||||||
|
<field name="dashboard_annual_leave_chart_type" class="ml8"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Salary Slips Toggle + Chart Type -->
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_left_pane">
|
||||||
|
<field name="dashboard_show_salary_slips" widget="boolean_toggle"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<label for="dashboard_show_salary_slips" string="Show Salary Slips"/>
|
||||||
|
<div class="text-muted">
|
||||||
|
Display Salary Slips statistics card in the dashboard header
|
||||||
|
</div>
|
||||||
|
<div class="mt8" attrs="{'invisible': [('dashboard_show_salary_slips', '=', False)]}">
|
||||||
|
<label for="dashboard_salary_slips_chart_type" string="Chart Type"/>
|
||||||
|
<field name="dashboard_salary_slips_chart_type" class="ml8"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Weekly Timesheet Toggle + Chart Type -->
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_left_pane">
|
||||||
|
<field name="dashboard_show_timesheet" widget="boolean_toggle"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<label for="dashboard_show_timesheet" string="Show Weekly Timesheet"/>
|
||||||
|
<div class="text-muted">
|
||||||
|
Display Weekly Timesheet statistics card in the dashboard header
|
||||||
|
</div>
|
||||||
|
<div class="mt8" attrs="{'invisible': [('dashboard_show_timesheet', '=', False)]}">
|
||||||
|
<label for="dashboard_timesheet_chart_type" string="Chart Type"/>
|
||||||
|
<field name="dashboard_timesheet_chart_type" class="ml8"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Attendance Hours Toggle + Chart Type -->
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_left_pane">
|
||||||
|
<field name="dashboard_show_attendance_hours" widget="boolean_toggle"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<label for="dashboard_show_attendance_hours" string="Show Attendance Hours"/>
|
||||||
|
<div class="text-muted">
|
||||||
|
Display Attendance Hours statistics card in the dashboard header
|
||||||
|
</div>
|
||||||
|
<div class="mt8" attrs="{'invisible': [('dashboard_show_attendance_hours', '=', False)]}">
|
||||||
|
<label for="dashboard_attendance_hours_chart_type" string="Chart Type"/>
|
||||||
|
<field name="dashboard_attendance_hours_chart_type" class="ml8"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attendance Check-in/out Section Visibility -->
|
||||||
|
<h2 class="mt32">Attendance Check-in Section</h2>
|
||||||
|
<div class="row mt16 o_settings_container">
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_left_pane">
|
||||||
|
<field name="dashboard_show_attendance_section" widget="boolean_toggle"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<label for="dashboard_show_attendance_section" string="Show Attendance Section"/>
|
||||||
|
<div class="text-muted">
|
||||||
|
Display the Attendance Check-in/out section on the right side of dashboard header.
|
||||||
|
When hidden, statistics cards will expand to fill the available space.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Enable Attendance Button (only enabled if Attendance Section is shown) -->
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box"
|
||||||
|
attrs="{'invisible': [('dashboard_show_attendance_section', '=', False)]}">
|
||||||
|
<div class="o_setting_left_pane">
|
||||||
|
<field name="dashboard_enable_attendance_button" widget="boolean_toggle"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<label for="dashboard_enable_attendance_button" string="Enable Check-in/out Button"/>
|
||||||
|
<div class="text-muted">
|
||||||
|
Allow employees to check-in and check-out from the dashboard.
|
||||||
|
<br/><em>Note: This setting only applies when Attendance Section is visible.</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Settings Action -->
|
||||||
|
<record id="action_dashboard_configuration" model="ir.actions.act_window">
|
||||||
|
<field name="name">General Settings</field>
|
||||||
|
<field name="type">ir.actions.act_window</field>
|
||||||
|
<field name="res_model">res.config.settings</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="target">inline</field>
|
||||||
|
<field name="context">{'module' : 'system_dashboard_classic'}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Settings Menu -->
|
||||||
|
<menuitem id="menu_dashboard_configuration" name="General Settings" parent="base_dashboard_root"
|
||||||
|
sequence="100" action="action_dashboard_configuration" groups="base.group_system"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
|
|
||||||
|
|
@ -2,28 +2,10 @@
|
||||||
<odoo>
|
<odoo>
|
||||||
<data>
|
<data>
|
||||||
|
|
||||||
<record id="approval_screen" model="ir.actions.act_window">
|
|
||||||
<field name="name">System Dashboard</field>
|
|
||||||
<field name="type">ir.actions.act_window</field>
|
|
||||||
<field name="res_model">system_dashboard_classic.dashboard</field>
|
|
||||||
<field name="view_mode">tree,form</field>
|
|
||||||
<field name="help" type="html">
|
|
||||||
<p class="o_view_nocontent_smiling_face">
|
|
||||||
Create first Dashboard !
|
|
||||||
</p>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Client Action For Approval Screen -->
|
|
||||||
<record id="action_approval_screen" model="ir.actions.client">
|
|
||||||
<field name="name">Approval Screen</field>
|
|
||||||
<field name="res_model">system_dashboard_classic.dashboard</field>
|
|
||||||
<field name="tag">system_dashboard_classic.dashboard</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Default View for System Dashboard, which is extended to make Dashboard View -->
|
<!-- Default View for System Dashboard, which is extended to make Dashboard View -->
|
||||||
<record id="self_service_dashboard" model="ir.actions.act_window">
|
<record id="self_service_dashboard" model="ir.actions.act_window">
|
||||||
<field name="name">System Dashboard</field>
|
<field name="name">Dashboard</field>
|
||||||
<field name="type">ir.actions.act_window</field>
|
<field name="type">ir.actions.act_window</field>
|
||||||
<field name="res_model">system_dashboard_classic.dashboard</field>
|
<field name="res_model">system_dashboard_classic.dashboard</field>
|
||||||
<field name="view_mode">tree,form</field>
|
<field name="view_mode">tree,form</field>
|
||||||
|
|
@ -41,18 +23,44 @@
|
||||||
<field name="tag">system_dashboard_classic.dashboard_self_services</field>
|
<field name="tag">system_dashboard_classic.dashboard_self_services</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<menuitem id="system_dashboard_classic_menu" name="System Dashboard" web_icon="system_dashboard_classic,static/description/icon.png" groups="base.group_user" sequence="-7" />
|
<menuitem id="system_dashboard_classic_menu" name="Dashboard" web_icon="system_dashboard_classic,static/description/icon.png" groups="base.group_user" sequence="-7" />
|
||||||
<menuitem id="menu_approval_screen" name="Approval Screen" parent= "system_dashboard_classic_menu" action="action_approval_screen" groups="system_dashboard_classic.system_board_group_manager" sequence="-8"/>
|
<menuitem id="menu_self_service_service" name="Self Service" parent= "system_dashboard_classic_menu" action="action_self_service_dashboard" groups="base.group_user" sequence="-9"/>
|
||||||
<menuitem id="menu_self_service_service" name="Self Service Screen" parent= "system_dashboard_classic_menu" action="action_self_service_dashboard" groups="base.group_user" sequence="-9"/>
|
|
||||||
|
|
||||||
<template id="assets_system_backend" name="System Dashboard Assets" inherit_id="web.assets_backend">
|
<template id="assets_system_backend" name="System Dashboard Assets" inherit_id="web.assets_backend">
|
||||||
<xpath expr="." position="inside">
|
<xpath expr="." position="inside">
|
||||||
|
<!-- CRITICAL: Apply cached colors BEFORE CSS loads to prevent flash of default colors -->
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var cached = localStorage.getItem('dashboard_colors');
|
||||||
|
if (cached) {
|
||||||
|
var colors = JSON.parse(cached);
|
||||||
|
var root = document.documentElement;
|
||||||
|
if (colors.primary) {
|
||||||
|
root.style.setProperty('--dash-primary', colors.primary);
|
||||||
|
root.style.setProperty('--dash-primary-light', colors.primaryLight || colors.primary);
|
||||||
|
root.style.setProperty('--dash-primary-dark', colors.primaryDark || colors.primary);
|
||||||
|
}
|
||||||
|
if (colors.secondary) {
|
||||||
|
root.style.setProperty('--dash-secondary', colors.secondary);
|
||||||
|
}
|
||||||
|
if (colors.warning) {
|
||||||
|
root.style.setProperty('--dash-warning', colors.warning);
|
||||||
|
}
|
||||||
|
if (colors.success) {
|
||||||
|
root.style.setProperty('--dash-success', colors.success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/MaterialDesign-Webfont/5.0.45/css/materialdesignicons.min.css"/>
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/MaterialDesign-Webfont/5.0.45/css/materialdesignicons.min.css"/>
|
||||||
<link rel="stylesheet" type="text/scss" href="/system_dashboard_classic/static/src/scss/pluscharts.scss"/>
|
<link rel="stylesheet" type="text/scss" href="/system_dashboard_classic/static/src/scss/pluscharts.scss"/>
|
||||||
<!--LTR-->
|
<!--LTR-->
|
||||||
<link rel="stylesheet" type="text/scss" href="/system_dashboard_classic/static/src/scss/variables.scss"/>
|
<link rel="stylesheet" type="text/scss" href="/system_dashboard_classic/static/src/scss/variables.scss"/>
|
||||||
<link rel="stylesheet" type="text/scss" href="/system_dashboard_classic/static/src/scss/core.scss"/>
|
<link rel="stylesheet" type="text/scss" href="/system_dashboard_classic/static/src/scss/core.scss"/>
|
||||||
<link rel="stylesheet" type="text/scss" href="/system_dashboard_classic/static/src/scss/cards.scss"/>
|
<link rel="stylesheet" type="text/scss" href="/system_dashboard_classic/static/src/scss/cards.scss"/>
|
||||||
|
<link rel="stylesheet" type="text/scss" href="/system_dashboard_classic/static/src/scss/genius-enhancements.scss"/>
|
||||||
<!--RTL-->
|
<!--RTL-->
|
||||||
<link rel="stylesheet" type="text/scss" href="/system_dashboard_classic/static/src/scss/rtl-cards.scss"/>
|
<link rel="stylesheet" type="text/scss" href="/system_dashboard_classic/static/src/scss/rtl-cards.scss"/>
|
||||||
<link rel="stylesheet" type="text/scss" href="/system_dashboard_classic/static/src/scss/rtl-core.scss"/>
|
<link rel="stylesheet" type="text/scss" href="/system_dashboard_classic/static/src/scss/rtl-core.scss"/>
|
||||||
|
|
@ -60,8 +68,9 @@
|
||||||
<!--<script type="text/javascript" src="/system_dashboard_classic/static/src/js/canvasjs.min.js"></script>-->
|
<!--<script type="text/javascript" src="/system_dashboard_classic/static/src/js/canvasjs.min.js"></script>-->
|
||||||
<script type="text/javascript" src="/system_dashboard_classic/static/src/js/d3.v5.min.js"/>
|
<script type="text/javascript" src="/system_dashboard_classic/static/src/js/d3.v5.min.js"/>
|
||||||
<script type="text/javascript" src="/system_dashboard_classic/static/src/js/pluscharts.js"></script>
|
<script type="text/javascript" src="/system_dashboard_classic/static/src/js/pluscharts.js"></script>
|
||||||
<script type="text/javascript" src="/system_dashboard_classic/static/src/js/system_dashboard.js"/>
|
|
||||||
<script type="text/javascript" src="/system_dashboard_classic/static/src/js/system_dashboard_self_service.js"/>
|
<script type="text/javascript" src="/system_dashboard_classic/static/src/js/system_dashboard_self_service.js"/>
|
||||||
|
<script type="text/javascript" src="/system_dashboard_classic/static/src/lib/confetti.min.js"/>
|
||||||
|
<script type="text/javascript" src="/system_dashboard_classic/static/src/js/genius_enhancements.js"/>
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
$('.app-drawer-toggle').click();
|
$('.app-drawer-toggle').click();
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,4 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
{
|
||||||
|
'name': 'Tour Genius',
|
||||||
|
'version': '14.0.3.0.0',
|
||||||
|
'category': 'Human Resources/Training',
|
||||||
|
'summary': 'Create interactive training tours and quizzes for Odoo',
|
||||||
|
'description': """
|
||||||
|
Tour Genius - Interactive Training Platform
|
||||||
|
============================================
|
||||||
|
|
||||||
|
Create interactive guides and training tours for any Odoo screen.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
---------
|
||||||
|
* Interactive step-by-step tours
|
||||||
|
* Visual tour recorder (Track On/Off)
|
||||||
|
* Training plans with progress tracking
|
||||||
|
* Quizzes and assessments
|
||||||
|
* Gamification with leaderboards
|
||||||
|
* Auto-start tours for new users
|
||||||
|
|
||||||
|
Author: Expert Development Team
|
||||||
|
""",
|
||||||
|
'author': 'Expert Development Team',
|
||||||
|
'website': 'https://www.expert.sa',
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
'depends': [
|
||||||
|
'base',
|
||||||
|
'mail',
|
||||||
|
'web',
|
||||||
|
'web_tour',
|
||||||
|
],
|
||||||
|
'data': [
|
||||||
|
# Security
|
||||||
|
'security/security.xml',
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
# Data (Crons)
|
||||||
|
'data/cron_data.xml',
|
||||||
|
# Assets (MUST load before views for JS to register)
|
||||||
|
'views/assets.xml',
|
||||||
|
# Menu (defines root menu)
|
||||||
|
'views/menu.xml',
|
||||||
|
# Dashboard (is the default action when clicking module)
|
||||||
|
'views/dashboard_views.xml',
|
||||||
|
# Views
|
||||||
|
'views/plan_views.xml',
|
||||||
|
'views/topic_views.xml',
|
||||||
|
'views/step_views.xml',
|
||||||
|
'views/progress_views.xml',
|
||||||
|
'views/quiz_views.xml',
|
||||||
|
'views/leaderboard_views.xml',
|
||||||
|
'views/reminder_views.xml',
|
||||||
|
# Demo/Sample Data (loads on install)
|
||||||
|
'data/demo_data.xml',
|
||||||
|
'data/demo_tours_sales.xml',
|
||||||
|
'data/demo_tours_purchase.xml',
|
||||||
|
'data/demo_tours_stock.xml',
|
||||||
|
'data/demo_tours_account.xml',
|
||||||
|
'data/demo_tours_hr.xml',
|
||||||
|
'data/demo_plans_steps.xml',
|
||||||
|
],
|
||||||
|
'demo': [],
|
||||||
|
'qweb': [
|
||||||
|
'static/src/xml/systray_template.xml',
|
||||||
|
'static/src/xml/dashboard_template.xml',
|
||||||
|
'static/src/xml/recorder_template.xml',
|
||||||
|
'static/src/xml/genius_celebration.xml',
|
||||||
|
'static/src/xml/genius_quiz_popup.xml',
|
||||||
|
'static/src/xml/genius_tip.xml',
|
||||||
|
],
|
||||||
|
'images': ['static/description/icon.png'],
|
||||||
|
'installable': True,
|
||||||
|
'application': True,
|
||||||
|
'auto_install': False,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from . import main
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Tour Genius Controllers
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request
|
||||||
|
import base64
|
||||||
|
|
||||||
|
|
||||||
|
class TourGeniusCertificateController(http.Controller):
|
||||||
|
"""Controller for certificate operations"""
|
||||||
|
|
||||||
|
@http.route('/tour_genius/certificate/download/<int:attempt_id>', type='http', auth='user')
|
||||||
|
def download_certificate(self, attempt_id, **kwargs):
|
||||||
|
"""
|
||||||
|
Download certificate PDF for a passed quiz attempt.
|
||||||
|
Always regenerates PDF to ensure latest design is used.
|
||||||
|
"""
|
||||||
|
attempt = request.env['genius.quiz.attempt'].browse(attempt_id)
|
||||||
|
|
||||||
|
# Security check: user can only download their own certificates
|
||||||
|
if not attempt.exists() or attempt.user_id.id != request.env.user.id:
|
||||||
|
return request.not_found()
|
||||||
|
|
||||||
|
# Check if passed
|
||||||
|
if not attempt.is_passed:
|
||||||
|
return request.not_found()
|
||||||
|
|
||||||
|
# Delete any existing attachment to force regeneration with new design
|
||||||
|
old_attachments = request.env['ir.attachment'].search([
|
||||||
|
('res_model', '=', 'genius.quiz.attempt'),
|
||||||
|
('res_id', '=', attempt.id),
|
||||||
|
('mimetype', '=', 'application/pdf'),
|
||||||
|
])
|
||||||
|
if old_attachments:
|
||||||
|
old_attachments.unlink()
|
||||||
|
|
||||||
|
# Generate new certificate with latest design
|
||||||
|
attachment_id = attempt.generate_certificate_pdf()
|
||||||
|
if not attachment_id:
|
||||||
|
return request.not_found()
|
||||||
|
|
||||||
|
attachment = request.env['ir.attachment'].browse(attachment_id)
|
||||||
|
|
||||||
|
# Return PDF file
|
||||||
|
filename = f'Certificate_{attempt.quiz_id.name}_{attempt.user_id.name}.pdf'
|
||||||
|
return request.make_response(
|
||||||
|
base64.b64decode(attachment.datas),
|
||||||
|
headers=[
|
||||||
|
('Content-Type', 'application/pdf'),
|
||||||
|
('Content-Disposition', f'inline; filename={filename}')
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@http.route('/tour_genius/certificate/view/<int:attempt_id>', type='http', auth='user')
|
||||||
|
def view_certificate(self, attempt_id, **kwargs):
|
||||||
|
"""
|
||||||
|
View certificate PDF inline (for preview).
|
||||||
|
"""
|
||||||
|
attempt = request.env['genius.quiz.attempt'].browse(attempt_id)
|
||||||
|
|
||||||
|
# Security check
|
||||||
|
if not attempt.exists() or attempt.user_id.id != request.env.user.id:
|
||||||
|
return request.not_found()
|
||||||
|
|
||||||
|
if not attempt.is_passed:
|
||||||
|
return request.not_found()
|
||||||
|
|
||||||
|
# Check for existing attachment
|
||||||
|
attachment = request.env['ir.attachment'].search([
|
||||||
|
('res_model', '=', 'genius.quiz.attempt'),
|
||||||
|
('res_id', '=', attempt.id),
|
||||||
|
('mimetype', '=', 'application/pdf'),
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
if not attachment:
|
||||||
|
attachment_id = attempt.generate_certificate_pdf()
|
||||||
|
if attachment_id:
|
||||||
|
attachment = request.env['ir.attachment'].browse(attachment_id)
|
||||||
|
else:
|
||||||
|
return request.not_found()
|
||||||
|
|
||||||
|
# Return PDF for inline viewing
|
||||||
|
return request.make_response(
|
||||||
|
base64.b64decode(attachment.datas),
|
||||||
|
headers=[
|
||||||
|
('Content-Type', 'application/pdf'),
|
||||||
|
('Content-Disposition', f'inline; filename="Certificate.pdf"'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<!-- 1. Leaderboard Update (Hourly) -->
|
||||||
|
<record id="ir_cron_genius_leaderboard_update" model="ir.cron">
|
||||||
|
<field name="name">Tour Genius: Update Leaderboard</field>
|
||||||
|
<field name="model_id" ref="model_genius_leaderboard"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model.update_leaderboard(period_type='alltime')
|
||||||
|
model.update_leaderboard(period_type='monthly')
|
||||||
|
model.update_leaderboard(period_type='weekly')</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">hours</field>
|
||||||
|
<field name="numbercall">-1</field>
|
||||||
|
<field name="doall" eval="False"/>
|
||||||
|
<field name="active" eval="True"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- 2. Create Reminders (Daily) -->
|
||||||
|
<record id="ir_cron_genius_reminder_create" model="ir.cron">
|
||||||
|
<field name="name">Tour Genius: Generate Reminders</field>
|
||||||
|
<field name="model_id" ref="model_genius_reminder"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_create_auto_reminders()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">days</field>
|
||||||
|
<field name="nextcall" eval="(DateTime.now().replace(hour=8, minute=0, second=0)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||||
|
<field name="numbercall">-1</field>
|
||||||
|
<field name="doall" eval="False"/>
|
||||||
|
<field name="active" eval="True"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- 3. Send Reminders (Hourly) -->
|
||||||
|
<record id="ir_cron_genius_reminder_send" model="ir.cron">
|
||||||
|
<field name="name">Tour Genius: Send Reminders</field>
|
||||||
|
<field name="model_id" ref="model_genius_reminder"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_send_reminders()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">hours</field>
|
||||||
|
<field name="numbercall">-1</field>
|
||||||
|
<field name="doall" eval="False"/>
|
||||||
|
<field name="active" eval="True"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<!-- =====================================================================
|
||||||
|
DEMO TAGS
|
||||||
|
===================================================================== -->
|
||||||
|
|
||||||
|
<record id="tag_beginner" model="genius.tour.tag">
|
||||||
|
<field name="name">Beginner</field>
|
||||||
|
<field name="color">10</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="tag_intermediate" model="genius.tour.tag">
|
||||||
|
<field name="name">Intermediate</field>
|
||||||
|
<field name="color">3</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="tag_advanced" model="genius.tour.tag">
|
||||||
|
<field name="name">Advanced</field>
|
||||||
|
<field name="color">1</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="tag_essential" model="genius.tour.tag">
|
||||||
|
<field name="name">Essential</field>
|
||||||
|
<field name="color">4</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="tag_workflow" model="genius.tour.tag">
|
||||||
|
<field name="name">Workflow</field>
|
||||||
|
<field name="color">5</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="tag_configuration" model="genius.tour.tag">
|
||||||
|
<field name="name">Configuration</field>
|
||||||
|
<field name="color">6</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="tag_reporting" model="genius.tour.tag">
|
||||||
|
<field name="name">Reporting</field>
|
||||||
|
<field name="color">7</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="tag_best_practice" model="genius.tour.tag">
|
||||||
|
<field name="name">Best Practice</field>
|
||||||
|
<field name="color">11</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,380 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<!-- =====================================================================
|
||||||
|
TRAINING PLANS
|
||||||
|
Organized training paths that group tours together
|
||||||
|
===================================================================== -->
|
||||||
|
|
||||||
|
<!-- Sales Training Plan -->
|
||||||
|
<record id="plan_sales_fundamentals" model="genius.plan">
|
||||||
|
<field name="name">Sales Fundamentals</field>
|
||||||
|
<field name="description"><![CDATA[
|
||||||
|
<h3>Master Odoo Sales</h3>
|
||||||
|
<p>A comprehensive training program covering all aspects of the Sales module.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Creating and managing quotations</li>
|
||||||
|
<li>Customer pricelist configuration</li>
|
||||||
|
<li>Complete sales workflow</li>
|
||||||
|
</ul>
|
||||||
|
]]></field>
|
||||||
|
<field name="state">published</field>
|
||||||
|
<field name="date_start" eval="time.strftime('%Y-%m-01')"/>
|
||||||
|
<field name="date_end" eval="time.strftime('%Y-12-31')"/>
|
||||||
|
<field name="estimated_hours">2.0</field>
|
||||||
|
<field name="target_modules">sale</field>
|
||||||
|
<field name="is_public">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Inventory Training Plan -->
|
||||||
|
<record id="plan_inventory_operations" model="genius.plan">
|
||||||
|
<field name="name">Inventory Operations</field>
|
||||||
|
<field name="description"><![CDATA[
|
||||||
|
<h3>Warehouse Management Training</h3>
|
||||||
|
<p>Learn to efficiently manage your inventory with Odoo Stock.</p>
|
||||||
|
]]></field>
|
||||||
|
<field name="state">published</field>
|
||||||
|
<field name="estimated_hours">2.5</field>
|
||||||
|
<field name="target_modules">stock</field>
|
||||||
|
<field name="is_public">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Finance Training Plan -->
|
||||||
|
<record id="plan_finance_essentials" model="genius.plan">
|
||||||
|
<field name="name">Finance Essentials</field>
|
||||||
|
<field name="description"><![CDATA[
|
||||||
|
<h3>Accounting & Finance Training</h3>
|
||||||
|
<p>Master financial operations from invoicing to period closing.</p>
|
||||||
|
]]></field>
|
||||||
|
<field name="state">published</field>
|
||||||
|
<field name="estimated_hours">3.0</field>
|
||||||
|
<field name="target_modules">account</field>
|
||||||
|
<field name="is_public">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- =====================================================================
|
||||||
|
TOUR STEPS - Demo steps for Sales First Quotation Tour
|
||||||
|
These are essential for the tour to actually work!
|
||||||
|
===================================================================== -->
|
||||||
|
|
||||||
|
<!-- Steps for: Creating Your First Quotation -->
|
||||||
|
<record id="step_sales_1_1" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_sales_first_quotation"/>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="title">Navigate to Quotations</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click on <strong>Sales</strong> in the main menu to access the Sales application.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_app[data-menu-xmlid='sale.sale_menu_root']</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_sales_1_2" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_sales_first_quotation"/>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="title">Create New Quotation</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click <strong>Create</strong> to start a new quotation.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_list_button_add</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
<field name="extra_trigger">.o_sale_order</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_sales_1_3" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_sales_first_quotation"/>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="title">Select Customer</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click on the <strong>Customer</strong> field and select a customer from the dropdown.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_form_editable .o_field_many2one[name='partner_id']</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
<field name="extra_trigger">.o_sale_order</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_sales_1_4" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_sales_first_quotation"/>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
<field name="title">Add Order Line</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click <strong>Add a product</strong> to add items to your quotation.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_field_x2many_list_row_add > a</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
<field name="extra_trigger">.o_field_many2one[name='partner_id'] .o_external_button</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_sales_1_5" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_sales_first_quotation"/>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="title">Select Product</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click on the <strong>Product</strong> field and select a product.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_field_widget[name=product_id], .o_field_widget[name=product_template_id]</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">right</field>
|
||||||
|
<field name="extra_trigger">.o_sale_order</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_sales_1_6" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_sales_first_quotation"/>
|
||||||
|
<field name="sequence">60</field>
|
||||||
|
<field name="title">Save Quotation</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click <strong>Save</strong> to save your quotation. You can also use <kbd>Ctrl+S</kbd>.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_form_button_save</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Steps for: Receiving Products (Inventory) -->
|
||||||
|
<record id="step_stock_1_1" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_stock_receiving"/>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="title">Open Inventory App</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click on <strong>Inventory</strong> in the main menu.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_app[data-menu-xmlid='stock.menu_stock_root']</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_stock_1_2" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_stock_receiving"/>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="title">Go to Receipts</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click on <strong>Receipts</strong> to view incoming shipments.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_kanban_record:first .o_primary, .o_kanban_record:contains('Receipts') .oe_kanban_content</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">right</field>
|
||||||
|
<field name="extra_trigger">.o_kanban_view</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_stock_1_3" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_stock_receiving"/>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="title">Select a Receipt</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click on any pending receipt to open it.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_list_view tr.o_data_row:first-child</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">right</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_stock_1_4" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_stock_receiving"/>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
<field name="title">Enter Received Quantity</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Enter the quantity you actually received in the <strong>Done</strong> column.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_field_widget[name='qty_done'] input</field>
|
||||||
|
<field name="step_type">input</field>
|
||||||
|
<field name="input_value">1</field>
|
||||||
|
<field name="position">left</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_stock_1_5" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_stock_receiving"/>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="title">Validate Receipt</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click <strong>Validate</strong> to confirm the receipt and update inventory.</p>]]></field>
|
||||||
|
<field name="css_selector">button[name='button_validate']</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Steps for: Creating Customer Invoices (Accounting) -->
|
||||||
|
<record id="step_acc_1_1" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_account_invoices"/>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="title">Open Accounting App</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click on <strong>Accounting</strong> or <strong>Invoicing</strong> in the main menu.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_app[data-menu-xmlid='account.menu_finance']</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_acc_1_2" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_account_invoices"/>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="title">Go to Invoices</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click on <strong>Customers → Invoices</strong> in the menu.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_menu_entry_lvl_2[data-menu-xmlid='account.menu_action_move_out_invoice_type'], a[data-menu-xmlid='account.menu_action_move_out_invoice_type']</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_acc_1_3" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_account_invoices"/>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="title">Create New Invoice</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click the <strong>Create</strong> button to start a new invoice.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_list_button_add</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_acc_1_4" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_account_invoices"/>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
<field name="title">Select Customer</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click on the <strong>Customer</strong> field and select a customer.</p>]]></field>
|
||||||
|
<field name="css_selector">div[name=partner_id]</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
<field name="extra_trigger">.o_form_editable</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_acc_1_5" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_account_invoices"/>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="title">Add Invoice Line</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click <strong>Add a line</strong> to add products or services.</p>]]></field>
|
||||||
|
<field name="css_selector">div[name=invoice_line_ids] .o_field_x2many_list_row_add a:not([data-context])</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
<field name="extra_trigger">[name=move_type][raw-value=out_invoice]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_acc_1_6" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_account_invoices"/>
|
||||||
|
<field name="sequence">60</field>
|
||||||
|
<field name="title">Confirm Invoice</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click <strong>Confirm</strong> to post the invoice.</p>]]></field>
|
||||||
|
<field name="css_selector">button[name=action_post]</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
<field name="extra_trigger">[name=move_type][raw-value=out_invoice]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Steps for: Creating a Purchase Order (Purchase) -->
|
||||||
|
<record id="step_purchase_order_1" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_purchase_order"/>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="title">Open Purchase App</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click on <strong>Purchase</strong> in the main menu to manage orders.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_app[data-menu-xmlid='purchase.menu_purchase_root']</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_purchase_order_2" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_purchase_order"/>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="title">Create Request for Quotation</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click <strong>Create</strong> to start a new RFQ.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_list_button_add</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
<field name="extra_trigger">.o_purchase_order</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_purchase_order_3" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_purchase_order"/>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="title">Select Vendor</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click on the <strong>Vendor</strong> field and select a vendor from the dropdown.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_form_editable .o_field_many2one[name='partner_id']</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">right</field>
|
||||||
|
<field name="extra_trigger">.o_purchase_order</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_purchase_order_4" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_purchase_order"/>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
<field name="title">Add Product</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click <strong>Add a product</strong> to select items.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_field_x2many_list_row_add > a</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
<field name="extra_trigger">.o_field_many2one[name='partner_id'] .o_external_button</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_purchase_order_5" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_purchase_order"/>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="title">Select Product</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click on the <strong>Product</strong> field and select a product.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_field_widget[name='product_id']</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">right</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_purchase_order_8" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_purchase_order"/>
|
||||||
|
<field name="sequence">80</field>
|
||||||
|
<field name="title">Save Order</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click <strong>Save</strong> to store the RFQ.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_form_button_save</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_purchase_order_9" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_purchase_order"/>
|
||||||
|
<field name="sequence">90</field>
|
||||||
|
<field name="title">Confirm Order</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click <strong>Confirm Order</strong> to finalize the purchase.</p>]]></field>
|
||||||
|
<field name="css_selector">button[name='button_confirm']</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Steps for: Employee Onboarding (HR) -->
|
||||||
|
<record id="step_hr_1_1" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_hr_onboarding"/>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="title">Open Employees App</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click on <strong>Employees</strong> in the main menu.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_app[data-menu-xmlid='hr.menu_hr_root']</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_hr_1_2" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_hr_onboarding"/>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="title">Create New Employee</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click <strong>Create</strong> to add a new employee.</p>]]></field>
|
||||||
|
<field name="css_selector">.o-kanban-button-new</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_hr_1_3" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_hr_onboarding"/>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="title">Enter Employee Name</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Type the employee's <strong>full name</strong>.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_field_widget[name='name'] input</field>
|
||||||
|
<field name="step_type">input</field>
|
||||||
|
<field name="input_value">John Smith</field>
|
||||||
|
<field name="position">right</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_hr_1_4" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_hr_onboarding"/>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
<field name="title">Select Job Position</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click on <strong>Job Position</strong> and select a role.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_field_widget[name='job_id']</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">right</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_hr_1_5" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_hr_onboarding"/>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="title">Select Department</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click on <strong>Department</strong> and select one.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_field_widget[name='department_id']</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">right</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="step_hr_1_6" model="genius.topic.step">
|
||||||
|
<field name="topic_id" ref="tour_hr_onboarding"/>
|
||||||
|
<field name="sequence">60</field>
|
||||||
|
<field name="title">Save Employee</field>
|
||||||
|
<field name="instruction"><![CDATA[<p>Click <strong>Save</strong> to create the employee record.</p>]]></field>
|
||||||
|
<field name="css_selector">.o_form_button_save</field>
|
||||||
|
<field name="step_type">click</field>
|
||||||
|
<field name="position">bottom</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<!-- =====================================================================
|
||||||
|
ACCOUNTING TOURS AND QUIZZES
|
||||||
|
Module: account
|
||||||
|
===================================================================== -->
|
||||||
|
|
||||||
|
<!-- ===================== QUIZ 1: Customer Invoices ===================== -->
|
||||||
|
<record id="quiz_account_invoices" model="genius.quiz">
|
||||||
|
<field name="name">Customer Invoices Quiz</field>
|
||||||
|
<field name="time_limit_minutes">10</field>
|
||||||
|
<field name="passing_score">70</field>
|
||||||
|
<field name="max_attempts">3</field>
|
||||||
|
<field name="shuffle_questions">True</field>
|
||||||
|
<field name="show_correct_answers">True</field>
|
||||||
|
<field name="success_message"><![CDATA[<p>🎉 Great! You understand invoicing basics!</p>]]></field>
|
||||||
|
<field name="fail_message"><![CDATA[<p>💼 Review the invoicing process and try again.</p>]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_acc_1_1" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_account_invoices"/>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="question_text">Where do you create a new customer invoice?</field>
|
||||||
|
<field name="question_type">single</field>
|
||||||
|
<field name="points">1</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_acc_1_1_a" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_acc_1_1"/>
|
||||||
|
<field name="answer_text">Accounting → Customers → Invoices</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_acc_1_1_b" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_acc_1_1"/>
|
||||||
|
<field name="answer_text">Sales → Orders → Invoices</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_acc_1_1_c" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_acc_1_1"/>
|
||||||
|
<field name="answer_text">CRM → Leads</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_acc_1_2" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_account_invoices"/>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="question_text">A draft invoice can be edited before posting.</field>
|
||||||
|
<field name="question_type">single</field>
|
||||||
|
<field name="points">1</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_acc_1_2_true" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_acc_1_2"/>
|
||||||
|
<field name="answer_text">True</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_acc_1_2_false" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_acc_1_2"/>
|
||||||
|
<field name="answer_text">False</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_acc_1_3" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_account_invoices"/>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="question_text">What are the invoice states?</field>
|
||||||
|
<field name="question_type">multiple</field>
|
||||||
|
<field name="points">2</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_acc_1_3_a" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_acc_1_3"/>
|
||||||
|
<field name="answer_text">Draft</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_acc_1_3_b" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_acc_1_3"/>
|
||||||
|
<field name="answer_text">Posted</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_acc_1_3_c" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_acc_1_3"/>
|
||||||
|
<field name="answer_text">Cancelled</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_acc_1_3_d" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_acc_1_3"/>
|
||||||
|
<field name="answer_text">Approved</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_acc_1_4" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_account_invoices"/>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
<field name="question_text">What action makes an invoice official and creates journal entries?</field>
|
||||||
|
<field name="question_type">short_answer</field>
|
||||||
|
<field name="correct_short_answer">Post</field>
|
||||||
|
<field name="case_sensitive">False</field>
|
||||||
|
<field name="points">1</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_acc_1_5" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_account_invoices"/>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="question_text">Credit notes are used to reverse or correct invoices.</field>
|
||||||
|
<field name="question_type">single</field>
|
||||||
|
<field name="points">1</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_acc_1_5_true" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_acc_1_5"/>
|
||||||
|
<field name="answer_text">True</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_acc_1_5_false" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_acc_1_5"/>
|
||||||
|
<field name="answer_text">False</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ===================== TOUR 1: Customer Invoices ===================== -->
|
||||||
|
<record id="tour_account_invoices" model="genius.topic">
|
||||||
|
<field name="name">Creating Customer Invoices</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="starting_url">/web</field>
|
||||||
|
<field name="module_xml_id">account</field>
|
||||||
|
<field name="tag_ids" eval="[(6, 0, [ref('tag_beginner'), ref('tag_essential')])]"/>
|
||||||
|
<field name="quiz_id" ref="quiz_account_invoices"/>
|
||||||
|
<field name="duration_minutes">15</field>
|
||||||
|
<field name="icon">fa-file-text</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<!-- =====================================================================
|
||||||
|
HR TOURS AND QUIZZES
|
||||||
|
Module: hr
|
||||||
|
===================================================================== -->
|
||||||
|
|
||||||
|
<!-- ===================== QUIZ 1: Employee Onboarding ===================== -->
|
||||||
|
<record id="quiz_hr_onboarding" model="genius.quiz">
|
||||||
|
<field name="name">Employee Onboarding Quiz</field>
|
||||||
|
<field name="time_limit_minutes">10</field>
|
||||||
|
<field name="passing_score">70</field>
|
||||||
|
<field name="max_attempts">3</field>
|
||||||
|
<field name="shuffle_questions">True</field>
|
||||||
|
<field name="show_correct_answers">True</field>
|
||||||
|
<field name="success_message"><![CDATA[<p>🎉 Excellent! You're ready to onboard employees!</p>]]></field>
|
||||||
|
<field name="fail_message"><![CDATA[<p>👥 Review the HR basics and try again.</p>]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_hr_1_1" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_hr_onboarding"/>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="question_text">Where do you create a new employee?</field>
|
||||||
|
<field name="question_type">single</field>
|
||||||
|
<field name="points">1</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_hr_1_1_a" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_hr_1_1"/>
|
||||||
|
<field name="answer_text">Employees → Employees</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_hr_1_1_b" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_hr_1_1"/>
|
||||||
|
<field name="answer_text">Settings → Users</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_hr_1_1_c" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_hr_1_1"/>
|
||||||
|
<field name="answer_text">Contacts → Create</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_hr_1_2" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_hr_onboarding"/>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="question_text">An employee record can be linked to a user account.</field>
|
||||||
|
<field name="question_type">single</field>
|
||||||
|
<field name="points">1</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_hr_1_2_true" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_hr_1_2"/>
|
||||||
|
<field name="answer_text">True</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_hr_1_2_false" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_hr_1_2"/>
|
||||||
|
<field name="answer_text">False</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_hr_1_3" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_hr_onboarding"/>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="question_text">What information is typically stored on an employee record?</field>
|
||||||
|
<field name="question_type">multiple</field>
|
||||||
|
<field name="points">2</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_hr_1_3_a" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_hr_1_3"/>
|
||||||
|
<field name="answer_text">Department</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_hr_1_3_b" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_hr_1_3"/>
|
||||||
|
<field name="answer_text">Job Position</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_hr_1_3_c" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_hr_1_3"/>
|
||||||
|
<field name="answer_text">Manager</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_hr_1_3_d" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_hr_1_3"/>
|
||||||
|
<field name="answer_text">Product prices</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_hr_1_4" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_hr_onboarding"/>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
<field name="question_text">What tab contains emergency contact information?</field>
|
||||||
|
<field name="question_type">short_answer</field>
|
||||||
|
<field name="correct_short_answer">Private Information</field>
|
||||||
|
<field name="case_sensitive">False</field>
|
||||||
|
<field name="points">1</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_hr_1_5" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_hr_onboarding"/>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="question_text">Employees can have multiple jobs assigned.</field>
|
||||||
|
<field name="question_type">single</field>
|
||||||
|
<field name="points">1</field>
|
||||||
|
<field name="explanation"><![CDATA[<p>An employee typically has one main job position, but can be assigned to multiple departments via contracts.</p>]]></field>
|
||||||
|
</record>
|
||||||
|
<record id="a_hr_1_5_true" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_hr_1_5"/>
|
||||||
|
<field name="answer_text">True</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_hr_1_5_false" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_hr_1_5"/>
|
||||||
|
<field name="answer_text">False</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ===================== TOUR 1: Employee Onboarding ===================== -->
|
||||||
|
<record id="tour_hr_onboarding" model="genius.topic">
|
||||||
|
<field name="name">Employee Onboarding</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="starting_url">/web</field>
|
||||||
|
<field name="module_xml_id">hr</field>
|
||||||
|
<field name="tag_ids" eval="[(6, 0, [ref('tag_beginner'), ref('tag_essential')])]"/>
|
||||||
|
<field name="quiz_id" ref="quiz_hr_onboarding"/>
|
||||||
|
<field name="duration_minutes">15</field>
|
||||||
|
<field name="icon">fa-users</field>
|
||||||
|
<!-- Auto-start for new users landing on HR dashboard -->
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<!-- =====================================================================
|
||||||
|
PURCHASE TOURS AND QUIZZES
|
||||||
|
Module: purchase
|
||||||
|
===================================================================== -->
|
||||||
|
|
||||||
|
<!-- ===================== QUIZ 1: Purchase Order ===================== -->
|
||||||
|
<record id="quiz_purchase_order" model="genius.quiz">
|
||||||
|
<field name="name">Purchase Order Basics Quiz</field>
|
||||||
|
<field name="time_limit_minutes">10</field>
|
||||||
|
<field name="passing_score">70</field>
|
||||||
|
<field name="max_attempts">3</field>
|
||||||
|
<field name="shuffle_questions">True</field>
|
||||||
|
<field name="show_correct_answers">True</field>
|
||||||
|
<field name="success_message"><![CDATA[<p>🎉 Great job! You understand purchase order basics!</p>]]></field>
|
||||||
|
<field name="fail_message"><![CDATA[<p>📚 Review the material and try again.</p>]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_pur_1_1" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_purchase_order"/>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="question_text">Where do you create a new Purchase Order?</field>
|
||||||
|
<field name="question_type">single</field>
|
||||||
|
<field name="points">1</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_pur_1_1_a" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_pur_1_1"/>
|
||||||
|
<field name="answer_text">Purchase → Orders → Purchase Orders</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_pur_1_1_b" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_pur_1_1"/>
|
||||||
|
<field name="answer_text">Sales → Orders</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_pur_1_1_c" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_pur_1_1"/>
|
||||||
|
<field name="answer_text">Inventory → Operations</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_pur_1_2" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_purchase_order"/>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="question_text">A Request for Quotation (RFQ) becomes a Purchase Order when confirmed.</field>
|
||||||
|
<field name="question_type">single</field>
|
||||||
|
<field name="points">1</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_pur_1_2_true" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_pur_1_2"/>
|
||||||
|
<field name="answer_text">True</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_pur_1_2_false" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_pur_1_2"/>
|
||||||
|
<field name="answer_text">False</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_pur_1_3" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_purchase_order"/>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="question_text">Which fields are required when creating a purchase order?</field>
|
||||||
|
<field name="question_type">multiple</field>
|
||||||
|
<field name="points">2</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_pur_1_3_a" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_pur_1_3"/>
|
||||||
|
<field name="answer_text">Vendor</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_pur_1_3_b" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_pur_1_3"/>
|
||||||
|
<field name="answer_text">Product lines</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_pur_1_3_c" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_pur_1_3"/>
|
||||||
|
<field name="answer_text">Delivery address</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_pur_1_4" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_purchase_order"/>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
<field name="question_text">What is the abbreviation for Request for Quotation?</field>
|
||||||
|
<field name="question_type">short_answer</field>
|
||||||
|
<field name="correct_short_answer">RFQ</field>
|
||||||
|
<field name="case_sensitive">False</field>
|
||||||
|
<field name="points">1</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_pur_1_5" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_purchase_order"/>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="question_text">Receiving products creates inventory movements automatically.</field>
|
||||||
|
<field name="question_type">single</field>
|
||||||
|
<field name="points">1</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_pur_1_5_true" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_pur_1_5"/>
|
||||||
|
<field name="answer_text">True</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_pur_1_5_false" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_pur_1_5"/>
|
||||||
|
<field name="answer_text">False</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ===================== TOUR 1: Purchase Order Basics ===================== -->
|
||||||
|
<record id="tour_purchase_order" model="genius.topic">
|
||||||
|
<field name="name">Creating a Purchase Order</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="starting_url">/web</field>
|
||||||
|
<field name="module_xml_id">purchase</field>
|
||||||
|
<field name="tag_ids" eval="[(6, 0, [ref('tag_beginner'), ref('tag_essential')])]"/>
|
||||||
|
<field name="quiz_id" ref="quiz_purchase_order"/>
|
||||||
|
<field name="duration_minutes">15</field>
|
||||||
|
<field name="icon">fa-shopping-cart</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<!-- =====================================================================
|
||||||
|
SALES TOURS AND QUIZZES
|
||||||
|
Module: sale
|
||||||
|
===================================================================== -->
|
||||||
|
|
||||||
|
<!-- ===================== QUIZ 1: First Quotation ===================== -->
|
||||||
|
<record id="quiz_sales_first_quotation" model="genius.quiz">
|
||||||
|
<field name="name">First Quotation Quiz</field>
|
||||||
|
<field name="description"><![CDATA[
|
||||||
|
<p>Test your knowledge on creating quotations in Odoo Sales.</p>
|
||||||
|
]]></field>
|
||||||
|
<field name="time_limit_minutes">10</field>
|
||||||
|
<field name="passing_score">70</field>
|
||||||
|
<field name="max_attempts">3</field>
|
||||||
|
<field name="shuffle_questions">True</field>
|
||||||
|
<field name="show_correct_answers">True</field>
|
||||||
|
<field name="success_message"><![CDATA[
|
||||||
|
<p>🎉 <strong>Congratulations!</strong> You've mastered the basics of creating quotations!</p>
|
||||||
|
]]></field>
|
||||||
|
<field name="fail_message"><![CDATA[
|
||||||
|
<p>📚 Keep practicing! Review the tour and try again.</p>
|
||||||
|
]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Quiz 1 Questions -->
|
||||||
|
<record id="q_sales_1_1" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_sales_first_quotation"/>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="question_text">Where do you create a new quotation in Odoo?</field>
|
||||||
|
<field name="question_type">single</field>
|
||||||
|
<field name="points">1</field>
|
||||||
|
<field name="explanation"><![CDATA[<p>Quotations are created from Sales → Orders → Quotations menu.</p>]]></field>
|
||||||
|
</record>
|
||||||
|
<record id="a_sales_1_1_a" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_sales_1_1"/>
|
||||||
|
<field name="answer_text">Sales → Orders → Quotations</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
<field name="feedback">Correct! This is the main location for managing quotations.</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_sales_1_1_b" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_sales_1_1"/>
|
||||||
|
<field name="answer_text">Inventory → Operations</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_sales_1_1_c" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_sales_1_1"/>
|
||||||
|
<field name="answer_text">Accounting → Invoices</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_sales_1_2" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_sales_first_quotation"/>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="question_text">A quotation becomes a Sales Order when confirmed.</field>
|
||||||
|
<field name="question_type">single</field>
|
||||||
|
<field name="points">1</field>
|
||||||
|
<field name="explanation"><![CDATA[<p>When you confirm a quotation, it automatically converts to a Sales Order.</p>]]></field>
|
||||||
|
</record>
|
||||||
|
<record id="a_sales_1_2_true" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_sales_1_2"/>
|
||||||
|
<field name="answer_text">True</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_sales_1_2_false" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_sales_1_2"/>
|
||||||
|
<field name="answer_text">False</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_sales_1_3" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_sales_first_quotation"/>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="question_text">Which fields are mandatory when creating a quotation?</field>
|
||||||
|
<field name="question_type">multiple</field>
|
||||||
|
<field name="points">2</field>
|
||||||
|
<field name="explanation"><![CDATA[<p>Customer is required, and at least one order line with a product.</p>]]></field>
|
||||||
|
</record>
|
||||||
|
<record id="a_sales_1_3_a" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_sales_1_3"/>
|
||||||
|
<field name="answer_text">Customer</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_sales_1_3_b" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_sales_1_3"/>
|
||||||
|
<field name="answer_text">Product in order lines</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_sales_1_3_c" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_sales_1_3"/>
|
||||||
|
<field name="answer_text">Payment Terms</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_sales_1_3_d" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_sales_1_3"/>
|
||||||
|
<field name="answer_text">Fiscal Position</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_sales_1_4" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_sales_first_quotation"/>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
<field name="question_text">What is the keyboard shortcut to save a record in Odoo?</field>
|
||||||
|
<field name="question_type">short_answer</field>
|
||||||
|
<field name="correct_short_answer">Ctrl+S</field>
|
||||||
|
<field name="case_sensitive">False</field>
|
||||||
|
<field name="points">1</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_sales_1_5" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_sales_first_quotation"/>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="question_text">What happens to the quotation expiration date if left empty?</field>
|
||||||
|
<field name="question_type">single</field>
|
||||||
|
<field name="points">1</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_sales_1_5_a" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_sales_1_5"/>
|
||||||
|
<field name="answer_text">The quotation never expires</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_sales_1_5_b" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_sales_1_5"/>
|
||||||
|
<field name="answer_text">It expires in 30 days</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_sales_1_5_c" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_sales_1_5"/>
|
||||||
|
<field name="answer_text">The system shows an error</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ===================== TOUR 1: First Quotation ===================== -->
|
||||||
|
<record id="tour_sales_first_quotation" model="genius.topic">
|
||||||
|
<field name="name">Creating Your First Quotation</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="starting_url">/web</field>
|
||||||
|
<field name="module_xml_id">sale</field>
|
||||||
|
<field name="tag_ids" eval="[(6, 0, [ref('tag_beginner'), ref('tag_essential')])]"/>
|
||||||
|
<field name="quiz_id" ref="quiz_sales_first_quotation"/>
|
||||||
|
<field name="duration_minutes">10</field>
|
||||||
|
<field name="icon">fa-file-text-o</field>
|
||||||
|
<field name="video_url">https://www.youtube.com/watch?v=dQw4w9WgXcQ</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<!-- =====================================================================
|
||||||
|
INVENTORY TOURS AND QUIZZES
|
||||||
|
Module: stock
|
||||||
|
===================================================================== -->
|
||||||
|
|
||||||
|
<!-- ===================== QUIZ 1: Receiving Products ===================== -->
|
||||||
|
<record id="quiz_stock_receiving" model="genius.quiz">
|
||||||
|
<field name="name">Receiving Products Quiz</field>
|
||||||
|
<field name="time_limit_minutes">10</field>
|
||||||
|
<field name="passing_score">70</field>
|
||||||
|
<field name="max_attempts">3</field>
|
||||||
|
<field name="shuffle_questions">True</field>
|
||||||
|
<field name="show_correct_answers">True</field>
|
||||||
|
<field name="success_message"><![CDATA[<p>🎉 Excellent! You've mastered product receiving!</p>]]></field>
|
||||||
|
<field name="fail_message"><![CDATA[<p>📦 Practice more with inventory operations.</p>]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_stock_1_1" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_stock_receiving"/>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="question_text">Where do you find incoming shipments to receive?</field>
|
||||||
|
<field name="question_type">single</field>
|
||||||
|
<field name="points">1</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_stock_1_1_a" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_stock_1_1"/>
|
||||||
|
<field name="answer_text">Inventory → Operations → Receipts</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_stock_1_1_b" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_stock_1_1"/>
|
||||||
|
<field name="answer_text">Purchase → Order Lines</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_stock_1_1_c" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_stock_1_1"/>
|
||||||
|
<field name="answer_text">Sales → Deliveries</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_stock_1_2" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_stock_receiving"/>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="question_text">Validating a receipt increases the stock quantity automatically.</field>
|
||||||
|
<field name="question_type">single</field>
|
||||||
|
<field name="points">1</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_stock_1_2_true" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_stock_1_2"/>
|
||||||
|
<field name="answer_text">True</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_stock_1_2_false" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_stock_1_2"/>
|
||||||
|
<field name="answer_text">False</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_stock_1_3" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_stock_receiving"/>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="question_text">What can you do if you receive fewer items than expected?</field>
|
||||||
|
<field name="question_type">multiple</field>
|
||||||
|
<field name="points">2</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_stock_1_3_a" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_stock_1_3"/>
|
||||||
|
<field name="answer_text">Update the "Done" quantity</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_stock_1_3_b" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_stock_1_3"/>
|
||||||
|
<field name="answer_text">Create a backorder for remaining quantity</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_stock_1_3_c" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_stock_1_3"/>
|
||||||
|
<field name="answer_text">Cancel and recreate the order</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_stock_1_4" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_stock_receiving"/>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
<field name="question_text">Barcode scanning can speed up receiving operations.</field>
|
||||||
|
<field name="question_type">single</field>
|
||||||
|
<field name="points">1</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_stock_1_4_true" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_stock_1_4"/>
|
||||||
|
<field name="answer_text">True</field>
|
||||||
|
<field name="is_correct">True</field>
|
||||||
|
</record>
|
||||||
|
<record id="a_stock_1_4_false" model="genius.quiz.answer">
|
||||||
|
<field name="question_id" ref="q_stock_1_4"/>
|
||||||
|
<field name="answer_text">False</field>
|
||||||
|
<field name="is_correct">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="q_stock_1_5" model="genius.quiz.question">
|
||||||
|
<field name="quiz_id" ref="quiz_stock_receiving"/>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="question_text">What is the default destination location for receipts?</field>
|
||||||
|
<field name="question_type">short_answer</field>
|
||||||
|
<field name="correct_short_answer">WH/Stock</field>
|
||||||
|
<field name="case_sensitive">False</field>
|
||||||
|
<field name="points">1</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ===================== TOUR 1: Receiving Products ===================== -->
|
||||||
|
<record id="tour_stock_receiving" model="genius.topic">
|
||||||
|
<field name="name">Receiving Products</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="starting_url">/web</field>
|
||||||
|
<field name="module_xml_id">stock</field>
|
||||||
|
<field name="tag_ids" eval="[(6, 0, [ref('tag_beginner'), ref('tag_essential')])]"/>
|
||||||
|
<field name="quiz_id" ref="quiz_stock_receiving"/>
|
||||||
|
<field name="duration_minutes">15</field>
|
||||||
|
<field name="icon">fa-truck</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Tour Genius Models - Flat Structure (Odoo Standard)
|
||||||
|
====================================================
|
||||||
|
|
||||||
|
This module follows Odoo standard flat structure.
|
||||||
|
No subfolders in models/ - all .py files at root level.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Mixins (load first)
|
||||||
|
from . import tour_mixin
|
||||||
|
|
||||||
|
# Core Models
|
||||||
|
from . import tour
|
||||||
|
from . import tour_step
|
||||||
|
from . import tour_plan
|
||||||
|
from . import tour_progress
|
||||||
|
from . import tour_tag
|
||||||
|
|
||||||
|
# Quiz (simplified)
|
||||||
|
from . import quiz
|
||||||
|
|
||||||
|
# Gamification
|
||||||
|
from . import leaderboard
|
||||||
|
|
||||||
|
# Reminders
|
||||||
|
from . import reminder
|
||||||
|
|
||||||
|
# User Extension
|
||||||
|
from . import res_users
|
||||||
|
|
@ -0,0 +1,339 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Leaderboard Model.
|
||||||
|
Gamification feature to rank users by training achievements.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import models, fields, api
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
class GeniusLeaderboard(models.Model):
|
||||||
|
_name = 'genius.leaderboard'
|
||||||
|
_description = 'Training Leaderboard'
|
||||||
|
_order = 'points desc, topics_completed desc'
|
||||||
|
|
||||||
|
user_id = fields.Many2one(
|
||||||
|
'res.users',
|
||||||
|
string='User',
|
||||||
|
required=True,
|
||||||
|
ondelete='cascade',
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Period
|
||||||
|
period_type = fields.Selection([
|
||||||
|
('weekly', 'Weekly'),
|
||||||
|
('monthly', 'Monthly'),
|
||||||
|
('alltime', 'All Time'),
|
||||||
|
], string='Period', required=True, default='alltime', index=True)
|
||||||
|
|
||||||
|
period_start = fields.Date(
|
||||||
|
string='Period Start',
|
||||||
|
help='Start date of this period'
|
||||||
|
)
|
||||||
|
period_end = fields.Date(
|
||||||
|
string='Period End',
|
||||||
|
help='End date of this period'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ranking
|
||||||
|
rank = fields.Integer(
|
||||||
|
string='Rank',
|
||||||
|
help='User rank in this period'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
points = fields.Integer(
|
||||||
|
string='Points',
|
||||||
|
default=0,
|
||||||
|
help='Total points earned'
|
||||||
|
)
|
||||||
|
topics_completed = fields.Integer(
|
||||||
|
string='Topics Completed',
|
||||||
|
default=0
|
||||||
|
)
|
||||||
|
quizzes_passed = fields.Integer(
|
||||||
|
string='Quizzes Passed',
|
||||||
|
default=0
|
||||||
|
)
|
||||||
|
time_spent_hours = fields.Float(
|
||||||
|
string='Time Spent (hours)',
|
||||||
|
default=0
|
||||||
|
)
|
||||||
|
streak_days = fields.Integer(
|
||||||
|
string='Streak (days)',
|
||||||
|
default=0,
|
||||||
|
help='Consecutive days of training'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Badges/Achievements
|
||||||
|
badge_count = fields.Integer(
|
||||||
|
string='Badges',
|
||||||
|
default=0
|
||||||
|
)
|
||||||
|
|
||||||
|
company_id = fields.Many2one(
|
||||||
|
'res.company',
|
||||||
|
string='Company',
|
||||||
|
default=lambda self: self.env.company
|
||||||
|
)
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('unique_user_period', 'UNIQUE(user_id, period_type, period_start, company_id)',
|
||||||
|
'Leaderboard entry already exists for this user and period!')
|
||||||
|
]
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Points System
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
POINTS = {
|
||||||
|
'topic_completed': 10,
|
||||||
|
'quiz_passed': 25,
|
||||||
|
'quiz_perfect': 50, # 100% score
|
||||||
|
'streak_7_days': 30,
|
||||||
|
'streak_30_days': 100,
|
||||||
|
'first_topic': 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Compute/Update Methods
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def calculate_user_stats(self, user_id, period_type='alltime', company_id=None):
|
||||||
|
"""Calculate stats for a user in a period"""
|
||||||
|
company_id = company_id or self.env.company.id
|
||||||
|
user = self.env['res.users'].browse(user_id)
|
||||||
|
|
||||||
|
# Determine date range
|
||||||
|
today = fields.Date.today()
|
||||||
|
period_start = False
|
||||||
|
period_end = False
|
||||||
|
|
||||||
|
if period_type == 'weekly':
|
||||||
|
period_start = today - timedelta(days=today.weekday())
|
||||||
|
period_end = period_start + timedelta(days=6)
|
||||||
|
elif period_type == 'monthly':
|
||||||
|
period_start = today.replace(day=1)
|
||||||
|
next_month = today.replace(day=28) + timedelta(days=4)
|
||||||
|
period_end = next_month - timedelta(days=next_month.day)
|
||||||
|
|
||||||
|
# Build progress domain
|
||||||
|
domain = [
|
||||||
|
('user_id', '=', user_id),
|
||||||
|
('state', 'in', ['done', 'verified']),
|
||||||
|
]
|
||||||
|
if period_start:
|
||||||
|
domain.append(('date_completed', '>=', period_start))
|
||||||
|
if period_end:
|
||||||
|
domain.append(('date_completed', '<=', period_end))
|
||||||
|
|
||||||
|
progress_records = self.env['genius.progress'].search(domain)
|
||||||
|
|
||||||
|
# Calculate metrics
|
||||||
|
topics_completed = len(progress_records)
|
||||||
|
|
||||||
|
# Quiz stats
|
||||||
|
quiz_domain = [('user_id', '=', user_id), ('is_passed', '=', True)]
|
||||||
|
if period_start:
|
||||||
|
quiz_domain.append(('submitted_at', '>=', period_start))
|
||||||
|
if period_end:
|
||||||
|
quiz_domain.append(('submitted_at', '<=', period_end))
|
||||||
|
|
||||||
|
quiz_attempts = self.env['genius.quiz.attempt'].search(quiz_domain)
|
||||||
|
quizzes_passed = len(quiz_attempts)
|
||||||
|
# Use integer comparison for robustness (Float 100.0 vs 100 issue)
|
||||||
|
perfect_quizzes = len(quiz_attempts.filtered(lambda q: q.points_possible > 0 and q.points_earned == q.points_possible))
|
||||||
|
|
||||||
|
# Time spent
|
||||||
|
time_spent = sum(progress_records.mapped('time_spent_minutes')) / 60.0
|
||||||
|
|
||||||
|
# Calculate points
|
||||||
|
points = (
|
||||||
|
topics_completed * self.POINTS['topic_completed'] +
|
||||||
|
quizzes_passed * self.POINTS['quiz_passed'] +
|
||||||
|
perfect_quizzes * self.POINTS['quiz_perfect']
|
||||||
|
)
|
||||||
|
|
||||||
|
# First topic bonus
|
||||||
|
if topics_completed > 0:
|
||||||
|
first_progress = self.env['genius.progress'].search([
|
||||||
|
('user_id', '=', user_id),
|
||||||
|
], order='date_completed', limit=1)
|
||||||
|
if first_progress and period_start and first_progress.date_completed:
|
||||||
|
if first_progress.date_completed.date() >= period_start:
|
||||||
|
points += self.POINTS['first_topic']
|
||||||
|
|
||||||
|
return {
|
||||||
|
'points': points,
|
||||||
|
'topics_completed': topics_completed,
|
||||||
|
'quizzes_passed': quizzes_passed,
|
||||||
|
'time_spent_hours': round(time_spent, 2),
|
||||||
|
'period_start': period_start,
|
||||||
|
'period_end': period_end,
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def update_leaderboard(self, period_type='alltime', company_id=None):
|
||||||
|
"""
|
||||||
|
Update leaderboard rankings for the given period.
|
||||||
|
Optimized with read_group for scalability.
|
||||||
|
"""
|
||||||
|
company_id = company_id or self.env.company.id
|
||||||
|
today = fields.Date.today()
|
||||||
|
|
||||||
|
# 1. Determine Date Range
|
||||||
|
period_start = False
|
||||||
|
period_end = False
|
||||||
|
|
||||||
|
if period_type == 'weekly':
|
||||||
|
# Start of week (Monday)
|
||||||
|
period_start = today - timedelta(days=today.weekday())
|
||||||
|
period_end = period_start + timedelta(days=6)
|
||||||
|
elif period_type == 'monthly':
|
||||||
|
period_start = today.replace(day=1)
|
||||||
|
# End of month
|
||||||
|
next_month = today.replace(day=28) + timedelta(days=4)
|
||||||
|
period_end = next_month - timedelta(days=next_month.day)
|
||||||
|
|
||||||
|
# 2. Build Domains
|
||||||
|
# Base domain: Completed topics for this company
|
||||||
|
# We assume GeniusProgress.user_id.company_id match? usually company_id isn't on progress.
|
||||||
|
# But user_id is. Filter users by company?
|
||||||
|
# For simplicity, we filter progress by date. User company check can be done after.
|
||||||
|
|
||||||
|
base_domain = [('state', 'in', ['done', 'verified'])]
|
||||||
|
if period_start:
|
||||||
|
base_domain.append(('date_completed', '>=', period_start))
|
||||||
|
if period_end:
|
||||||
|
base_domain.append(('date_completed', '<=', period_end))
|
||||||
|
|
||||||
|
# 3. Batch Aggregation (Main Stats)
|
||||||
|
# fields: points_earned (sum), time_spent_seconds (sum), count (topics)
|
||||||
|
main_stats = self.env['genius.progress'].read_group(
|
||||||
|
base_domain,
|
||||||
|
['user_id', 'points_earned:sum', 'time_spent_seconds:sum'],
|
||||||
|
['user_id']
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Batch Aggregation (Quiz Pass Count)
|
||||||
|
# Need separate query for "how many quizzes passed"
|
||||||
|
quiz_domain = base_domain + [('is_quiz_passed', '=', True)]
|
||||||
|
quiz_stats = self.env['genius.progress'].read_group(
|
||||||
|
quiz_domain,
|
||||||
|
['user_id'],
|
||||||
|
['user_id']
|
||||||
|
)
|
||||||
|
quiz_counts = {r['user_id'][0]: r['user_id_count'] for r in quiz_stats}
|
||||||
|
|
||||||
|
# 5. Process Users
|
||||||
|
entries_to_create = []
|
||||||
|
entries_to_update = {} # ID -> Vals
|
||||||
|
|
||||||
|
# Prepare existing entries map to update efficiently
|
||||||
|
existing_entries = self.search([
|
||||||
|
('period_type', '=', period_type),
|
||||||
|
('period_start', '=', period_start),
|
||||||
|
('company_id', '=', company_id)
|
||||||
|
])
|
||||||
|
existing_map = {e.user_id.id: e for e in existing_entries}
|
||||||
|
|
||||||
|
entries = [] # For ranking
|
||||||
|
|
||||||
|
for group in main_stats:
|
||||||
|
user_id = group['user_id'][0]
|
||||||
|
|
||||||
|
# Skip if user not in company?
|
||||||
|
# user = self.env['res.users'].browse(user_id) # Costly
|
||||||
|
# We skip strict company check for speed, assumming visibility rules handle it.
|
||||||
|
|
||||||
|
completed_count = group['user_id_count']
|
||||||
|
points = group['points_earned']
|
||||||
|
time_seconds = group['time_spent_seconds']
|
||||||
|
quizzes = quiz_counts.get(user_id, 0)
|
||||||
|
|
||||||
|
# Add First Topic Bonus (Legacy support, optional, skipping for batch speed)
|
||||||
|
# If strictly required, query here.
|
||||||
|
|
||||||
|
vals = {
|
||||||
|
'points': points,
|
||||||
|
'topics_completed': completed_count,
|
||||||
|
'quizzes_passed': quizzes,
|
||||||
|
'time_spent_hours': time_seconds / 3600.0,
|
||||||
|
'period_start': period_start,
|
||||||
|
'period_end': period_end,
|
||||||
|
'user_id': user_id,
|
||||||
|
'period_type': period_type,
|
||||||
|
'company_id': company_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
existing = existing_map.get(user_id)
|
||||||
|
if existing:
|
||||||
|
existing.write(vals)
|
||||||
|
entries.append(existing)
|
||||||
|
else:
|
||||||
|
entries_to_create.append(vals)
|
||||||
|
|
||||||
|
# Bulk Create
|
||||||
|
if entries_to_create:
|
||||||
|
created = self.create(entries_to_create)
|
||||||
|
entries.extend(created)
|
||||||
|
|
||||||
|
# 6. Rank Update
|
||||||
|
sorted_entries = sorted(entries, key=lambda e: (-e.points, -e.topics_completed))
|
||||||
|
for rank, entry in enumerate(sorted_entries, 1):
|
||||||
|
entry.rank = rank
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# API Methods
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def get_leaderboard(self, period_type='alltime', limit=10):
|
||||||
|
"""Get leaderboard for display"""
|
||||||
|
entries = self.search([
|
||||||
|
('period_type', '=', period_type),
|
||||||
|
('company_id', '=', self.env.company.id),
|
||||||
|
], order='rank', limit=limit)
|
||||||
|
|
||||||
|
return [{
|
||||||
|
'rank': e.rank,
|
||||||
|
'user_id': e.user_id.id,
|
||||||
|
'user_name': e.user_id.name,
|
||||||
|
'user_image': e.user_id.image_128,
|
||||||
|
'points': e.points,
|
||||||
|
'topics_completed': e.topics_completed,
|
||||||
|
'quizzes_passed': e.quizzes_passed,
|
||||||
|
'time_spent_hours': e.time_spent_hours,
|
||||||
|
} for e in entries]
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_user_rank(self, user_id=None, period_type='alltime'):
|
||||||
|
"""Get rank for a specific user"""
|
||||||
|
user_id = user_id or self.env.user.id
|
||||||
|
|
||||||
|
entry = self.search([
|
||||||
|
('user_id', '=', user_id),
|
||||||
|
('period_type', '=', period_type),
|
||||||
|
('company_id', '=', self.env.company.id),
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
if not entry:
|
||||||
|
return {
|
||||||
|
'rank': None,
|
||||||
|
'points': 0,
|
||||||
|
'message': 'Not ranked yet',
|
||||||
|
}
|
||||||
|
|
||||||
|
total_entries = self.search_count([
|
||||||
|
('period_type', '=', period_type),
|
||||||
|
('company_id', '=', self.env.company.id),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
'rank': entry.rank,
|
||||||
|
'points': entry.points,
|
||||||
|
'total_users': total_entries,
|
||||||
|
'percentile': round((1 - (entry.rank / total_entries)) * 100, 1) if total_entries else 0,
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,278 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Reminder Model.
|
||||||
|
Automated reminders for incomplete training.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import models, fields, api, _
|
||||||
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GeniusReminder(models.Model):
|
||||||
|
_name = 'genius.reminder'
|
||||||
|
_description = 'Training Reminder'
|
||||||
|
_order = 'scheduled_date'
|
||||||
|
_inherit = ['mail.thread']
|
||||||
|
|
||||||
|
name = fields.Char(
|
||||||
|
string='Subject',
|
||||||
|
required=True,
|
||||||
|
default=_("Training Reminder")
|
||||||
|
)
|
||||||
|
user_id = fields.Many2one(
|
||||||
|
'res.users',
|
||||||
|
string='User',
|
||||||
|
required=True,
|
||||||
|
ondelete='cascade',
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# What to remind about
|
||||||
|
reminder_type = fields.Selection([
|
||||||
|
('incomplete_topic', 'Incomplete Topic'),
|
||||||
|
('incomplete_plan', 'Incomplete Plan'),
|
||||||
|
('new_topic', 'New Topic Available'),
|
||||||
|
('quiz_due', 'Quiz Due'),
|
||||||
|
('streak', 'Keep Your Streak'),
|
||||||
|
('custom', 'Custom Message'),
|
||||||
|
], string='Type', required=True, default='incomplete_topic')
|
||||||
|
|
||||||
|
topic_id = fields.Many2one(
|
||||||
|
'genius.topic',
|
||||||
|
string='Topic',
|
||||||
|
help='Related topic (if applicable)'
|
||||||
|
)
|
||||||
|
plan_id = fields.Many2one(
|
||||||
|
'genius.plan',
|
||||||
|
string='Plan',
|
||||||
|
help='Related plan (if applicable)'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Scheduling
|
||||||
|
scheduled_date = fields.Datetime(
|
||||||
|
string='Scheduled For',
|
||||||
|
required=True,
|
||||||
|
default=lambda self: fields.Datetime.now() + timedelta(days=1)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
state = fields.Selection([
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('sent', 'Sent'),
|
||||||
|
('cancelled', 'Cancelled'),
|
||||||
|
('failed', 'Failed'),
|
||||||
|
], string='Status', default='pending', required=True, index=True)
|
||||||
|
|
||||||
|
sent_date = fields.Datetime(string='Sent At')
|
||||||
|
|
||||||
|
# Message Content
|
||||||
|
message_body = fields.Html(
|
||||||
|
string='Message',
|
||||||
|
compute='_compute_message_body',
|
||||||
|
store=True
|
||||||
|
)
|
||||||
|
custom_message = fields.Html(
|
||||||
|
string='Custom Message',
|
||||||
|
help='Custom message for custom reminder type'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Repeat Settings
|
||||||
|
is_recurring = fields.Boolean(
|
||||||
|
string='Recurring',
|
||||||
|
default=False
|
||||||
|
)
|
||||||
|
recurrence_interval = fields.Integer(
|
||||||
|
string='Repeat Every (days)',
|
||||||
|
default=7
|
||||||
|
)
|
||||||
|
|
||||||
|
company_id = fields.Many2one(
|
||||||
|
'res.company',
|
||||||
|
string='Company',
|
||||||
|
default=lambda self: self.env.company
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Computed Fields
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
@api.depends('reminder_type', 'topic_id', 'plan_id', 'user_id', 'custom_message')
|
||||||
|
def _compute_message_body(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.reminder_type == 'custom' and rec.custom_message:
|
||||||
|
rec.message_body = rec.custom_message
|
||||||
|
elif rec.reminder_type == 'incomplete_topic' and rec.topic_id:
|
||||||
|
rec.message_body = _("""
|
||||||
|
<p>Hi %(user)s,</p>
|
||||||
|
<p>You haven't completed the training topic: <strong>%(topic)s</strong></p>
|
||||||
|
<p>Continue your learning journey today!</p>
|
||||||
|
""") % {
|
||||||
|
'user': rec.user_id.name,
|
||||||
|
'topic': rec.topic_id.name,
|
||||||
|
}
|
||||||
|
elif rec.reminder_type == 'incomplete_plan' and rec.plan_id:
|
||||||
|
rec.message_body = _("""
|
||||||
|
<p>Hi %(user)s,</p>
|
||||||
|
<p>You have an incomplete training plan: <strong>%(plan)s</strong></p>
|
||||||
|
<p>Keep up your progress!</p>
|
||||||
|
""") % {
|
||||||
|
'user': rec.user_id.name,
|
||||||
|
'plan': rec.plan_id.name,
|
||||||
|
}
|
||||||
|
elif rec.reminder_type == 'streak':
|
||||||
|
rec.message_body = _("""
|
||||||
|
<p>Hi %(user)s,</p>
|
||||||
|
<p>Don't break your learning streak!</p>
|
||||||
|
<p>Complete a quick topic today to keep your momentum.</p>
|
||||||
|
""") % {'user': rec.user_id.name}
|
||||||
|
elif rec.reminder_type == 'new_topic' and rec.topic_id:
|
||||||
|
rec.message_body = _("""
|
||||||
|
<p>Hi %(user)s,</p>
|
||||||
|
<p>A new training topic is available: <strong>%(topic)s</strong></p>
|
||||||
|
<p>Check it out!</p>
|
||||||
|
""") % {
|
||||||
|
'user': rec.user_id.name,
|
||||||
|
'topic': rec.topic_id.name,
|
||||||
|
}
|
||||||
|
elif rec.reminder_type == 'quiz_due' and rec.topic_id:
|
||||||
|
rec.message_body = _("""
|
||||||
|
<p>Hi %(user)s,</p>
|
||||||
|
<p>You have completed the tour <strong>%(topic)s</strong>, but haven't passed the quiz yet.</p>
|
||||||
|
<p>Take the quiz now to get Certified!</p>
|
||||||
|
""") % {
|
||||||
|
'user': rec.user_id.name,
|
||||||
|
'topic': rec.topic_id.name,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
rec.message_body = _("<p>You have a training reminder.</p>")
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Actions
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
def action_send(self):
|
||||||
|
"""Send the reminder now"""
|
||||||
|
for rec in self:
|
||||||
|
rec._send_reminder()
|
||||||
|
|
||||||
|
def action_cancel(self):
|
||||||
|
"""Cancel the reminder"""
|
||||||
|
self.write({'state': 'cancelled'})
|
||||||
|
|
||||||
|
def _send_reminder(self):
|
||||||
|
"""Actually send the reminder email"""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
# Validate email before attempting to send
|
||||||
|
if not self.user_id.email:
|
||||||
|
_logger.warning("Cannot send reminder %s: user %s has no email",
|
||||||
|
self.id, self.user_id.name)
|
||||||
|
self.write({'state': 'failed'})
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
template = self.env.ref('tour_genius.email_template_training_reminder', raise_if_not_found=False)
|
||||||
|
|
||||||
|
if template:
|
||||||
|
template.send_mail(self.id, force_send=True)
|
||||||
|
else:
|
||||||
|
# Fallback: send simple email
|
||||||
|
self.env['mail.mail'].create({
|
||||||
|
'subject': self.name,
|
||||||
|
'body_html': self.message_body,
|
||||||
|
'email_to': self.user_id.email,
|
||||||
|
'auto_delete': True,
|
||||||
|
}).send()
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'state': 'sent',
|
||||||
|
'sent_date': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Handle recurrence
|
||||||
|
if self.is_recurring:
|
||||||
|
self.copy({
|
||||||
|
'scheduled_date': fields.Datetime.now() + timedelta(days=self.recurrence_interval),
|
||||||
|
'state': 'pending',
|
||||||
|
'sent_date': False,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.write({'state': 'failed'})
|
||||||
|
raise
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Scheduled Actions
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def _cron_send_reminders(self):
|
||||||
|
"""Cron job to send pending reminders"""
|
||||||
|
pending = self.search([
|
||||||
|
('state', '=', 'pending'),
|
||||||
|
('scheduled_date', '<=', fields.Datetime.now()),
|
||||||
|
])
|
||||||
|
|
||||||
|
for reminder in pending:
|
||||||
|
try:
|
||||||
|
reminder._send_reminder()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception("Failed to send reminder %s: %s", reminder.id, str(e))
|
||||||
|
# State already marked as 'failed' in _send_reminder
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cron_create_auto_reminders(self):
|
||||||
|
"""Create automatic reminders for users with incomplete training or pending quizzes"""
|
||||||
|
# 1. Stale Progress (Incomplete > 3 days)
|
||||||
|
three_days_ago = fields.Datetime.now() - timedelta(days=3)
|
||||||
|
|
||||||
|
stale_progress = self.env['genius.progress'].search([
|
||||||
|
('state', '=', 'in_progress'),
|
||||||
|
('date_started', '<=', three_days_ago),
|
||||||
|
])
|
||||||
|
|
||||||
|
for progress in stale_progress:
|
||||||
|
# Check if reminder already exists
|
||||||
|
existing = self.search([
|
||||||
|
('user_id', '=', progress.user_id.id),
|
||||||
|
('topic_id', '=', progress.topic_id.id),
|
||||||
|
('reminder_type', '=', 'incomplete_topic'),
|
||||||
|
('state', '=', 'pending'),
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
self.create({
|
||||||
|
'name': _("Continue your training: %s") % progress.topic_id.name,
|
||||||
|
'user_id': progress.user_id.id,
|
||||||
|
'reminder_type': 'incomplete_topic',
|
||||||
|
'topic_id': progress.topic_id.id,
|
||||||
|
'plan_id': progress.plan_id.id if progress.plan_id else False,
|
||||||
|
'scheduled_date': fields.Datetime.now() + timedelta(hours=24),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. Quiz Reminders (Completed > 1 day but Not Verified, and Quiz exists)
|
||||||
|
one_day_ago = fields.Datetime.now() - timedelta(days=1)
|
||||||
|
unverified_done = self.env['genius.progress'].search([
|
||||||
|
('state', '=', 'done'),
|
||||||
|
('date_completed', '<=', one_day_ago),
|
||||||
|
])
|
||||||
|
|
||||||
|
for progress in unverified_done:
|
||||||
|
# Check if Topic has a Quiz
|
||||||
|
if progress.topic_id.quiz_id:
|
||||||
|
# Check if already reminded
|
||||||
|
existing = self.search([
|
||||||
|
('user_id', '=', progress.user_id.id),
|
||||||
|
('topic_id', '=', progress.topic_id.id),
|
||||||
|
('reminder_type', '=', 'quiz_due'),
|
||||||
|
('state', 'in', ['pending', 'sent']), # Don't spam if sent
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
self.create({
|
||||||
|
'name': _("Get Certified: %s") % progress.topic_id.name,
|
||||||
|
'user_id': progress.user_id.id,
|
||||||
|
'reminder_type': 'quiz_due',
|
||||||
|
'topic_id': progress.topic_id.id,
|
||||||
|
'scheduled_date': fields.Datetime.now() + timedelta(hours=24),
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
|
||||||
|
class ResUsers(models.Model):
|
||||||
|
"""
|
||||||
|
Extension of res.users to add accessible modules helper.
|
||||||
|
"""
|
||||||
|
_inherit = 'res.users'
|
||||||
|
|
||||||
|
def _get_accessible_modules(self):
|
||||||
|
"""
|
||||||
|
Get list of module names this user can access.
|
||||||
|
Called from record rules for dynamic domain filtering.
|
||||||
|
|
||||||
|
:return: list of module technical names
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
return self.env['genius.module.access.mixin']._get_user_accessible_modules(self)
|
||||||
|
|
||||||
|
# Training progress summary
|
||||||
|
# NOTE: These are non-stored computed fields that query genius.progress
|
||||||
|
# on every access. They have no dependencies because they read from
|
||||||
|
# a different model (genius.progress), not fields on res.users.
|
||||||
|
genius_completed_topics = fields.Integer(
|
||||||
|
string='Completed Topics',
|
||||||
|
compute='_compute_genius_stats',
|
||||||
|
help='Number of training topics completed by this user'
|
||||||
|
)
|
||||||
|
genius_in_progress_topics = fields.Integer(
|
||||||
|
string='In Progress Topics',
|
||||||
|
compute='_compute_genius_stats',
|
||||||
|
help='Number of training topics currently in progress'
|
||||||
|
)
|
||||||
|
genius_total_time_hours = fields.Float(
|
||||||
|
string='Total Training Time (Hours)',
|
||||||
|
compute='_compute_genius_stats',
|
||||||
|
help='Total time spent on training'
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends() # No dependencies: always recomputes when accessed (non-stored)
|
||||||
|
def _compute_genius_stats(self):
|
||||||
|
"""
|
||||||
|
Compute training statistics for users.
|
||||||
|
|
||||||
|
Note: Uses @api.depends() with no arguments because these stats
|
||||||
|
depend on genius.progress records, not res.users fields. Since the
|
||||||
|
fields are non-stored, they recompute fresh on every read.
|
||||||
|
"""
|
||||||
|
Progress = self.env['genius.progress'].sudo()
|
||||||
|
for user in self:
|
||||||
|
completed = Progress.search_count([
|
||||||
|
('user_id', '=', user.id),
|
||||||
|
('state', 'in', ('done', 'verified')),
|
||||||
|
])
|
||||||
|
in_progress = Progress.search_count([
|
||||||
|
('user_id', '=', user.id),
|
||||||
|
('state', '=', 'in_progress'),
|
||||||
|
])
|
||||||
|
total_time = sum(Progress.search([
|
||||||
|
('user_id', '=', user.id),
|
||||||
|
]).mapped('time_spent_minutes'))
|
||||||
|
|
||||||
|
user.genius_completed_topics = completed
|
||||||
|
user.genius_in_progress_topics = in_progress
|
||||||
|
user.genius_total_time_hours = total_time / 60.0 if total_time else 0.0
|
||||||
|
|
@ -0,0 +1,949 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Tour Model (Simplified)
|
||||||
|
=======================
|
||||||
|
Main model for training tours.
|
||||||
|
Removed complex dependencies on registry models.
|
||||||
|
Uses standard Odoo models (ir.module.module, ir.model, ir.actions.act_window).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class GeniusTour(models.Model):
|
||||||
|
"""
|
||||||
|
Training Tour (previously genius.topic).
|
||||||
|
Simplified model without registry dependencies.
|
||||||
|
"""
|
||||||
|
_name = 'genius.topic'
|
||||||
|
_description = 'Training Tour'
|
||||||
|
_order = 'sequence, id'
|
||||||
|
|
||||||
|
name = fields.Char(string='Tour Title', required=True, translate=True)
|
||||||
|
sequence = fields.Integer(string='Order', default=10)
|
||||||
|
|
||||||
|
# Plan link (optional - tour can exist standalone)
|
||||||
|
plan_id = fields.Many2one('genius.plan', string='Training Plan', ondelete='set null')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Starting URL (main tour entry point)
|
||||||
|
starting_url = fields.Char(
|
||||||
|
string='Starting URL',
|
||||||
|
help='URL where this tour begins (e.g., /web#action=123)'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Media
|
||||||
|
image = fields.Binary(string='Image', attachment=True)
|
||||||
|
video_url = fields.Char(string='Video URL')
|
||||||
|
document = fields.Binary(string='Document (PDF)', attachment=True)
|
||||||
|
|
||||||
|
# Target Module and Model (Many2one for searchability)
|
||||||
|
module_id = fields.Many2one(
|
||||||
|
'ir.module.module',
|
||||||
|
string='Target Module',
|
||||||
|
domain="[('state', '=', 'installed')]",
|
||||||
|
help='Select the target module for this tour'
|
||||||
|
)
|
||||||
|
module_xml_id = fields.Char(related='module_id.name', string='Module Technical Name', store=True)
|
||||||
|
|
||||||
|
# Computed display name for module
|
||||||
|
module_name = fields.Char(
|
||||||
|
string='Module Display Name',
|
||||||
|
compute='_compute_module_name',
|
||||||
|
store=True
|
||||||
|
)
|
||||||
|
action_id = fields.Many2one(
|
||||||
|
'ir.actions.act_window',
|
||||||
|
string='Navigation Action',
|
||||||
|
help='Action to open when starting this tour'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tags
|
||||||
|
tag_ids = fields.Many2many('genius.tour.tag', 'genius_topic_tag_rel',
|
||||||
|
'topic_id', 'tag_id', string='Tags')
|
||||||
|
|
||||||
|
# Tour Steps
|
||||||
|
step_ids = fields.One2many('genius.topic.step', 'topic_id', string='Tour Steps')
|
||||||
|
step_count = fields.Integer(string='Step Count', compute='_compute_step_count', store=True)
|
||||||
|
|
||||||
|
# Consumed By Tracking
|
||||||
|
consumed_user_ids = fields.Many2many('res.users', 'genius_topic_consumed_rel',
|
||||||
|
'topic_id', 'user_id', string='Completed By')
|
||||||
|
consumed_count = fields.Integer(string='Completions', compute='_compute_consumed_count', store=True)
|
||||||
|
|
||||||
|
# Prerequisites
|
||||||
|
prerequisite_ids = fields.Many2many('genius.topic', 'genius_topic_prereq_rel',
|
||||||
|
'topic_id', 'prereq_id', string='Prerequisites')
|
||||||
|
|
||||||
|
# Quiz Link
|
||||||
|
quiz_id = fields.Many2one('genius.quiz', string='Quiz')
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
duration_minutes = fields.Integer(string='Duration (Minutes)', default=15)
|
||||||
|
icon = fields.Char(string='Icon', default='fa-graduation-cap', help='Font Awesome icon class (e.g., fa-book, fa-cog)')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Progress
|
||||||
|
progress_ids = fields.One2many('genius.progress', 'topic_id', string='Progress Records')
|
||||||
|
|
||||||
|
# State Workflow
|
||||||
|
state = fields.Selection([
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('published', 'Published'),
|
||||||
|
], string='Status', default='draft', tracking=True,
|
||||||
|
help='Draft: Only visible to admins. Published: Visible to all users.')
|
||||||
|
|
||||||
|
active = fields.Boolean(string='Active', default=True)
|
||||||
|
company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Compute Methods
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
@api.depends('step_ids')
|
||||||
|
def _compute_step_count(self):
|
||||||
|
for tour in self:
|
||||||
|
tour.step_count = len(tour.step_ids)
|
||||||
|
|
||||||
|
@api.depends('consumed_user_ids')
|
||||||
|
def _compute_consumed_count(self):
|
||||||
|
for tour in self:
|
||||||
|
tour.consumed_count = len(tour.consumed_user_ids)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def unlink(self):
|
||||||
|
"""Prevent deleting tours that have user progress or completions."""
|
||||||
|
for tour in self:
|
||||||
|
# Check for completions
|
||||||
|
if tour.consumed_count > 0:
|
||||||
|
raise UserError(
|
||||||
|
_('Cannot delete tour "%s" because it has been completed by %d user(s). '
|
||||||
|
'Archive it instead (uncheck Active).') % (tour.name, tour.consumed_count)
|
||||||
|
)
|
||||||
|
# Check for ANY progress records (started, in-progress, etc.)
|
||||||
|
if tour.progress_ids:
|
||||||
|
raise UserError(
|
||||||
|
_('Cannot delete tour "%s" because it has %d progress record(s). '
|
||||||
|
'Archive it instead.') % (tour.name, len(tour.progress_ids))
|
||||||
|
)
|
||||||
|
# Check for linked quiz with attempts
|
||||||
|
if tour.quiz_id and tour.quiz_id.attempt_count > 0:
|
||||||
|
raise UserError(
|
||||||
|
_('Cannot delete tour "%s" because its linked quiz has user attempts. '
|
||||||
|
'Archive it instead.') % tour.name
|
||||||
|
)
|
||||||
|
return super(GeniusTour, self).unlink()
|
||||||
|
|
||||||
|
|
||||||
|
@api.depends('module_id')
|
||||||
|
def _compute_module_name(self):
|
||||||
|
for tour in self:
|
||||||
|
tour.module_name = tour.module_id.name if tour.module_id else ''
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Action Methods
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
def action_publish(self):
|
||||||
|
"""Publish tour - makes it visible to all users."""
|
||||||
|
return self.write({'state': 'published'})
|
||||||
|
|
||||||
|
def action_set_draft(self):
|
||||||
|
"""Set tour back to draft - only visible to admins."""
|
||||||
|
return self.write({'state': 'draft'})
|
||||||
|
|
||||||
|
def action_test_tour(self):
|
||||||
|
"""
|
||||||
|
Test tour without tracking progress (Admin/Instructor only).
|
||||||
|
Allows testing tours before publishing without affecting statistics.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
# Security check - only instructors can test
|
||||||
|
if not self.env.user.has_group('tour_genius.group_genius_instructor'):
|
||||||
|
raise UserError(_('Only instructors can test tours.'))
|
||||||
|
|
||||||
|
# Check if tour has steps
|
||||||
|
if not self.step_ids:
|
||||||
|
raise UserError(_('Cannot test a tour without steps. Add steps first.'))
|
||||||
|
|
||||||
|
# Get tour data for dynamic registration
|
||||||
|
tour_data = self.get_single_tour_for_test()
|
||||||
|
if tour_data.get('error'):
|
||||||
|
raise UserError(_(tour_data['error']))
|
||||||
|
|
||||||
|
# Return client action with tour data for dynamic registration
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'tour_genius_run_tour',
|
||||||
|
'params': {
|
||||||
|
'tour_name': 'genius_tour_' + str(self.id),
|
||||||
|
'test_mode': True,
|
||||||
|
'tour_data': tour_data, # Full tour data for dynamic JS registration
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_open_topic(self):
|
||||||
|
"""Start tour using Odoo's native tour.reset() via client action"""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
# Check prerequisites
|
||||||
|
can_start, missing = self.check_prerequisites()
|
||||||
|
if not can_start:
|
||||||
|
raise UserError(_('You must complete "%s" before starting this tour.') % missing.name)
|
||||||
|
|
||||||
|
# Mark as in progress
|
||||||
|
self._update_user_progress('in_progress')
|
||||||
|
|
||||||
|
# Return client action that will call tour.reset()
|
||||||
|
# This is the canonical Odoo pattern - no URL params needed
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'tour_genius_run_tour',
|
||||||
|
'params': {
|
||||||
|
'tour_name': 'genius_tour_' + str(self.id),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_mark_consumed(self):
|
||||||
|
"""Mark this tour as completed by current user"""
|
||||||
|
user = self.env.user
|
||||||
|
for tour in self:
|
||||||
|
if user not in tour.consumed_user_ids:
|
||||||
|
tour.write({'consumed_user_ids': [(4, user.id)]})
|
||||||
|
|
||||||
|
# ALWAYS update progress to track time/count/date for every completion
|
||||||
|
# The _update_user_progress method handles incrementing completion_count
|
||||||
|
tour._update_user_progress('done')
|
||||||
|
return True
|
||||||
|
|
||||||
|
def action_mark_skipped(self):
|
||||||
|
"""Mark this tour as skipped by current user (not completed)"""
|
||||||
|
user = self.env.user
|
||||||
|
for tour in self:
|
||||||
|
# Skipped tours are NOT added to consumed_user_ids
|
||||||
|
# They can try again later
|
||||||
|
tour._update_user_progress('skipped')
|
||||||
|
return True
|
||||||
|
|
||||||
|
def action_quick_preview(self):
|
||||||
|
"""Preview the tour"""
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_url',
|
||||||
|
'url': f'/web?genius_preview_tour={self.id}',
|
||||||
|
'target': 'new',
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_view_steps(self):
|
||||||
|
"""Open steps for this tour"""
|
||||||
|
self.ensure_one()
|
||||||
|
# Use specific embedded tree view for cleaner display
|
||||||
|
tree_view_id = self.env.ref('tour_genius.view_genius_topic_step_tree_embedded').id
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': f'Steps - {self.name}',
|
||||||
|
'res_model': 'genius.topic.step',
|
||||||
|
'view_mode': 'tree,form',
|
||||||
|
'views': [(tree_view_id, 'tree'), (False, 'form')],
|
||||||
|
'domain': [('topic_id', '=', self.id)],
|
||||||
|
'context': {'default_topic_id': self.id},
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_view_completions(self):
|
||||||
|
"""Open user progress records for this tour (Admin view)"""
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': f'Completions - {self.name}',
|
||||||
|
'res_model': 'genius.progress',
|
||||||
|
'view_mode': 'tree,form',
|
||||||
|
'domain': [('topic_id', '=', self.id)],
|
||||||
|
'context': {'default_topic_id': self.id},
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_start_recording(self):
|
||||||
|
"""Start the inline recorder panel"""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if self.starting_url:
|
||||||
|
target_url = self.starting_url
|
||||||
|
elif self.action_id:
|
||||||
|
target_url = f'/web#action={self.action_id.id}'
|
||||||
|
else:
|
||||||
|
target_url = '/web'
|
||||||
|
|
||||||
|
# We MUST inject the parameter into the query string (before the #)
|
||||||
|
# to force a full page reload, otherwise $(document).ready won't fire
|
||||||
|
if '#' in target_url:
|
||||||
|
base, fragment = target_url.split('#', 1)
|
||||||
|
sep = '&' if '?' in base else '?'
|
||||||
|
url = f"{base}{sep}genius_recorder={self.id}#{fragment}"
|
||||||
|
else:
|
||||||
|
sep = '&' if '?' in target_url else '?'
|
||||||
|
url = f"{target_url}{sep}genius_recorder={self.id}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_url',
|
||||||
|
'url': url,
|
||||||
|
'target': 'self',
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_start_quiz(self):
|
||||||
|
"""
|
||||||
|
Start a quiz for this tour.
|
||||||
|
Creates a new attempt or resumes existing in-progress one.
|
||||||
|
Returns action to open the quiz form.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.quiz_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for in-progress attempt
|
||||||
|
attempt = self.env['genius.quiz.attempt'].search([
|
||||||
|
('quiz_id', '=', self.quiz_id.id),
|
||||||
|
('user_id', '=', self.env.user.id),
|
||||||
|
('state', '=', 'in_progress')
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
if not attempt:
|
||||||
|
# Check Max Attempts
|
||||||
|
if self.quiz_id.max_attempts > 0:
|
||||||
|
attempt_count = self.env['genius.quiz.attempt'].search_count([
|
||||||
|
('quiz_id', '=', self.quiz_id.id),
|
||||||
|
('user_id', '=', self.env.user.id)
|
||||||
|
])
|
||||||
|
if attempt_count >= self.quiz_id.max_attempts:
|
||||||
|
raise UserError(_("You have reached the maximum number of attempts (%s) for this quiz.") % self.quiz_id.max_attempts)
|
||||||
|
|
||||||
|
# Create new attempt using Quiz Logic (handles shuffle, sample)
|
||||||
|
attempt = self.quiz_id.create_attempt(self.env.user.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'genius.quiz.attempt',
|
||||||
|
'res_id': attempt.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'views': [(False, 'form')],
|
||||||
|
'target': 'new', # Contextual window (modal-like)
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_start_quiz_popup(self):
|
||||||
|
"""
|
||||||
|
Start a quiz for the popup widget.
|
||||||
|
Returns quiz data (questions, answers) for frontend rendering.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.quiz_id:
|
||||||
|
return {'error': _('No quiz linked to this tour.')}
|
||||||
|
|
||||||
|
# Check Max Attempts
|
||||||
|
if self.quiz_id.max_attempts > 0:
|
||||||
|
attempt_count = self.env['genius.quiz.attempt'].search_count([
|
||||||
|
('quiz_id', '=', self.quiz_id.id),
|
||||||
|
('user_id', '=', self.env.user.id),
|
||||||
|
('state', '=', 'submitted'),
|
||||||
|
('is_preview', '=', False) # CRITICAL: Exclude preview attempts
|
||||||
|
])
|
||||||
|
if attempt_count >= self.quiz_id.max_attempts:
|
||||||
|
return {'error': _('Maximum attempts reached (%s).') % self.quiz_id.max_attempts}
|
||||||
|
|
||||||
|
# Check if already achieved 100% (Genius Mode: Allow retry even if passed, unless perfect)
|
||||||
|
perfect_attempts = self.env['genius.quiz.attempt'].search_count([
|
||||||
|
('quiz_id', '=', self.quiz_id.id),
|
||||||
|
('user_id', '=', self.env.user.id),
|
||||||
|
('state', '=', 'submitted'),
|
||||||
|
('score', '>=', 100.0),
|
||||||
|
('is_preview', '=', False)
|
||||||
|
])
|
||||||
|
if perfect_attempts > 0:
|
||||||
|
return {'error': _('You have already achieved a perfect score (100%)! Genius!')}
|
||||||
|
|
||||||
|
# Check for in-progress attempt (with race condition protection)
|
||||||
|
# Use SQL lock to prevent duplicate attempts from multiple tabs
|
||||||
|
self.env.cr.execute("""
|
||||||
|
SELECT id FROM genius_quiz_attempt
|
||||||
|
WHERE quiz_id = %s AND user_id = %s AND state = 'in_progress'
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE NOWAIT
|
||||||
|
""", (self.quiz_id.id, self.env.user.id))
|
||||||
|
result = self.env.cr.fetchone()
|
||||||
|
|
||||||
|
if result:
|
||||||
|
attempt = self.env['genius.quiz.attempt'].browse(result[0])
|
||||||
|
else:
|
||||||
|
# Create new attempt
|
||||||
|
attempt = self.quiz_id.create_attempt(self.env.user.id)
|
||||||
|
|
||||||
|
# Build questions data from attempt's responses (respects shuffle/sample)
|
||||||
|
questions = []
|
||||||
|
for response in attempt.response_ids:
|
||||||
|
q = response.question_id
|
||||||
|
question_data = {
|
||||||
|
'id': q.id,
|
||||||
|
'text': q.question_text.strip() if q.question_text else '',
|
||||||
|
'type': q.question_type,
|
||||||
|
'image': q.image.decode('utf-8') if q.image else None,
|
||||||
|
'answers': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add answers (for single/multiple choice and ordering)
|
||||||
|
answers_list = list(q.answer_ids)
|
||||||
|
|
||||||
|
# CRITICAL: For ordering questions, shuffle answers so user doesn't see correct order
|
||||||
|
if q.question_type == 'ordering':
|
||||||
|
import random
|
||||||
|
random.shuffle(answers_list)
|
||||||
|
|
||||||
|
for ans in answers_list:
|
||||||
|
question_data['answers'].append({
|
||||||
|
'id': ans.id,
|
||||||
|
'text': ans.answer_text,
|
||||||
|
})
|
||||||
|
|
||||||
|
questions.append(question_data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'attempt_id': attempt.id,
|
||||||
|
'quiz_name': self.quiz_id.name,
|
||||||
|
'questions': questions,
|
||||||
|
'time_limit_minutes': self.quiz_id.time_limit_minutes,
|
||||||
|
'passing_score': self.quiz_id.passing_score,
|
||||||
|
# New fields for enhanced UX
|
||||||
|
'description': self.quiz_id.description or '',
|
||||||
|
'success_message': self.quiz_id.success_message or '',
|
||||||
|
'fail_message': self.quiz_id.fail_message or '',
|
||||||
|
'show_correct_answers': self.quiz_id.show_correct_answers,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_certificate_data(self):
|
||||||
|
"""
|
||||||
|
Get certificate data for displaying in the popup.
|
||||||
|
Returns the best attempt info for certificate display.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.quiz_id:
|
||||||
|
return {'error': _('No quiz linked to this tour.')}
|
||||||
|
|
||||||
|
# Get best passed attempt
|
||||||
|
attempt = self.env['genius.quiz.attempt'].search([
|
||||||
|
('quiz_id', '=', self.quiz_id.id),
|
||||||
|
('user_id', '=', self.env.user.id),
|
||||||
|
('state', '=', 'submitted'),
|
||||||
|
('is_passed', '=', True),
|
||||||
|
], order='score desc', limit=1)
|
||||||
|
|
||||||
|
if not attempt:
|
||||||
|
return {'error': _('No passed attempt found.')}
|
||||||
|
|
||||||
|
# Calculate time taken
|
||||||
|
time_taken = 0
|
||||||
|
if attempt.started_at and attempt.submitted_at:
|
||||||
|
delta = attempt.submitted_at - attempt.started_at
|
||||||
|
time_taken = int(delta.total_seconds() / 60)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'attempt_id': attempt.id, # Required for PDF download
|
||||||
|
'score': attempt.score,
|
||||||
|
'points_earned': attempt.points_earned,
|
||||||
|
'points_possible': attempt.points_possible,
|
||||||
|
'time_taken': time_taken,
|
||||||
|
'user_name': self.env.user.name,
|
||||||
|
'date': attempt.submitted_at.strftime('%B %d, %Y') if attempt.submitted_at else '',
|
||||||
|
'passing_score': self.quiz_id.passing_score,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Helper Methods
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
def check_prerequisites(self, user=None):
|
||||||
|
"""Check if user has completed prerequisites"""
|
||||||
|
user = user or self.env.user
|
||||||
|
for prereq in self.prerequisite_ids:
|
||||||
|
if user not in prereq.consumed_user_ids:
|
||||||
|
return False, prereq
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
def action_track_start(self):
|
||||||
|
"""Called by JS when tour starts to ensure start time is recorded"""
|
||||||
|
# Call update progress without 'ensure_one' since JS might send ID list
|
||||||
|
# but typically it sends one ID
|
||||||
|
self._update_user_progress('in_progress')
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _update_user_progress(self, state):
|
||||||
|
"""Update or create progress record for current user"""
|
||||||
|
user = self.env.user
|
||||||
|
for tour in self:
|
||||||
|
progress = self.env['genius.progress'].search([
|
||||||
|
('user_id', '=', user.id),
|
||||||
|
('topic_id', '=', tour.id),
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
vals = {'state': state}
|
||||||
|
|
||||||
|
if state == 'in_progress':
|
||||||
|
# Set date_started for the CURRENT attempt
|
||||||
|
vals['date_started'] = fields.Datetime.now()
|
||||||
|
|
||||||
|
# PROTECTION: Don't downgrade done/verified to in_progress
|
||||||
|
# and don't clear date_completed for already-completed tours
|
||||||
|
if not progress or progress.state not in ('done', 'verified'):
|
||||||
|
vals['date_completed'] = False
|
||||||
|
else:
|
||||||
|
# User is re-running a completed tour - don't change state
|
||||||
|
vals.pop('state', None)
|
||||||
|
|
||||||
|
elif state == 'done':
|
||||||
|
vals['date_completed'] = fields.Datetime.now()
|
||||||
|
# Increment completion count
|
||||||
|
if progress:
|
||||||
|
vals['completion_count'] = progress.completion_count + 1
|
||||||
|
else:
|
||||||
|
vals['completion_count'] = 1
|
||||||
|
|
||||||
|
elif state == 'skipped':
|
||||||
|
vals['date_skipped'] = fields.Datetime.now()
|
||||||
|
|
||||||
|
if progress:
|
||||||
|
# Don't downgrade from 'done' or 'verified' to 'skipped'
|
||||||
|
# User might be re-taking a tour they already finished
|
||||||
|
if state == 'skipped' and progress.state in ('done', 'verified'):
|
||||||
|
vals.pop('state', None)
|
||||||
|
progress.write(vals)
|
||||||
|
else:
|
||||||
|
if state == 'done':
|
||||||
|
vals['completion_count'] = 1
|
||||||
|
self.env['genius.progress'].create({
|
||||||
|
'user_id': user.id,
|
||||||
|
'topic_id': tour.id,
|
||||||
|
'plan_id': tour.plan_id.id if tour.plan_id else False,
|
||||||
|
**vals,
|
||||||
|
})
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Tour Registration API (for JS tour_loader)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
def get_single_tour_for_test(self):
|
||||||
|
"""
|
||||||
|
Return a single tour's data for testing (bypasses state filter).
|
||||||
|
Used by action_test_tour to allow testing draft tours.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
# Get active steps (use sudo to ensure access to all fields/translations)
|
||||||
|
active_steps = self.sudo().step_ids.filtered('active')
|
||||||
|
if not active_steps:
|
||||||
|
return {'error': 'No steps defined for this tour.'}
|
||||||
|
|
||||||
|
# Build steps data for JS
|
||||||
|
steps = []
|
||||||
|
for step in active_steps.sorted('sequence'):
|
||||||
|
step_data = step.to_tour_step_dict()
|
||||||
|
if step_data:
|
||||||
|
steps.append(step_data)
|
||||||
|
|
||||||
|
if not steps:
|
||||||
|
return {'error': 'No valid steps found.'}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': 'genius_tour_' + str(self.id),
|
||||||
|
'url': self.starting_url or '/web',
|
||||||
|
'steps': steps,
|
||||||
|
'test_mode': True, # Flag for JS to skip progress tracking
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_tours_for_registration(self):
|
||||||
|
"""
|
||||||
|
Return all active, published tours for static JS registration.
|
||||||
|
Called by tour_loader.js on every page load.
|
||||||
|
Returns tours in Odoo web_tour format.
|
||||||
|
"""
|
||||||
|
# Only register published tours (draft tours are admin-only and not runnable)
|
||||||
|
topics = self.sudo().search([('active', '=', True), ('state', '=', 'published')])
|
||||||
|
result = []
|
||||||
|
user = self.env.user
|
||||||
|
|
||||||
|
for topic in topics:
|
||||||
|
# Get active steps
|
||||||
|
active_steps = topic.step_ids.filtered('active')
|
||||||
|
if not active_steps:
|
||||||
|
continue # Skip tours without steps
|
||||||
|
|
||||||
|
# Check Quiz Status and Attempts
|
||||||
|
quiz_status = 'none'
|
||||||
|
quiz_score = 0
|
||||||
|
attempts_count = 0
|
||||||
|
attempts_remaining = -1 # -1 means unlimited
|
||||||
|
|
||||||
|
if topic.quiz_id:
|
||||||
|
# Single query to get all submitted attempts (reused for count and best score)
|
||||||
|
all_attempts = self.env['genius.quiz.attempt'].sudo().search([
|
||||||
|
('quiz_id', '=', topic.quiz_id.id),
|
||||||
|
('user_id', '=', user.id),
|
||||||
|
('state', '=', 'submitted'),
|
||||||
|
('is_preview', '=', False), # CRITICAL: Exclude test/preview attempts
|
||||||
|
])
|
||||||
|
|
||||||
|
attempts_count = len(all_attempts)
|
||||||
|
|
||||||
|
if all_attempts:
|
||||||
|
# Get best attempt (highest score)
|
||||||
|
best_attempt = all_attempts.sorted('score', reverse=True)[:1]
|
||||||
|
quiz_status = 'passed' if best_attempt.is_passed else 'failed'
|
||||||
|
quiz_score = best_attempt.score
|
||||||
|
else:
|
||||||
|
quiz_status = 'not_attempted'
|
||||||
|
|
||||||
|
# Calculate remaining attempts
|
||||||
|
if topic.quiz_id.max_attempts > 0:
|
||||||
|
attempts_remaining = max(0, topic.quiz_id.max_attempts - attempts_count)
|
||||||
|
|
||||||
|
# Build steps in Odoo tour format
|
||||||
|
steps = []
|
||||||
|
for step in active_steps.sorted('sequence'):
|
||||||
|
step_data = step.to_tour_step_dict()
|
||||||
|
if step_data:
|
||||||
|
steps.append(step_data)
|
||||||
|
|
||||||
|
result.append({
|
||||||
|
'id': topic.id,
|
||||||
|
'name': topic.name,
|
||||||
|
'starting_url': topic.starting_url or '/web',
|
||||||
|
'steps': steps,
|
||||||
|
'quiz': {
|
||||||
|
'id': topic.quiz_id.id if topic.quiz_id else False,
|
||||||
|
'status': quiz_status,
|
||||||
|
'score': quiz_score,
|
||||||
|
'name': topic.quiz_id.name if topic.quiz_id else '',
|
||||||
|
'source_tour_id': topic.id,
|
||||||
|
'max_attempts': topic.quiz_id.max_attempts if topic.quiz_id else 0,
|
||||||
|
'attempts_count': attempts_count,
|
||||||
|
'attempts_remaining': attempts_remaining,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Dashboard API
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def get_dashboard_data(self):
|
||||||
|
"""Get data for Dashboard view"""
|
||||||
|
user = self.env.user
|
||||||
|
is_admin = user.has_group('tour_genius.group_genius_admin')
|
||||||
|
|
||||||
|
# Admins see all active tours (including draft), regular users only see published
|
||||||
|
domain = [('active', '=', True)]
|
||||||
|
if not is_admin:
|
||||||
|
domain.append(('state', '=', 'published'))
|
||||||
|
|
||||||
|
all_tours = self.search(domain)
|
||||||
|
|
||||||
|
tours = []
|
||||||
|
certified_count = 0
|
||||||
|
|
||||||
|
for tour in all_tours:
|
||||||
|
# Check Progress specifically for verified state
|
||||||
|
progress = self.env['genius.progress'].search([
|
||||||
|
('user_id', '=', user.id),
|
||||||
|
('topic_id', '=', tour.id)
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
# Check if user has passed quiz for this tour
|
||||||
|
quiz_passed = False
|
||||||
|
best_attempt_id = False
|
||||||
|
if tour.quiz_id:
|
||||||
|
passed_attempt = self.env['genius.quiz.attempt'].search([
|
||||||
|
('quiz_id', '=', tour.quiz_id.id),
|
||||||
|
('user_id', '=', user.id),
|
||||||
|
('state', '=', 'submitted'),
|
||||||
|
('is_passed', '=', True),
|
||||||
|
('is_preview', '=', False), # CRITICAL: Exclude test/preview attempts for certification
|
||||||
|
], order='score desc', limit=1)
|
||||||
|
if passed_attempt:
|
||||||
|
quiz_passed = True
|
||||||
|
best_attempt_id = passed_attempt.id
|
||||||
|
|
||||||
|
user_status = 'new'
|
||||||
|
if progress:
|
||||||
|
# Use the state from progress record
|
||||||
|
if progress.state == 'verified':
|
||||||
|
user_status = 'verified'
|
||||||
|
certified_count += 1
|
||||||
|
elif progress.state == 'done':
|
||||||
|
# If done AND passed quiz → verified, else completed
|
||||||
|
if quiz_passed:
|
||||||
|
user_status = 'verified'
|
||||||
|
certified_count += 1
|
||||||
|
else:
|
||||||
|
user_status = 'completed'
|
||||||
|
elif progress.state == 'in_progress':
|
||||||
|
user_status = 'in_progress'
|
||||||
|
# Check consumed_user_ids (legacy fallback)
|
||||||
|
elif user in tour.consumed_user_ids:
|
||||||
|
if quiz_passed:
|
||||||
|
user_status = 'verified'
|
||||||
|
certified_count += 1
|
||||||
|
else:
|
||||||
|
user_status = 'completed'
|
||||||
|
elif user in tour.consumed_user_ids:
|
||||||
|
if quiz_passed:
|
||||||
|
user_status = 'verified'
|
||||||
|
certified_count += 1
|
||||||
|
else:
|
||||||
|
user_status = 'completed'
|
||||||
|
|
||||||
|
# Get quiz status for this tour
|
||||||
|
quiz_data = {
|
||||||
|
'quiz_id': False,
|
||||||
|
'quiz_status': 'none',
|
||||||
|
'quiz_score': 0,
|
||||||
|
'attempts_remaining': -1,
|
||||||
|
'can_retry': False,
|
||||||
|
'is_perfect': False, # True when score = 100%
|
||||||
|
'has_certificate': False, # True when user passed any quiz
|
||||||
|
'certificate_attempt_id': False, # For download link
|
||||||
|
}
|
||||||
|
|
||||||
|
if tour.quiz_id:
|
||||||
|
quiz_data['quiz_id'] = tour.quiz_id.id
|
||||||
|
quiz_data['has_certificate'] = quiz_passed
|
||||||
|
quiz_data['certificate_attempt_id'] = best_attempt_id
|
||||||
|
|
||||||
|
# Get user's quiz attempts (EXCLUDING PREVIEWS)
|
||||||
|
attempts = self.env['genius.quiz.attempt'].search([
|
||||||
|
('quiz_id', '=', tour.quiz_id.id),
|
||||||
|
('user_id', '=', user.id),
|
||||||
|
('state', '=', 'submitted'),
|
||||||
|
('is_preview', '=', False), # CRITICAL: Exclude test/preview attempts
|
||||||
|
], order='score desc')
|
||||||
|
|
||||||
|
if attempts:
|
||||||
|
best_attempt = attempts[0]
|
||||||
|
quiz_data['quiz_score'] = round(best_attempt.score)
|
||||||
|
quiz_data['quiz_status'] = 'passed' if best_attempt.is_passed else 'failed'
|
||||||
|
quiz_data['is_perfect'] = best_attempt.score >= 100
|
||||||
|
|
||||||
|
# Check if can retry (allow retry even if passed but not perfect)
|
||||||
|
if tour.quiz_id.max_attempts > 0:
|
||||||
|
quiz_data['attempts_remaining'] = max(0, tour.quiz_id.max_attempts - len(attempts))
|
||||||
|
# Can retry if: not perfect AND has remaining attempts
|
||||||
|
quiz_data['can_retry'] = quiz_data['attempts_remaining'] > 0 and not quiz_data['is_perfect']
|
||||||
|
else:
|
||||||
|
quiz_data['attempts_remaining'] = -1 # Unlimited
|
||||||
|
# Can retry if not perfect (unlimited attempts)
|
||||||
|
quiz_data['can_retry'] = not quiz_data['is_perfect']
|
||||||
|
else:
|
||||||
|
quiz_data['quiz_status'] = 'not_attempted'
|
||||||
|
quiz_data['can_retry'] = True
|
||||||
|
if tour.quiz_id.max_attempts > 0:
|
||||||
|
quiz_data['attempts_remaining'] = tour.quiz_id.max_attempts
|
||||||
|
else:
|
||||||
|
quiz_data['attempts_remaining'] = -1
|
||||||
|
|
||||||
|
tours.append({
|
||||||
|
'id': tour.id,
|
||||||
|
'name': tour.name,
|
||||||
|
'icon': tour.icon or '*',
|
||||||
|
'module_name': tour.module_name or '',
|
||||||
|
'step_count': tour.step_count,
|
||||||
|
'user_status': user_status,
|
||||||
|
'duration_minutes': tour.duration_minutes,
|
||||||
|
'state': tour.state, # draft or published
|
||||||
|
# Tags for display
|
||||||
|
'tags': [{'id': t.id, 'name': t.name, 'color': t.color} for t in tour.tag_ids],
|
||||||
|
# Admin info: how many users have verified this tour
|
||||||
|
'verified_count': self.env['genius.progress'].search_count([
|
||||||
|
('topic_id', '=', tour.id),
|
||||||
|
('state', '=', 'verified')
|
||||||
|
]),
|
||||||
|
# Quiz data
|
||||||
|
'quiz_id': quiz_data['quiz_id'],
|
||||||
|
'quiz_status': quiz_data['quiz_status'],
|
||||||
|
'quiz_score': quiz_data['quiz_score'],
|
||||||
|
'attempts_remaining': quiz_data['attempts_remaining'],
|
||||||
|
'can_retry': quiz_data['can_retry'],
|
||||||
|
'is_perfect': quiz_data['is_perfect'],
|
||||||
|
'has_certificate': quiz_data['has_certificate'],
|
||||||
|
'certificate_attempt_id': quiz_data['certificate_attempt_id'],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Recalculate counts based on user_status logic above
|
||||||
|
completed_count = len([t for t in tours if t['user_status'] in ['completed', 'verified']])
|
||||||
|
in_progress_count = len([t for t in tours if t['user_status'] == 'in_progress'])
|
||||||
|
total_count = len(tours)
|
||||||
|
|
||||||
|
progress = (completed_count / total_count * 100) if total_count else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'tours': tours,
|
||||||
|
'is_admin': is_admin,
|
||||||
|
'progress': progress,
|
||||||
|
'stats': {
|
||||||
|
'total_tours': total_count,
|
||||||
|
'completed': completed_count,
|
||||||
|
'in_progress': in_progress_count,
|
||||||
|
'certified': certified_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Contextual Training API
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def get_contextual_training(self, context):
|
||||||
|
"""Get training relevant to current screen with genius-level detail"""
|
||||||
|
user = self.env.user
|
||||||
|
|
||||||
|
# 1. Parse Context
|
||||||
|
model_name = context.get('model')
|
||||||
|
action_id = context.get('action_id')
|
||||||
|
menu_id = context.get('menu_id')
|
||||||
|
|
||||||
|
# Resolve Action -> Model
|
||||||
|
if action_id and not model_name:
|
||||||
|
try:
|
||||||
|
action = self.env['ir.actions.act_window'].browse(int(action_id))
|
||||||
|
if action.exists() and action.res_model:
|
||||||
|
model_name = action.res_model
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# NEW: Resolve Menu -> Module (most reliable for app context)
|
||||||
|
menu_module = None
|
||||||
|
if menu_id:
|
||||||
|
try:
|
||||||
|
menu = self.env['ir.ui.menu'].browse(int(menu_id))
|
||||||
|
if menu.exists():
|
||||||
|
# Get root menu (app) by traversing parent chain
|
||||||
|
root_menu = menu
|
||||||
|
while root_menu.parent_id:
|
||||||
|
root_menu = root_menu.parent_id
|
||||||
|
|
||||||
|
# Get module from XML ID of root menu
|
||||||
|
xml_id = self.env['ir.model.data'].sudo().search([
|
||||||
|
('model', '=', 'ir.ui.menu'),
|
||||||
|
('res_id', '=', root_menu.id)
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
if xml_id:
|
||||||
|
menu_module = xml_id.module
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
relevant_tours = self.env['genius.topic']
|
||||||
|
|
||||||
|
# 2. Strict Match (High Priority) - by action_id only since model_id was removed
|
||||||
|
strict_tours = self.env['genius.topic']
|
||||||
|
if action_id:
|
||||||
|
strict_tours |= self.search([('active', '=', True), ('state', '=', 'published'), ('action_id', '=', int(action_id))])
|
||||||
|
|
||||||
|
relevant_tours |= strict_tours
|
||||||
|
|
||||||
|
# 3. Module Match (Broad Context - Lower Priority)
|
||||||
|
module_names_to_search = []
|
||||||
|
|
||||||
|
# 3a. From model's module
|
||||||
|
if model_name:
|
||||||
|
ir_model = self.env['ir.model'].search([('model', '=', model_name)], limit=1)
|
||||||
|
if ir_model and ir_model.modules:
|
||||||
|
module_names_to_search.extend([m.strip() for m in ir_model.modules.split(',')])
|
||||||
|
|
||||||
|
# 3b. From menu module (NEW - most reliable for app context)
|
||||||
|
if menu_module and menu_module not in module_names_to_search:
|
||||||
|
module_names_to_search.append(menu_module)
|
||||||
|
|
||||||
|
if module_names_to_search:
|
||||||
|
module_tours = self.search([
|
||||||
|
('active', '=', True),
|
||||||
|
('state', '=', 'published'),
|
||||||
|
('module_xml_id', 'in', module_names_to_search),
|
||||||
|
('id', 'not in', strict_tours.ids) # Avoid duplicates
|
||||||
|
])
|
||||||
|
relevant_tours |= module_tours
|
||||||
|
|
||||||
|
# 4. Fallback: If no context, show "featured" tours (limited)
|
||||||
|
if not relevant_tours:
|
||||||
|
# Show top 5 most recent published tours the user hasn't completed
|
||||||
|
featured_tours = self.search([
|
||||||
|
('active', '=', True),
|
||||||
|
('state', '=', 'published'),
|
||||||
|
], order='create_date desc', limit=5)
|
||||||
|
relevant_tours = featured_tours
|
||||||
|
|
||||||
|
# 4. Process Status & Progress for each tour
|
||||||
|
result_topics = []
|
||||||
|
new_count = 0
|
||||||
|
|
||||||
|
for t in relevant_tours:
|
||||||
|
# Get Progress
|
||||||
|
progress = self.env['genius.progress'].search([
|
||||||
|
('user_id', '=', user.id),
|
||||||
|
('topic_id', '=', t.id)
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
# Determine Status
|
||||||
|
status = 'new'
|
||||||
|
progress_percent = 0
|
||||||
|
|
||||||
|
if progress:
|
||||||
|
status = progress.state
|
||||||
|
if status == 'in_progress' and t.step_count > 0:
|
||||||
|
# Generic 50% for in_progress
|
||||||
|
progress_percent = 50
|
||||||
|
elif status in ('done', 'verified'):
|
||||||
|
progress_percent = 100
|
||||||
|
elif user in t.consumed_user_ids:
|
||||||
|
status = 'done'
|
||||||
|
progress_percent = 100
|
||||||
|
|
||||||
|
# Count "New" items for badge (strict + module)
|
||||||
|
if status == 'new':
|
||||||
|
new_count += 1
|
||||||
|
|
||||||
|
result_topics.append({
|
||||||
|
'id': t.id,
|
||||||
|
'name': t.name,
|
||||||
|
'icon': t.icon or 'fa-graduation-cap',
|
||||||
|
'module_name': t.module_xml_id,
|
||||||
|
'step_count': t.step_count,
|
||||||
|
'status': status, # new, in_progress, done, verified, skipped
|
||||||
|
'progress': progress_percent,
|
||||||
|
'is_strict_match': t in strict_tours, # Use this for "Recommended" badge
|
||||||
|
'duration_minutes': t.duration_minutes,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 5. Sort: In Progress > New (Strict) > New (Module) > Completed
|
||||||
|
def sort_key(item):
|
||||||
|
# Sort order (lower is better)
|
||||||
|
# 1. In Progress (Resume!) -> 0
|
||||||
|
# 2. New Strict Match (Recommended) -> 1
|
||||||
|
# 3. New Broad Match -> 2
|
||||||
|
# 4. Skipped -> 3
|
||||||
|
# 5. Done/Verified -> 4
|
||||||
|
|
||||||
|
if item['status'] == 'in_progress':
|
||||||
|
return 0
|
||||||
|
elif item['status'] == 'new':
|
||||||
|
return 1 if item['is_strict_match'] else 2
|
||||||
|
elif item['status'] == 'skipped':
|
||||||
|
return 3
|
||||||
|
return 4
|
||||||
|
|
||||||
|
result_topics.sort(key=sort_key)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'topics': result_topics,
|
||||||
|
'context': context,
|
||||||
|
'total_count': len(relevant_tours),
|
||||||
|
'new_count': new_count,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Tour Mixin (Simplified)
|
||||||
|
=======================
|
||||||
|
Mixin for module access detection.
|
||||||
|
Uses standard Odoo ir.module.module instead of registry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import models, api
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GeniusModuleAccessMixin(models.AbstractModel):
|
||||||
|
"""Mixin for detecting which modules a user has access to."""
|
||||||
|
_name = 'genius.module.access.mixin'
|
||||||
|
_description = 'Module Access Detection Mixin'
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_user_accessible_modules(self, user=None):
|
||||||
|
"""
|
||||||
|
Detect which modules a user has access to.
|
||||||
|
Returns list of module technical names.
|
||||||
|
"""
|
||||||
|
user = user or self.env.user
|
||||||
|
|
||||||
|
# Admin sees everything
|
||||||
|
if user.has_group('tour_genius.group_genius_admin'):
|
||||||
|
return self.env['ir.module.module'].search([
|
||||||
|
('state', '=', 'installed')
|
||||||
|
]).mapped('name')
|
||||||
|
|
||||||
|
accessible_modules = set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check model access rights
|
||||||
|
model_accesses = self.env['ir.model.access'].sudo().search([
|
||||||
|
('group_id', 'in', user.groups_id.ids),
|
||||||
|
('perm_read', '=', True),
|
||||||
|
])
|
||||||
|
|
||||||
|
for access in model_accesses:
|
||||||
|
if access.model_id and access.model_id.modules:
|
||||||
|
module_names = [m.strip() for m in access.model_id.modules.split(',')]
|
||||||
|
accessible_modules.update(module_names)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning("Error detecting accessible modules: %s", str(e))
|
||||||
|
# Fallback: return all installed modules
|
||||||
|
return self.env['ir.module.module'].sudo().search([
|
||||||
|
('state', '=', 'installed')
|
||||||
|
]).mapped('name')
|
||||||
|
|
||||||
|
return list(accessible_modules)
|
||||||
|
|
||||||
|
def filter_by_user_access(self, records=None, user=None):
|
||||||
|
"""Filter records to only those the user can access based on module."""
|
||||||
|
records = records if records is not None else self
|
||||||
|
user = user or self.env.user
|
||||||
|
|
||||||
|
# Admin sees everything
|
||||||
|
if user.has_group('tour_genius.group_genius_admin'):
|
||||||
|
return records
|
||||||
|
|
||||||
|
accessible_modules = self._get_user_accessible_modules(user)
|
||||||
|
|
||||||
|
return records.filtered(
|
||||||
|
lambda r: not r.module_name or r.module_name in accessible_modules
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Tour Plan Model (Simplified)
|
||||||
|
=============================
|
||||||
|
Training plan without complex sections.
|
||||||
|
Topics are linked directly to plans.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
|
||||||
|
class GeniusPlan(models.Model):
|
||||||
|
"""Training Plan - simplified without sections."""
|
||||||
|
_name = 'genius.plan'
|
||||||
|
_description = 'Training Plan'
|
||||||
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||||
|
_order = 'create_date desc'
|
||||||
|
|
||||||
|
name = fields.Char(string='Plan Name', required=True, tracking=True, translate=True)
|
||||||
|
description = fields.Html(string='Description', translate=True)
|
||||||
|
cover_image = fields.Binary(string='Cover Image', attachment=True)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
state = fields.Selection([
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('published', 'Published'),
|
||||||
|
('archived', 'Archived'),
|
||||||
|
], string='Status', default='draft', tracking=True)
|
||||||
|
|
||||||
|
# Schedule
|
||||||
|
date_start = fields.Date(string='Start Date')
|
||||||
|
date_end = fields.Date(string='End Date')
|
||||||
|
estimated_hours = fields.Float(string='Estimated Hours')
|
||||||
|
|
||||||
|
# Topics (direct link - no sections!)
|
||||||
|
topic_ids = fields.One2many('genius.topic', 'plan_id', string='Topics')
|
||||||
|
topic_count = fields.Integer(string='Topic Count', compute='_compute_topic_count', store=True)
|
||||||
|
|
||||||
|
# Target Modules (simplified - uses names not registry)
|
||||||
|
target_modules = fields.Char(
|
||||||
|
string='Covered Modules',
|
||||||
|
help='Comma-separated list of module names (e.g., sale, purchase, account)'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Participants
|
||||||
|
attendee_ids = fields.Many2many('res.users', 'genius_plan_attendee_rel',
|
||||||
|
'plan_id', 'user_id', string='Trainees')
|
||||||
|
attendee_count = fields.Integer(string='Trainee Count', compute='_compute_attendee_count')
|
||||||
|
instructor_ids = fields.Many2many('res.users', 'genius_plan_instructor_rel',
|
||||||
|
'plan_id', 'user_id', string='Instructors')
|
||||||
|
|
||||||
|
# Library Mode
|
||||||
|
is_template = fields.Boolean(string='Is Template', default=False)
|
||||||
|
is_public = fields.Boolean(string='Public Access', default=False)
|
||||||
|
|
||||||
|
# Multi-company
|
||||||
|
company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company)
|
||||||
|
|
||||||
|
# Progress
|
||||||
|
progress_ids = fields.One2many('genius.progress', 'plan_id', string='Progress Records')
|
||||||
|
overall_progress = fields.Float(string='Overall Progress (%)', compute='_compute_overall_progress')
|
||||||
|
|
||||||
|
active = fields.Boolean(string='Active', default=True)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Compute Methods
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
@api.depends('topic_ids')
|
||||||
|
def _compute_topic_count(self):
|
||||||
|
for plan in self:
|
||||||
|
plan.topic_count = len(plan.topic_ids)
|
||||||
|
|
||||||
|
@api.depends('attendee_ids')
|
||||||
|
def _compute_attendee_count(self):
|
||||||
|
for plan in self:
|
||||||
|
plan.attendee_count = len(plan.attendee_ids)
|
||||||
|
|
||||||
|
@api.depends('progress_ids', 'progress_ids.state')
|
||||||
|
def _compute_overall_progress(self):
|
||||||
|
for plan in self:
|
||||||
|
if not plan.topic_count or not plan.attendee_count:
|
||||||
|
plan.overall_progress = 0.0
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_expected = plan.topic_count * plan.attendee_count
|
||||||
|
if total_expected == 0:
|
||||||
|
plan.overall_progress = 0.0
|
||||||
|
continue
|
||||||
|
|
||||||
|
completed = len(plan.progress_ids.filtered(lambda p: p.state in ('done', 'verified')))
|
||||||
|
plan.overall_progress = (completed / total_expected) * 100
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Action Methods
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
def action_publish(self):
|
||||||
|
"""Publish and initialize progress for attendees"""
|
||||||
|
for plan in self:
|
||||||
|
plan.write({'state': 'published'})
|
||||||
|
plan._initialize_progress_for_attendees()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def action_archive(self):
|
||||||
|
return self.write({'state': 'archived'})
|
||||||
|
|
||||||
|
def action_draft(self):
|
||||||
|
return self.write({'state': 'draft'})
|
||||||
|
|
||||||
|
def _initialize_progress_for_attendees(self):
|
||||||
|
"""Create progress records for all attendees"""
|
||||||
|
for plan in self:
|
||||||
|
for user in plan.attendee_ids:
|
||||||
|
for topic in plan.topic_ids:
|
||||||
|
existing = self.env['genius.progress'].search([
|
||||||
|
('user_id', '=', user.id),
|
||||||
|
('topic_id', '=', topic.id),
|
||||||
|
], limit=1)
|
||||||
|
if not existing:
|
||||||
|
self.env['genius.progress'].create({
|
||||||
|
'user_id': user.id,
|
||||||
|
'topic_id': topic.id,
|
||||||
|
'plan_id': plan.id,
|
||||||
|
'state': 'pending',
|
||||||
|
})
|
||||||
|
|
||||||
|
def action_view_topics(self):
|
||||||
|
"""Open topics for this plan"""
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': f'Topics - {self.name}',
|
||||||
|
'res_model': 'genius.topic',
|
||||||
|
'view_mode': 'tree,form',
|
||||||
|
'domain': [('plan_id', '=', self.id)],
|
||||||
|
'context': {'default_plan_id': self.id},
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_view_attendees(self):
|
||||||
|
"""Open attendee list"""
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': f'Trainees - {self.name}',
|
||||||
|
'res_model': 'res.users',
|
||||||
|
'view_mode': 'tree,form',
|
||||||
|
'domain': [('id', 'in', self.attendee_ids.ids)],
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_start_first_topic(self):
|
||||||
|
"""Start the first topic for the current user"""
|
||||||
|
self.ensure_one()
|
||||||
|
first_topic = self.topic_ids.sorted('sequence')[:1]
|
||||||
|
if first_topic:
|
||||||
|
return first_topic.action_open_topic()
|
||||||
|
return True
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
|
||||||
|
class GeniusProgress(models.Model):
|
||||||
|
"""
|
||||||
|
Tracks user progress on topics.
|
||||||
|
One record per user per topic.
|
||||||
|
"""
|
||||||
|
_name = 'genius.progress'
|
||||||
|
_description = 'User Progress'
|
||||||
|
_order = 'date_started desc'
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('user_topic_uniq', 'unique(user_id, topic_id)', 'Progress record already exists for this tour!')
|
||||||
|
]
|
||||||
|
|
||||||
|
user_id = fields.Many2one(
|
||||||
|
'res.users',
|
||||||
|
string='User',
|
||||||
|
required=True,
|
||||||
|
ondelete='cascade',
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
topic_id = fields.Many2one(
|
||||||
|
'genius.topic',
|
||||||
|
string='Topic',
|
||||||
|
required=True,
|
||||||
|
ondelete='restrict',
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
plan_id = fields.Many2one(
|
||||||
|
'genius.plan',
|
||||||
|
string='Plan',
|
||||||
|
ondelete='cascade',
|
||||||
|
help='Training plan (for faster queries)'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Progress State
|
||||||
|
state = fields.Selection(
|
||||||
|
selection=[
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('in_progress', 'In Progress'),
|
||||||
|
('skipped', 'Skipped'),
|
||||||
|
('done', 'Completed'),
|
||||||
|
('verified', 'Certified'), # Certified = Completed + Passed Quiz
|
||||||
|
],
|
||||||
|
string='Status',
|
||||||
|
default='pending',
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
date_started = fields.Datetime(
|
||||||
|
string='Started At'
|
||||||
|
)
|
||||||
|
date_completed = fields.Datetime(
|
||||||
|
string='Completed At'
|
||||||
|
)
|
||||||
|
date_verified = fields.Datetime(
|
||||||
|
string='Verified At'
|
||||||
|
)
|
||||||
|
date_skipped = fields.Datetime(
|
||||||
|
string='Last Skipped At'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Completion Tracking
|
||||||
|
completion_count = fields.Integer(
|
||||||
|
string='Times Completed',
|
||||||
|
default=0,
|
||||||
|
help='Number of times user has completed this tour'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Quiz Score (if applicable)
|
||||||
|
quiz_score = fields.Float(
|
||||||
|
string='Quiz Score (%)'
|
||||||
|
)
|
||||||
|
quiz_attempt_id = fields.Many2one(
|
||||||
|
'genius.quiz.attempt',
|
||||||
|
string='Quiz Attempt'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Time spent
|
||||||
|
time_spent_seconds = fields.Float(
|
||||||
|
string='Time (Seconds)',
|
||||||
|
compute='_compute_time_spent',
|
||||||
|
store=True,
|
||||||
|
digits=(10, 2)
|
||||||
|
)
|
||||||
|
time_spent_minutes = fields.Float(
|
||||||
|
string='Time (Minutes)',
|
||||||
|
compute='_compute_time_spent',
|
||||||
|
store=True,
|
||||||
|
digits=(10, 2)
|
||||||
|
)
|
||||||
|
duration_display = fields.Char(
|
||||||
|
string='Duration',
|
||||||
|
compute='_compute_time_spent',
|
||||||
|
store=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Gamification (Stored for Performance)
|
||||||
|
points_earned = fields.Integer(string='Points', compute='_compute_gamification', store=True)
|
||||||
|
is_quiz_passed = fields.Boolean(string='Quiz Passed', compute='_compute_gamification', store=True)
|
||||||
|
is_quiz_perfect = fields.Boolean(string='Quiz Perfect', compute='_compute_gamification', store=True)
|
||||||
|
|
||||||
|
@api.depends('state', 'quiz_attempt_id', 'quiz_attempt_id.is_passed', 'quiz_score')
|
||||||
|
def _compute_gamification(self):
|
||||||
|
for r in self:
|
||||||
|
points = 0
|
||||||
|
is_passed = False
|
||||||
|
is_perfect = False
|
||||||
|
|
||||||
|
# Topic Completion
|
||||||
|
if r.state in ['done', 'verified']:
|
||||||
|
points += 10
|
||||||
|
|
||||||
|
# Quiz Completion
|
||||||
|
if r.quiz_attempt_id and r.quiz_attempt_id.is_passed:
|
||||||
|
is_passed = True
|
||||||
|
points += 25
|
||||||
|
# Perfect Score (Bonus)
|
||||||
|
# Check robust float comparison or just use integer points?
|
||||||
|
# attempt.score is float. attempt.points_earned == points_possible is better.
|
||||||
|
if r.quiz_attempt_id.points_possible > 0 and r.quiz_attempt_id.points_earned == r.quiz_attempt_id.points_possible:
|
||||||
|
is_perfect = True
|
||||||
|
points += 50
|
||||||
|
|
||||||
|
r.points_earned = points
|
||||||
|
r.is_quiz_passed = is_passed
|
||||||
|
r.is_quiz_perfect = is_perfect
|
||||||
|
|
||||||
|
|
||||||
|
# Notes
|
||||||
|
notes = fields.Text(
|
||||||
|
string='Notes',
|
||||||
|
help='Trainer notes about this progress'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@api.depends('date_started', 'date_completed')
|
||||||
|
def _compute_time_spent(self):
|
||||||
|
for progress in self:
|
||||||
|
if progress.date_started and progress.date_completed:
|
||||||
|
delta = progress.date_completed - progress.date_started
|
||||||
|
seconds = delta.total_seconds()
|
||||||
|
progress.time_spent_seconds = seconds
|
||||||
|
progress.time_spent_minutes = seconds / 60.0
|
||||||
|
|
||||||
|
# Format duration (e.g. "2m 15s")
|
||||||
|
m, s = divmod(int(seconds), 60)
|
||||||
|
h, m = divmod(m, 60)
|
||||||
|
if h > 0:
|
||||||
|
progress.duration_display = f"{h}h {m}m {s}s"
|
||||||
|
elif m > 0:
|
||||||
|
progress.duration_display = f"{m}m {s}s"
|
||||||
|
else:
|
||||||
|
progress.duration_display = f"{s}s"
|
||||||
|
else:
|
||||||
|
progress.time_spent_seconds = 0.0
|
||||||
|
progress.time_spent_minutes = 0.0
|
||||||
|
progress.duration_display = "0s"
|
||||||
|
|
||||||
|
def action_verify(self):
|
||||||
|
"""Mark progress as verified by trainer"""
|
||||||
|
return self.write({
|
||||||
|
'state': 'verified',
|
||||||
|
'date_verified': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
def action_reset(self):
|
||||||
|
"""Reset progress to pending"""
|
||||||
|
return self.write({
|
||||||
|
'state': 'pending',
|
||||||
|
'date_started': False,
|
||||||
|
'date_completed': False,
|
||||||
|
'date_verified': False,
|
||||||
|
'quiz_score': 0,
|
||||||
|
'quiz_attempt_id': False,
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
|
||||||
|
class GeniusTopicStep(models.Model):
|
||||||
|
"""
|
||||||
|
Individual step within a topic tour.
|
||||||
|
Simplified model focusing on CSS selector and basic configuration.
|
||||||
|
"""
|
||||||
|
_name = 'genius.topic.step'
|
||||||
|
_description = 'Topic Step'
|
||||||
|
_order = 'sequence, id'
|
||||||
|
|
||||||
|
topic_id = fields.Many2one(
|
||||||
|
'genius.topic',
|
||||||
|
string='Topic',
|
||||||
|
required=True,
|
||||||
|
ondelete='cascade',
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
sequence = fields.Integer(
|
||||||
|
string='Order',
|
||||||
|
default=10
|
||||||
|
)
|
||||||
|
title = fields.Char(
|
||||||
|
string='Step Title',
|
||||||
|
required=True,
|
||||||
|
translate=True,
|
||||||
|
help='Short title for this step (displayed in tooltip)'
|
||||||
|
)
|
||||||
|
instruction = fields.Html(
|
||||||
|
string='Instruction',
|
||||||
|
translate=True,
|
||||||
|
sanitize=True,
|
||||||
|
help='Detailed instruction for the user'
|
||||||
|
)
|
||||||
|
|
||||||
|
# CSS Selector - THE MAIN FIELD
|
||||||
|
css_selector = fields.Char(
|
||||||
|
string='CSS Selector',
|
||||||
|
required=True,
|
||||||
|
help='CSS selector for the target element. Use the Recorder to capture this automatically.'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step Type (simplified - only used types)
|
||||||
|
step_type = fields.Selection(
|
||||||
|
selection=[
|
||||||
|
('click', 'Click'),
|
||||||
|
('input', 'Input Text'),
|
||||||
|
],
|
||||||
|
string='Step Type',
|
||||||
|
default='click',
|
||||||
|
required=True,
|
||||||
|
help='Click: Wait for user to click element. Input: Type specific text.'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tooltip Position
|
||||||
|
position = fields.Selection(
|
||||||
|
selection=[
|
||||||
|
('top', 'Top'),
|
||||||
|
('bottom', 'Bottom'),
|
||||||
|
('left', 'Left'),
|
||||||
|
('right', 'Right'),
|
||||||
|
],
|
||||||
|
string='Tip Position',
|
||||||
|
default='bottom',
|
||||||
|
help='Position of the tooltip relative to the element'
|
||||||
|
)
|
||||||
|
|
||||||
|
# For input steps
|
||||||
|
input_value = fields.Char(
|
||||||
|
string='Input Value',
|
||||||
|
help='Value to type (only for Input Text steps)'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extra trigger for context validation (Odoo standard pattern)
|
||||||
|
extra_trigger = fields.Char(
|
||||||
|
string='Extra Trigger',
|
||||||
|
help='Additional CSS selector that must be visible for this step to activate. '
|
||||||
|
'Used to ensure the correct page/context.'
|
||||||
|
)
|
||||||
|
|
||||||
|
active = fields.Boolean(
|
||||||
|
string='Active',
|
||||||
|
default=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Related fields for display
|
||||||
|
topic_name = fields.Char(
|
||||||
|
related='topic_id.name',
|
||||||
|
string='Tour Name',
|
||||||
|
store=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Helper Methods
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
def to_tour_step_dict(self):
|
||||||
|
"""
|
||||||
|
Convert to Odoo web_tour step format.
|
||||||
|
Returns a dict compatible with web_tour.
|
||||||
|
|
||||||
|
Note: We include both Odoo-standard fields (trigger, content, position)
|
||||||
|
and Genius-specific fields (title, geniusTitle) for GeniusTip widget.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
# Combine Title and Instruction for content
|
||||||
|
content_html = f"<strong>{self.title}</strong>"
|
||||||
|
if self.instruction:
|
||||||
|
# Simple clean up of P tags if necessary, or just append
|
||||||
|
# web_tour supports HTML in content
|
||||||
|
content_html += f"<br/>{self.instruction}"
|
||||||
|
|
||||||
|
step_dict = {
|
||||||
|
'trigger': self.css_selector,
|
||||||
|
'content': content_html,
|
||||||
|
'position': self.position,
|
||||||
|
# GENIUS: Add title separately for custom GeniusTip widget
|
||||||
|
'title': self.title,
|
||||||
|
'geniusTitle': self.title,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add Extra Trigger if present
|
||||||
|
if self.extra_trigger:
|
||||||
|
step_dict['extra_trigger'] = self.extra_trigger
|
||||||
|
|
||||||
|
# Generate run command based on step type
|
||||||
|
if self.step_type == 'click':
|
||||||
|
step_dict['run'] = 'click'
|
||||||
|
elif self.step_type == 'input' and self.input_value:
|
||||||
|
escaped_value = (self.input_value or '').replace("'", "\\'")
|
||||||
|
step_dict['run'] = f"text {escaped_value}"
|
||||||
|
|
||||||
|
return step_dict
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
|
||||||
|
class GeniusTourTag(models.Model):
|
||||||
|
"""
|
||||||
|
Tags for additional topic classification.
|
||||||
|
Flexible tagging system for filtering.
|
||||||
|
"""
|
||||||
|
_name = 'genius.tour.tag'
|
||||||
|
_description = 'Tour Tag'
|
||||||
|
_order = 'name'
|
||||||
|
|
||||||
|
name = fields.Char(
|
||||||
|
string='Tag Name',
|
||||||
|
required=True,
|
||||||
|
translate=True
|
||||||
|
)
|
||||||
|
color = fields.Integer(
|
||||||
|
string='Color Index'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Usage Stats
|
||||||
|
topic_count = fields.Integer(
|
||||||
|
string='Topic Count',
|
||||||
|
compute='_compute_topic_count'
|
||||||
|
)
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('unique_name', 'UNIQUE(name)', 'Tag name must be unique!')
|
||||||
|
]
|
||||||
|
|
||||||
|
def _compute_topic_count(self):
|
||||||
|
for tag in self:
|
||||||
|
tag.topic_count = self.env['genius.topic'].search_count([
|
||||||
|
('tag_ids', 'in', [tag.id])
|
||||||
|
])
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_plan_user,genius.plan.user,model_genius_plan,group_genius_user,1,0,0,0
|
||||||
|
access_plan_instructor,genius.plan.instructor,model_genius_plan,group_genius_instructor,1,1,1,0
|
||||||
|
access_plan_admin,genius.plan.admin,model_genius_plan,group_genius_admin,1,1,1,1
|
||||||
|
access_topic_user,genius.topic.user,model_genius_topic,group_genius_user,1,0,0,0
|
||||||
|
access_topic_instructor,genius.topic.instructor,model_genius_topic,group_genius_instructor,1,1,1,1
|
||||||
|
access_step_user,genius.topic.step.user,model_genius_topic_step,group_genius_user,1,0,0,0
|
||||||
|
access_step_instructor,genius.topic.step.instructor,model_genius_topic_step,group_genius_instructor,1,1,1,1
|
||||||
|
access_progress_user,genius.progress.user,model_genius_progress,group_genius_user,1,1,1,0
|
||||||
|
access_progress_instructor,genius.progress.instructor,model_genius_progress,group_genius_instructor,1,1,1,1
|
||||||
|
access_tag_user,genius.tour.tag.user,model_genius_tour_tag,group_genius_user,1,0,0,0
|
||||||
|
access_tag_instructor,genius.tour.tag.instructor,model_genius_tour_tag,group_genius_instructor,1,1,1,1
|
||||||
|
access_quiz_user,genius.quiz.user,model_genius_quiz,group_genius_user,1,0,0,0
|
||||||
|
access_quiz_instructor,genius.quiz.instructor,model_genius_quiz,group_genius_instructor,1,1,1,1
|
||||||
|
access_question_user,genius.quiz.question.user,model_genius_quiz_question,group_genius_user,1,0,0,0
|
||||||
|
access_question_instructor,genius.quiz.question.instructor,model_genius_quiz_question,group_genius_instructor,1,1,1,1
|
||||||
|
access_answer_user,genius.quiz.answer.user,model_genius_quiz_answer,group_genius_user,1,0,0,0
|
||||||
|
access_answer_instructor,genius.quiz.answer.instructor,model_genius_quiz_answer,group_genius_instructor,1,1,1,1
|
||||||
|
access_attempt_user,genius.quiz.attempt.user,model_genius_quiz_attempt,group_genius_user,1,1,1,0
|
||||||
|
access_attempt_instructor,genius.quiz.attempt.instructor,model_genius_quiz_attempt,group_genius_instructor,1,1,1,1
|
||||||
|
access_response_user,genius.quiz.response.user,model_genius_quiz_response,group_genius_user,1,1,1,0
|
||||||
|
access_response_instructor,genius.quiz.response.instructor,model_genius_quiz_response,group_genius_instructor,1,1,1,1
|
||||||
|
access_leaderboard_user,genius.leaderboard.user,model_genius_leaderboard,group_genius_user,1,0,0,0
|
||||||
|
access_leaderboard_admin,genius.leaderboard.admin,model_genius_leaderboard,group_genius_admin,1,1,1,1
|
||||||
|
access_reminder_user,genius.reminder.user,model_genius_reminder,group_genius_user,1,0,0,0
|
||||||
|
access_reminder_instructor,genius.reminder.instructor,model_genius_reminder,group_genius_instructor,1,1,1,1
|
||||||
|
|
|
@ -0,0 +1,155 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- Security Groups -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<!-- Base Category for Module -->
|
||||||
|
<record id="module_category_tour_genius" model="ir.module.category">
|
||||||
|
<field name="name">Tour Genius</field>
|
||||||
|
<field name="description">Interactive Training Platform</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- User Group: Can view and complete assigned training -->
|
||||||
|
<record id="group_genius_user" model="res.groups">
|
||||||
|
<field name="name">User</field>
|
||||||
|
<field name="category_id" ref="module_category_tour_genius"/>
|
||||||
|
<field name="comment">Can view and complete training assigned to them. Read-only access to training content.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Instructor Group: Can create and manage training content -->
|
||||||
|
<record id="group_genius_instructor" model="res.groups">
|
||||||
|
<field name="name">Instructor</field>
|
||||||
|
<field name="category_id" ref="module_category_tour_genius"/>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_genius_user'))]"/>
|
||||||
|
<field name="comment">Can create and manage training plans, topics, and quizzes. Can view all trainees' progress.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Admin Group: Full access to everything -->
|
||||||
|
<record id="group_genius_admin" model="res.groups">
|
||||||
|
<field name="name">Administrator</field>
|
||||||
|
<field name="category_id" ref="module_category_tour_genius"/>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_genius_instructor'))]"/>
|
||||||
|
<field name="comment">Full access to all training content, configuration, and registry management.</field>
|
||||||
|
<field name="users" eval="[(4, ref('base.user_admin'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- Record Rules -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<!-- Plan: Users see only public plans or assigned plans -->
|
||||||
|
<record id="rule_plan_user" model="ir.rule">
|
||||||
|
<field name="name">Training Plan: User Access</field>
|
||||||
|
<field name="model_id" ref="model_genius_plan"/>
|
||||||
|
<field name="groups" eval="[(4, ref('group_genius_user'))]"/>
|
||||||
|
<field name="domain_force">[
|
||||||
|
'|',
|
||||||
|
('is_public', '=', True),
|
||||||
|
('attendee_ids', 'in', [user.id])
|
||||||
|
]</field>
|
||||||
|
<field name="perm_read" eval="True"/>
|
||||||
|
<field name="perm_write" eval="False"/>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Plan: Instructors can manage their own plans -->
|
||||||
|
<record id="rule_plan_instructor" model="ir.rule">
|
||||||
|
<field name="name">Training Plan: Instructor Access</field>
|
||||||
|
<field name="model_id" ref="model_genius_plan"/>
|
||||||
|
<field name="groups" eval="[(4, ref('group_genius_instructor'))]"/>
|
||||||
|
<field name="domain_force">[
|
||||||
|
'|',
|
||||||
|
('instructor_ids', 'in', [user.id]),
|
||||||
|
('create_uid', '=', user.id)
|
||||||
|
]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Plan: Admin sees everything -->
|
||||||
|
<record id="rule_plan_admin" model="ir.rule">
|
||||||
|
<field name="name">Training Plan: Admin Full Access</field>
|
||||||
|
<field name="model_id" ref="model_genius_plan"/>
|
||||||
|
<field name="groups" eval="[(4, ref('group_genius_admin'))]"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Progress: Users see only their own progress -->
|
||||||
|
<record id="rule_progress_user" model="ir.rule">
|
||||||
|
<field name="name">Progress: User Own Records</field>
|
||||||
|
<field name="model_id" ref="model_genius_progress"/>
|
||||||
|
<field name="groups" eval="[(4, ref('group_genius_user'))]"/>
|
||||||
|
<field name="domain_force">[('user_id', '=', user.id)]</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"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Progress: Instructors see all progress in their plans -->
|
||||||
|
<record id="rule_progress_instructor" model="ir.rule">
|
||||||
|
<field name="name">Progress: Instructor Access</field>
|
||||||
|
<field name="model_id" ref="model_genius_progress"/>
|
||||||
|
<field name="groups" eval="[(4, ref('group_genius_instructor'))]"/>
|
||||||
|
<field name="domain_force">[
|
||||||
|
'|',
|
||||||
|
('plan_id.instructor_ids', 'in', [user.id]),
|
||||||
|
('plan_id.create_uid', '=', user.id)
|
||||||
|
]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Quiz Attempt: Users see only their own attempts -->
|
||||||
|
<record id="rule_quiz_attempt_user" model="ir.rule">
|
||||||
|
<field name="name">Quiz Attempt: User Own Records</field>
|
||||||
|
<field name="model_id" ref="model_genius_quiz_attempt"/>
|
||||||
|
<field name="groups" eval="[(4, ref('group_genius_user'))]"/>
|
||||||
|
<field name="domain_force">[('user_id', '=', user.id)]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Admin Rules: Full Access -->
|
||||||
|
<record id="rule_topic_admin" model="ir.rule">
|
||||||
|
<field name="name">Training Topic: Admin Full Access</field>
|
||||||
|
<field name="model_id" ref="model_genius_topic"/>
|
||||||
|
<field name="groups" eval="[(4, ref('group_genius_admin'))]"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rule_step_admin" model="ir.rule">
|
||||||
|
<field name="name">Training Step: Admin Full Access</field>
|
||||||
|
<field name="model_id" ref="model_genius_topic_step"/>
|
||||||
|
<field name="groups" eval="[(4, ref('group_genius_admin'))]"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rule_progress_admin" model="ir.rule">
|
||||||
|
<field name="name">Training Progress: Admin Full Access</field>
|
||||||
|
<field name="model_id" ref="model_genius_progress"/>
|
||||||
|
<field name="groups" eval="[(4, ref('group_genius_admin'))]"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Multi-company rules -->
|
||||||
|
<record id="rule_plan_company" model="ir.rule">
|
||||||
|
<field name="name">Training Plan: Multi-Company</field>
|
||||||
|
<field name="model_id" ref="model_genius_plan"/>
|
||||||
|
<field name="domain_force">[
|
||||||
|
'|',
|
||||||
|
('company_id', '=', False),
|
||||||
|
('company_id', 'in', company_ids)
|
||||||
|
]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rule_topic_company" model="ir.rule">
|
||||||
|
<field name="name">Training Topic: Multi-Company</field>
|
||||||
|
<field name="model_id" ref="model_genius_topic"/>
|
||||||
|
<field name="domain_force">[
|
||||||
|
'|',
|
||||||
|
('company_id', '=', False),
|
||||||
|
('company_id', 'in', company_ids)
|
||||||
|
]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 502 KiB |
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,453 @@
|
||||||
|
odoo.define('tour_genius.Dashboard', function (require) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var AbstractAction = require('web.AbstractAction');
|
||||||
|
var core = require('web.core');
|
||||||
|
var ajax = require('web.ajax');
|
||||||
|
var session = require('web.session');
|
||||||
|
var Dialog = require('web.Dialog');
|
||||||
|
var tour = require('web_tour.tour'); // Add tour require
|
||||||
|
|
||||||
|
var QWeb = core.qweb;
|
||||||
|
var _t = core._t;
|
||||||
|
|
||||||
|
var GeniusDashboard = AbstractAction.extend({
|
||||||
|
template: 'tour_genius.Dashboard',
|
||||||
|
cssLibs: [],
|
||||||
|
jsLibs: [],
|
||||||
|
|
||||||
|
events: {
|
||||||
|
'click .btn-new-tour': '_onNewTour',
|
||||||
|
'click .btn-run-tour': '_onRunTour',
|
||||||
|
'click .btn-edit-tour': '_onEditTour',
|
||||||
|
'click .btn-delete-tour': '_onDeleteTour',
|
||||||
|
'click .tour-card': '_onTourCardClick',
|
||||||
|
'keyup .search-input': '_onSearchTours',
|
||||||
|
// Quiz buttons
|
||||||
|
'click .btn-quiz-start': '_onQuizStart',
|
||||||
|
'click .btn-quiz-retry': '_onQuizStart',
|
||||||
|
'click .btn-quiz-improve': '_onQuizStart', // Improve uses same handler as start
|
||||||
|
'click .btn-quiz-certificate': '_onViewCertificate',
|
||||||
|
'click .stat-card': '_onFilterTours',
|
||||||
|
},
|
||||||
|
|
||||||
|
init: function (parent, action) {
|
||||||
|
this._super.apply(this, arguments);
|
||||||
|
this.action = action;
|
||||||
|
this.tours = [];
|
||||||
|
this.isAdmin = false;
|
||||||
|
this.userProgress = 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
willStart: function () {
|
||||||
|
var self = this;
|
||||||
|
return this._super.apply(this, arguments).then(function () {
|
||||||
|
return self._loadDashboardData();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
start: function () {
|
||||||
|
var self = this;
|
||||||
|
return this._super.apply(this, arguments).then(function () {
|
||||||
|
self._renderDashboard();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load dashboard data from server
|
||||||
|
*/
|
||||||
|
_loadDashboardData: function () {
|
||||||
|
var self = this;
|
||||||
|
return this._rpc({
|
||||||
|
model: 'genius.topic',
|
||||||
|
method: 'get_dashboard_data',
|
||||||
|
args: [],
|
||||||
|
}).then(function (result) {
|
||||||
|
self.tours = result.tours || [];
|
||||||
|
self.isAdmin = result.is_admin || false;
|
||||||
|
self.userProgress = result.progress || 0;
|
||||||
|
self.stats = result.stats || {};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the client action is re-attached to the DOM (e.g. back button)
|
||||||
|
* We reload the data to ensure it's fresh after changes in the form view.
|
||||||
|
*/
|
||||||
|
on_attach_callback: function () {
|
||||||
|
var self = this;
|
||||||
|
this._loadDashboardData().then(function() {
|
||||||
|
self._renderDashboard();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the dashboard
|
||||||
|
*/
|
||||||
|
_renderDashboard: function () {
|
||||||
|
var self = this;
|
||||||
|
this.$('.o_genius_dashboard_content').html(
|
||||||
|
QWeb.render('tour_genius.DashboardContent', {
|
||||||
|
tours: self.tours,
|
||||||
|
isAdmin: self.isAdmin,
|
||||||
|
userProgress: self.userProgress,
|
||||||
|
stats: self.stats,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new tour - Opens Recorder directly (no dialog)
|
||||||
|
* Tour Name and Module are entered in the Recorder Panel itself
|
||||||
|
*/
|
||||||
|
_onNewTour: function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
// Redirect to Recorder in "New Tour" mode
|
||||||
|
// The Recorder will handle tour creation on first step save
|
||||||
|
window.location.href = '/web?genius_recorder=new';
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a tour - Comprehensive cleanup and robust activation
|
||||||
|
* Handles all scenarios including incomplete tours and URL matching
|
||||||
|
*/
|
||||||
|
_onRunTour: function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
var self = this;
|
||||||
|
var tourId = $(ev.currentTarget).data('tour-id');
|
||||||
|
var tourName = 'genius_tour_' + tourId;
|
||||||
|
|
||||||
|
console.log('[Tour Genius] Running tour:', tourName);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PHASE 1: Complete cleanup of ALL tour state
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// 1a. Destroy all active tooltips
|
||||||
|
if (tour.active_tooltips) {
|
||||||
|
Object.keys(tour.active_tooltips).forEach(function(key) {
|
||||||
|
try {
|
||||||
|
var tip = tour.active_tooltips[key];
|
||||||
|
if (tip) {
|
||||||
|
if (tip.widget && tip.widget.destroy) {
|
||||||
|
tip.widget.destroy();
|
||||||
|
}
|
||||||
|
if (tip.$anchor) {
|
||||||
|
tip.$anchor.off('.tour_tip');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Tour Genius] Error cleaning tooltip:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tour.active_tooltips = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1b. Clear running tour state
|
||||||
|
tour.running_tour = null;
|
||||||
|
tour.paused = false;
|
||||||
|
|
||||||
|
// 1c. Clear ALL localStorage items related to tours
|
||||||
|
// Use reverse loop to avoid skipping when removing items
|
||||||
|
var keysToRemove = [];
|
||||||
|
for (var i = 0; i < window.localStorage.length; i++) {
|
||||||
|
var key = window.localStorage.key(i);
|
||||||
|
if (key && (
|
||||||
|
key.startsWith('debugging_tour_') ||
|
||||||
|
key.startsWith('tour_manager_') ||
|
||||||
|
key.startsWith('tour_step_') ||
|
||||||
|
key.endsWith('_was_active')
|
||||||
|
)) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keysToRemove.forEach(function(key) {
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1d. Reset current_step for ALL registered tours (including Odoo standard)
|
||||||
|
// This ensures any running Odoo tour is stopped when we start a Genius tour
|
||||||
|
if (tour.tours) {
|
||||||
|
Object.keys(tour.tours).forEach(function(name) {
|
||||||
|
var t = tour.tours[name];
|
||||||
|
if (t) {
|
||||||
|
t.current_step = 0;
|
||||||
|
// Only reset ready for genius tours (to force re-registration)
|
||||||
|
if (name.startsWith('genius_tour_')) {
|
||||||
|
t.ready = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Tour Genius] Cleanup complete, removed', keysToRemove.length, 'localStorage items');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PHASE 2: Activate the requested tour with forced reload
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function activateTour() {
|
||||||
|
var targetTour = tour.tours && tour.tours[tourName];
|
||||||
|
|
||||||
|
if (!targetTour) {
|
||||||
|
Dialog.alert(self, _t("Tour not found. Make sure the tour has at least one step. Try refreshing the page."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetTour.steps || targetTour.steps.length === 0) {
|
||||||
|
Dialog.alert(self, _t("This tour has no steps. Please add steps before running."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tourUrl = targetTour.url || '/web';
|
||||||
|
|
||||||
|
// Set the debugging flag (tells Odoo to activate this tour on load)
|
||||||
|
// NOTE: Do NOT set 'running_tour' - that triggers AUTOMATED test mode!
|
||||||
|
// Only 'debugging_tour_X' is needed for interactive mode
|
||||||
|
window.localStorage.setItem('debugging_tour_' + tourName, 'true');
|
||||||
|
|
||||||
|
// Force page reload by appending timestamp
|
||||||
|
// This ensures the tour_loader.js picks up the debugging flag
|
||||||
|
var targetUrl = window.location.origin + tourUrl;
|
||||||
|
|
||||||
|
// Parse and rebuild URL with timestamp to force reload
|
||||||
|
try {
|
||||||
|
var url = new URL(targetUrl);
|
||||||
|
url.searchParams.set('genius_tour_run', Date.now());
|
||||||
|
window.location.href = url.href;
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback: append timestamp manually
|
||||||
|
var separator = tourUrl.indexOf('?') >= 0 ? '&' : '?';
|
||||||
|
window.location.href = targetUrl + separator + 'genius_tour_run=' + Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if tour exists, if not try to reload
|
||||||
|
if (tour.tours && tour.tours[tourName]) {
|
||||||
|
activateTour();
|
||||||
|
} else {
|
||||||
|
// Tour not found - try reloading tour definitions
|
||||||
|
console.log('[Tour Genius] Tour not found, reloading definitions...');
|
||||||
|
|
||||||
|
var tourLoader = require('tour_genius.tour_loader');
|
||||||
|
if (tourLoader && tourLoader.registerAllTours) {
|
||||||
|
tourLoader.registerAllTours();
|
||||||
|
|
||||||
|
// Wait for registration, then try again
|
||||||
|
setTimeout(function() {
|
||||||
|
if (tour.tours && tour.tours[tourName]) {
|
||||||
|
activateTour();
|
||||||
|
} else {
|
||||||
|
Dialog.alert(self, _t("Tour not found or not loaded. Please refresh the page and try again."));
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
Dialog.alert(self, _t("Tour not found. Please refresh the page and try again."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit a tour (Admin only)
|
||||||
|
*/
|
||||||
|
_onEditTour: function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
var self = this;
|
||||||
|
var tourId = $(ev.currentTarget).data('tour-id');
|
||||||
|
|
||||||
|
this.do_action({
|
||||||
|
type: 'ir.actions.act_window',
|
||||||
|
name: _t('Edit Tour'),
|
||||||
|
res_model: 'genius.topic',
|
||||||
|
res_id: tourId,
|
||||||
|
view_mode: 'form',
|
||||||
|
views: [[false, 'form']],
|
||||||
|
target: 'current',
|
||||||
|
context: {
|
||||||
|
'from_genius_dashboard': true,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
// Return to dashboard after closing
|
||||||
|
on_close: function () {
|
||||||
|
self._loadDashboardData().then(function() {
|
||||||
|
self._renderDashboard();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a tour (Admin only)
|
||||||
|
*/
|
||||||
|
_onDeleteTour: function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
var tourId = $(ev.currentTarget).data('tour-id');
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
Dialog.confirm(this, _t("Are you sure you want to delete this tour?"), {
|
||||||
|
confirm_callback: function () {
|
||||||
|
self._rpc({
|
||||||
|
model: 'genius.topic',
|
||||||
|
method: 'unlink',
|
||||||
|
args: [[parseInt(tourId)]],
|
||||||
|
}).then(function () {
|
||||||
|
self._loadDashboardData().then(function() {
|
||||||
|
self._renderDashboard();
|
||||||
|
});
|
||||||
|
}).catch(function (error) {
|
||||||
|
// Handle deletion blocked by unlink() constraints
|
||||||
|
var message = error.message && error.message.data && error.message.data.message
|
||||||
|
? error.message.data.message
|
||||||
|
: _t('Cannot delete this tour. It may have user progress or completions.');
|
||||||
|
Dialog.alert(self, message, {
|
||||||
|
title: _t('Deletion Blocked'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click on tour card (view details)
|
||||||
|
*/
|
||||||
|
_onTourCardClick: function (ev) {
|
||||||
|
var tourId = $(ev.currentTarget).data('tour-id');
|
||||||
|
// If clicking buttons, don't trigger card click
|
||||||
|
if (!$(ev.target).closest('.btn').length) {
|
||||||
|
// Genius Logic: Card Click -> Edit/View Details (Form View)
|
||||||
|
this._onEditTour(ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter tours by status (Stat Cards)
|
||||||
|
*/
|
||||||
|
_onFilterTours: function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
var $card = $(ev.currentTarget);
|
||||||
|
var filter = $card.data('filter');
|
||||||
|
|
||||||
|
// Ignore progress card or undefined filters
|
||||||
|
if (!filter || filter === 'progress') return;
|
||||||
|
|
||||||
|
// Visual Feedback
|
||||||
|
this.$('.stat-card').removeClass('active');
|
||||||
|
$card.addClass('active');
|
||||||
|
|
||||||
|
// Reset search input
|
||||||
|
this.$('.search-input').val('');
|
||||||
|
|
||||||
|
// Filter Logic
|
||||||
|
this.$('.tour-card').each(function () {
|
||||||
|
var $tour = $(this);
|
||||||
|
var status = $tour.data('status');
|
||||||
|
|
||||||
|
// Match logic:
|
||||||
|
// 1. 'all' shows everything
|
||||||
|
// 2. Exact match
|
||||||
|
// 3. 'completed' should also include 'verified' (since verified means completed + passed quiz)
|
||||||
|
if (filter === 'all' ||
|
||||||
|
status === filter ||
|
||||||
|
(filter === 'completed' && status === 'verified')) {
|
||||||
|
$tour.fadeIn(200);
|
||||||
|
} else {
|
||||||
|
$tour.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search tours
|
||||||
|
*/
|
||||||
|
_onSearchTours: function (ev) {
|
||||||
|
var query = $(ev.currentTarget).val().toLowerCase();
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
this.$('.tour-card').each(function () {
|
||||||
|
var name = $(this).find('.tour-name').text().toLowerCase();
|
||||||
|
var module = $(this).find('.tour-module').text().toLowerCase();
|
||||||
|
|
||||||
|
if (name.indexOf(query) >= 0 || module.indexOf(query) >= 0) {
|
||||||
|
$(this).show();
|
||||||
|
} else {
|
||||||
|
$(this).hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start/Retry Quiz from dashboard
|
||||||
|
*/
|
||||||
|
_onQuizStart: function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
var tourId = $(ev.currentTarget).data('tour-id');
|
||||||
|
|
||||||
|
// Find tour data
|
||||||
|
var tour = this.tours.find(function(t) { return t.id === tourId; });
|
||||||
|
if (!tour || !tour.quiz_id) {
|
||||||
|
console.warn('[Tour Genius] No quiz found for tour:', tourId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import and open GeniusQuizPopup
|
||||||
|
var QuizModule = require('tour_genius.GeniusQuizPopup');
|
||||||
|
var GeniusQuizPopup = QuizModule.GeniusQuizPopup;
|
||||||
|
|
||||||
|
var popup = new GeniusQuizPopup(self, {
|
||||||
|
quizId: tour.quiz_id,
|
||||||
|
topicId: tourId,
|
||||||
|
quizName: tour.name + ' Quiz',
|
||||||
|
showCertificate: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
popup.appendTo(document.body).then(function() {
|
||||||
|
console.log('[Tour Genius] Quiz popup opened for tour:', tour.name);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View Certificate from dashboard
|
||||||
|
*/
|
||||||
|
_onViewCertificate: function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
var tourId = $(ev.currentTarget).data('tour-id');
|
||||||
|
|
||||||
|
// Find tour data
|
||||||
|
var tour = this.tours.find(function(t) { return t.id === tourId; });
|
||||||
|
if (!tour || !tour.quiz_id) {
|
||||||
|
console.warn('[Tour Genius] No quiz found for tour:', tourId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open GeniusQuizPopup in certificate mode
|
||||||
|
var GeniusQuizPopup = require('tour_genius.GeniusQuizPopup');
|
||||||
|
|
||||||
|
var popup = new GeniusQuizPopup({
|
||||||
|
quizId: tour.quiz_id,
|
||||||
|
topicId: tourId,
|
||||||
|
quizName: tour.name,
|
||||||
|
showCertificate: true,
|
||||||
|
existingScore: tour.quiz_score || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
popup.appendTo(document.body).then(function() {
|
||||||
|
console.log('[Tour Genius] Certificate view opened for tour:', tour.name);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
core.action_registry.add('genius_dashboard', GeniusDashboard);
|
||||||
|
|
||||||
|
return GeniusDashboard;
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
/**
|
||||||
|
* Tour Genius - Celebration Widget
|
||||||
|
* ================================
|
||||||
|
* A "genius" themed celebration widget for Tour Genius completion.
|
||||||
|
* Features lightbulb "eureka" moment with particle effects.
|
||||||
|
*/
|
||||||
|
|
||||||
|
odoo.define('tour_genius.GeniusCelebration', function (require) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var Widget = require('web.Widget');
|
||||||
|
var core = require('web.core');
|
||||||
|
|
||||||
|
var _t = core._t;
|
||||||
|
|
||||||
|
var GeniusCelebration = Widget.extend({
|
||||||
|
template: 'tour_genius.genius_celebration_v2',
|
||||||
|
xmlDependencies: ['/tour_genius/static/src/xml/genius_celebration.xml'],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
* @constructor
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {string} [options.message] Message to display
|
||||||
|
* @param {string} [options.fadeout] Delay: 'fast', 'medium', 'slow', 'no'
|
||||||
|
*/
|
||||||
|
init: function (options) {
|
||||||
|
this._super.apply(this, arguments);
|
||||||
|
var celebrationDelay = {slow: 5500, medium: 4500, fast: 3000, no: false};
|
||||||
|
this.options = _.defaults(options || {}, {
|
||||||
|
fadeout: 'medium',
|
||||||
|
message: _t('<strong>Well Done!</strong> You completed the tour!'),
|
||||||
|
quiz: null, // Default quiz to null
|
||||||
|
});
|
||||||
|
this.delay = celebrationDelay[this.options.fadeout];
|
||||||
|
},
|
||||||
|
|
||||||
|
events: {
|
||||||
|
'click .o_take_quiz_btn': '_onTakeQuiz',
|
||||||
|
'click .o_view_certificate_btn': '_onViewCertificate',
|
||||||
|
'click .o_genius_close_btn': '_onClose',
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
start: function () {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
// Clean up any persistent hand from tour
|
||||||
|
if (typeof window.cleanupGeniusHand === 'function') {
|
||||||
|
window.cleanupGeniusHand();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destroy on click outside
|
||||||
|
setTimeout(function () {
|
||||||
|
core.bus.on('click', self, function (ev) {
|
||||||
|
// Safety check: className might be SVGAnimatedString for SVG elements
|
||||||
|
var className = ev.target.className;
|
||||||
|
var classStr = (typeof className === 'string') ? className : (className.baseVal || '');
|
||||||
|
|
||||||
|
if (ev.originalEvent &&
|
||||||
|
classStr.indexOf('o_genius') === -1 &&
|
||||||
|
classStr.indexOf('o_take_quiz_btn') === -1 &&
|
||||||
|
classStr.indexOf('o_view_certificate_btn') === -1 &&
|
||||||
|
$(ev.target).parents('.o_genius_reward').length === 0) {
|
||||||
|
this.destroy(); // Fix: Bind to proper context or use self
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto fadeout after delay
|
||||||
|
if (this.delay) {
|
||||||
|
setTimeout(function () {
|
||||||
|
self.$el.addClass('o_genius_fading');
|
||||||
|
setTimeout(function () {
|
||||||
|
self.destroy();
|
||||||
|
}, 800);
|
||||||
|
}, this.delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the message content
|
||||||
|
this.$('.o_genius_msg_content').html(this.options.message);
|
||||||
|
|
||||||
|
return this._super.apply(this, arguments);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
* Clean up bus listener to prevent memory leaks
|
||||||
|
*/
|
||||||
|
destroy: function () {
|
||||||
|
core.bus.off('click', this);
|
||||||
|
this._super.apply(this, arguments);
|
||||||
|
},
|
||||||
|
|
||||||
|
_onClose: function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
this.destroy();
|
||||||
|
},
|
||||||
|
_onTakeQuiz: function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
var quiz = this.options.quiz;
|
||||||
|
|
||||||
|
// Open the Genius Quiz Popup
|
||||||
|
// FIX: GeniusQuizPopup is returned as a property of the module object
|
||||||
|
var GeniusQuizPopupModule = require('tour_genius.GeniusQuizPopup');
|
||||||
|
var GeniusQuizPopup = GeniusQuizPopupModule.GeniusQuizPopup;
|
||||||
|
|
||||||
|
var popup = new GeniusQuizPopup(null, {
|
||||||
|
quizId: quiz.id,
|
||||||
|
topicId: quiz.source_tour_id,
|
||||||
|
quizName: quiz.name,
|
||||||
|
});
|
||||||
|
popup.appendTo($('body'));
|
||||||
|
|
||||||
|
// Close the celebration
|
||||||
|
self.destroy();
|
||||||
|
},
|
||||||
|
|
||||||
|
_onViewCertificate: function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
var quiz = this.options.quiz;
|
||||||
|
|
||||||
|
// Get attempt_id via RPC then open PDF directly
|
||||||
|
var rpc = require('web.rpc');
|
||||||
|
|
||||||
|
rpc.query({
|
||||||
|
model: 'genius.topic',
|
||||||
|
method: 'get_certificate_data',
|
||||||
|
args: [[quiz.source_tour_id]],
|
||||||
|
}).then(function (data) {
|
||||||
|
if (data && data.attempt_id) {
|
||||||
|
// Open PDF in new tab
|
||||||
|
window.open('/tour_genius/certificate/view/' + data.attempt_id, '_blank');
|
||||||
|
} else {
|
||||||
|
console.warn('[GeniusCelebration] No certificate found');
|
||||||
|
}
|
||||||
|
// Close the celebration
|
||||||
|
self.destroy();
|
||||||
|
}).catch(function (err) {
|
||||||
|
console.error('[GeniusCelebration] Error getting certificate:', err);
|
||||||
|
self.destroy();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return GeniusCelebration;
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,866 @@
|
||||||
|
/**
|
||||||
|
* Tour Genius - Genius Quiz Popup Widget
|
||||||
|
* ========================================
|
||||||
|
* Beautiful popup quiz experience with glassmorphism design.
|
||||||
|
* Displays one question at a time with smooth animations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
odoo.define('tour_genius.GeniusQuizPopup', function (require) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var Widget = require('web.Widget');
|
||||||
|
var AbstractField = require('web.AbstractField');
|
||||||
|
var rpc = require('web.rpc');
|
||||||
|
var core = require('web.core');
|
||||||
|
|
||||||
|
var _t = core._t;
|
||||||
|
var QWeb = core.qweb;
|
||||||
|
|
||||||
|
var GeniusQuizPopup = Widget.extend({
|
||||||
|
template: 'tour_genius.GeniusQuizPopup_v2',
|
||||||
|
xmlDependencies: ['/tour_genius/static/src/xml/genius_quiz_popup.xml'],
|
||||||
|
|
||||||
|
events: {
|
||||||
|
'click .o_answer_option': '_onAnswerSelect',
|
||||||
|
'click .o_quiz_next': '_onNext',
|
||||||
|
'click .o_quiz_prev': '_onPrev',
|
||||||
|
'click .o_quiz_submit': '_onSubmit',
|
||||||
|
'click .o_quiz_close': '_onClose',
|
||||||
|
'click .o_quiz_retry': '_onRetry',
|
||||||
|
'change .o_review_answers_toggle': '_onReviewToggle',
|
||||||
|
'keyup .o_short_answer_input': '_onShortAnswerChange',
|
||||||
|
'click .o_confirm_yes': '_onConfirmYes',
|
||||||
|
'click .o_confirm_no': '_onConfirmNo',
|
||||||
|
// Ordering drag-and-drop
|
||||||
|
'dragstart .o_ordering_item': '_onOrderingDragStart',
|
||||||
|
'dragover .o_ordering_item': '_onOrderingDragOver',
|
||||||
|
'drop .o_ordering_item': '_onOrderingDrop',
|
||||||
|
'dragend .o_ordering_item': '_onOrderingDragEnd',
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {number} options.quizId - Quiz ID
|
||||||
|
* @param {number} options.topicId - Source Topic ID
|
||||||
|
* @param {string} options.quizName - Quiz Name
|
||||||
|
* @param {boolean} options.showCertificate - Show certificate directly (for passed quizzes)
|
||||||
|
* @param {number} options.existingScore - Existing score (when showing certificate)
|
||||||
|
*/
|
||||||
|
init: function (parent, options) {
|
||||||
|
this._super(parent);
|
||||||
|
options = options || {};
|
||||||
|
this.quizId = options.quizId;
|
||||||
|
this.topicId = options.topicId;
|
||||||
|
this.quizName = options.quizName || 'Quiz';
|
||||||
|
this.showCertificate = options.showCertificate || false;
|
||||||
|
this.existingScore = options.existingScore || 0;
|
||||||
|
this.isPreview = options.isPreview || false;
|
||||||
|
|
||||||
|
this.attemptId = null;
|
||||||
|
this.questions = [];
|
||||||
|
this.currentIndex = 0;
|
||||||
|
this.responses = {};
|
||||||
|
this.timeLimit = 0;
|
||||||
|
this.timerInterval = null;
|
||||||
|
this.timeRemaining = 0;
|
||||||
|
|
||||||
|
this.isSubmitted = false;
|
||||||
|
this.results = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
willStart: function () {
|
||||||
|
var self = this;
|
||||||
|
return this._super.apply(this, arguments).then(function () {
|
||||||
|
// If showing certificate, skip loading quiz data
|
||||||
|
if (self.showCertificate) {
|
||||||
|
return self._loadCertificateData();
|
||||||
|
}
|
||||||
|
return self._loadQuizData();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
start: function () {
|
||||||
|
var self = this;
|
||||||
|
console.log('[GeniusQuizPopup] Start called. Template:', this.template);
|
||||||
|
console.log('[GeniusQuizPopup] $el:', this.$el);
|
||||||
|
|
||||||
|
return this._super.apply(this, arguments).then(function () {
|
||||||
|
console.log('🚀 ANTIGRAVITY: GeniusQuizPopup Loaded v2 - Ghost Code Exorcised');
|
||||||
|
if (self.showCertificate) {
|
||||||
|
// console.log('[GeniusQuizPopup] Showing certificate');
|
||||||
|
// self._renderCertificate(); // REMOVED: Ghost code causing legacy UI
|
||||||
|
// Fallback to results if certificate requested but method missing
|
||||||
|
self._renderResults();
|
||||||
|
} else {
|
||||||
|
console.log('[GeniusQuizPopup] Rendering Question');
|
||||||
|
self._renderQuestion();
|
||||||
|
self._startTimer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy: function () {
|
||||||
|
if (this.timerInterval) {
|
||||||
|
clearInterval(this.timerInterval);
|
||||||
|
}
|
||||||
|
this._super.apply(this, arguments);
|
||||||
|
},
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Data Loading
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
_loadQuizData: function () {
|
||||||
|
var self = this;
|
||||||
|
var def;
|
||||||
|
|
||||||
|
if (this.isPreview && this.quizId) {
|
||||||
|
// Preview Mode: Load directly from Quiz model
|
||||||
|
def = rpc.query({
|
||||||
|
model: 'genius.quiz',
|
||||||
|
method: 'action_start_quiz_preview',
|
||||||
|
args: [[this.quizId]],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Normal Mode: Load via Topic (Tour Context)
|
||||||
|
def = rpc.query({
|
||||||
|
model: 'genius.topic',
|
||||||
|
method: 'action_start_quiz_popup',
|
||||||
|
args: [[this.topicId]],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return def.then(function (data) {
|
||||||
|
if (!data || data.error) {
|
||||||
|
self.loadError = data ? data.error : _t('Failed to load quiz');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.attemptId = data.attempt_id;
|
||||||
|
self.questions = data.questions || [];
|
||||||
|
self.timeLimit = data.time_limit_minutes || 0;
|
||||||
|
self.timeRemaining = self.timeLimit * 60; // Convert to seconds
|
||||||
|
self.passingScore = data.passing_score || 70;
|
||||||
|
|
||||||
|
// Initialize responses
|
||||||
|
self.questions.forEach(function (q) {
|
||||||
|
self.responses[q.id] = {
|
||||||
|
selectedIds: [],
|
||||||
|
textAnswer: '',
|
||||||
|
orderSequence: q.type === 'ordering' ? q.answers.map(function(a) { return a.id; }) : []
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}).catch(function (err) {
|
||||||
|
console.error('[GeniusQuizPopup] Error loading quiz:', err);
|
||||||
|
// Handle concurrent access error (row locked)
|
||||||
|
if (err.message && err.message.includes('could not obtain lock')) {
|
||||||
|
self.loadError = _t('Quiz is being opened in another tab. Please close other tabs and try again.');
|
||||||
|
} else {
|
||||||
|
self.loadError = _t('Failed to load quiz. Please try again.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_loadCertificateData: function () {
|
||||||
|
var self = this;
|
||||||
|
return rpc.query({
|
||||||
|
model: 'genius.topic',
|
||||||
|
method: 'get_certificate_data',
|
||||||
|
args: [[this.topicId]],
|
||||||
|
}).then(function (data) {
|
||||||
|
if (!data || data.error) {
|
||||||
|
self.loadError = data ? data.error : 'Failed to load certificate';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set results for certificate display
|
||||||
|
self.results = {
|
||||||
|
attempt_id: data.attempt_id, // For PDF download
|
||||||
|
score: data.score || self.existingScore,
|
||||||
|
is_passed: true,
|
||||||
|
points_earned: data.points_earned || 0,
|
||||||
|
points_possible: data.points_possible || 0,
|
||||||
|
time_taken: data.time_taken || 0,
|
||||||
|
user_name: data.user_name,
|
||||||
|
date: data.date,
|
||||||
|
can_retry: false,
|
||||||
|
};
|
||||||
|
self.passingScore = data.passing_score || 70;
|
||||||
|
}).catch(function (err) {
|
||||||
|
console.error('[GeniusQuizPopup] Error loading certificate:', err);
|
||||||
|
// Fallback: create minimal results from existing data
|
||||||
|
self.results = {
|
||||||
|
score: self.existingScore,
|
||||||
|
is_passed: true,
|
||||||
|
points_earned: 0,
|
||||||
|
points_possible: 0,
|
||||||
|
time_taken: 0,
|
||||||
|
user_name: 'User',
|
||||||
|
date: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
|
||||||
|
can_retry: false,
|
||||||
|
};
|
||||||
|
self.passingScore = 70;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Rendering
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
_renderQuestion: function () {
|
||||||
|
if (this.loadError) {
|
||||||
|
this.$('.o_quiz_question_container').html(
|
||||||
|
'<div class="o_quiz_error">' +
|
||||||
|
'<i class="fa fa-exclamation-circle"></i> ' + this.loadError +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.questions.length === 0) {
|
||||||
|
this.$('.o_quiz_question_container').html(
|
||||||
|
'<div class="o_quiz_error">' +
|
||||||
|
'<i class="fa fa-info-circle"></i> No questions in this quiz' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var question = this.questions[this.currentIndex];
|
||||||
|
var response = this.responses[question.id];
|
||||||
|
|
||||||
|
// Render question content
|
||||||
|
this.$('.o_quiz_question_container').html(
|
||||||
|
QWeb.render('tour_genius.QuizQuestion', {
|
||||||
|
question: question,
|
||||||
|
index: this.currentIndex,
|
||||||
|
total: this.questions.length,
|
||||||
|
response: response,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
var progress = ((this.currentIndex + 1) / this.questions.length) * 100;
|
||||||
|
this.$('.o_quiz_progress_fill').css('width', progress + '%');
|
||||||
|
this.$('.o_quiz_progress_text').text((this.currentIndex + 1) + ' / ' + this.questions.length);
|
||||||
|
|
||||||
|
// Update navigation buttons
|
||||||
|
this.$('.o_quiz_prev').prop('disabled', this.currentIndex === 0);
|
||||||
|
|
||||||
|
var isLast = this.currentIndex === this.questions.length - 1;
|
||||||
|
this.$('.o_quiz_next').toggle(!isLast);
|
||||||
|
this.$('.o_quiz_submit').toggle(isLast);
|
||||||
|
|
||||||
|
// Animation
|
||||||
|
var self = this;
|
||||||
|
this.$('.o_quiz_question_container').addClass('o_quiz_fade_in');
|
||||||
|
setTimeout(function () {
|
||||||
|
self.$('.o_quiz_question_container').removeClass('o_quiz_fade_in');
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderResults: function () {
|
||||||
|
// Render V2 template to bypass cache
|
||||||
|
this.$('.o_quiz_body').html(
|
||||||
|
QWeb.render('tour_genius.QuizResults_v2', {
|
||||||
|
results: this.results,
|
||||||
|
quizName: this.quizName,
|
||||||
|
passingScore: this.passingScore,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update header
|
||||||
|
this.$('.o_quiz_timer').hide();
|
||||||
|
this.$('.o_quiz_title').text(this.results.is_passed ? '🎉 Congratulations!' : '📝 Quiz Complete');
|
||||||
|
},
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Timer
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
_startTimer: function () {
|
||||||
|
if (this.timeLimit <= 0) {
|
||||||
|
this.$('.o_quiz_timer').hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
this._updateTimerDisplay();
|
||||||
|
|
||||||
|
this.timerInterval = setInterval(function () {
|
||||||
|
self.timeRemaining--;
|
||||||
|
self._updateTimerDisplay();
|
||||||
|
|
||||||
|
if (self.timeRemaining <= 0) {
|
||||||
|
clearInterval(self.timerInterval);
|
||||||
|
self._autoSubmit();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateTimerDisplay: function () {
|
||||||
|
var minutes = Math.floor(this.timeRemaining / 60);
|
||||||
|
var seconds = this.timeRemaining % 60;
|
||||||
|
var display = String(minutes).padStart(2, '0') + ':' + String(seconds).padStart(2, '0');
|
||||||
|
|
||||||
|
this.$('.o_quiz_timer_value').text(display);
|
||||||
|
|
||||||
|
// Warning colors
|
||||||
|
if (this.timeRemaining <= 60) {
|
||||||
|
this.$('.o_quiz_timer').addClass('o_quiz_timer_critical');
|
||||||
|
} else if (this.timeRemaining <= 120) {
|
||||||
|
this.$('.o_quiz_timer').addClass('o_quiz_timer_warning');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Event Handlers
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
_onAnswerSelect: function (ev) {
|
||||||
|
var $option = $(ev.currentTarget);
|
||||||
|
var answerId = parseInt($option.data('answer-id'));
|
||||||
|
var question = this.questions[this.currentIndex];
|
||||||
|
var response = this.responses[question.id];
|
||||||
|
var $icon = $option.find('.o_answer_indicator i');
|
||||||
|
|
||||||
|
if (question.type === 'multiple') {
|
||||||
|
// Toggle selection for multiple choice (checkbox style)
|
||||||
|
var idx = response.selectedIds.indexOf(answerId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
// Deselect
|
||||||
|
response.selectedIds.splice(idx, 1);
|
||||||
|
$option.removeClass('selected');
|
||||||
|
$icon.removeClass('fa-check-square').addClass('fa-square-o');
|
||||||
|
} else {
|
||||||
|
// Select
|
||||||
|
response.selectedIds.push(answerId);
|
||||||
|
$option.addClass('selected');
|
||||||
|
$icon.removeClass('fa-square-o').addClass('fa-check-square');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single selection (radio style - including true/false)
|
||||||
|
response.selectedIds = [answerId];
|
||||||
|
|
||||||
|
// Reset all options first
|
||||||
|
this.$('.o_answer_option').removeClass('selected');
|
||||||
|
this.$('.o_answer_option .o_answer_indicator i')
|
||||||
|
.removeClass('fa-dot-circle-o')
|
||||||
|
.addClass('fa-circle-o');
|
||||||
|
|
||||||
|
// Select current option
|
||||||
|
$option.addClass('selected');
|
||||||
|
$icon.removeClass('fa-circle-o').addClass('fa-dot-circle-o');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_onShortAnswerChange: function (ev) {
|
||||||
|
var question = this.questions[this.currentIndex];
|
||||||
|
this.responses[question.id].textAnswer = $(ev.currentTarget).val();
|
||||||
|
},
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Ordering Drag-and-Drop Handlers
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
_onOrderingDragStart: function (ev) {
|
||||||
|
var $item = $(ev.currentTarget);
|
||||||
|
this._draggedOrderItem = $item;
|
||||||
|
$item.addClass('o_ordering_dragging');
|
||||||
|
ev.originalEvent.dataTransfer.effectAllowed = 'move';
|
||||||
|
ev.originalEvent.dataTransfer.setData('text/plain', $item.data('answer-id'));
|
||||||
|
},
|
||||||
|
|
||||||
|
_onOrderingDragOver: function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.originalEvent.dataTransfer.dropEffect = 'move';
|
||||||
|
|
||||||
|
var $target = $(ev.currentTarget);
|
||||||
|
if (!this._draggedOrderItem || $target[0] === this._draggedOrderItem[0]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visual feedback
|
||||||
|
this.$('.o_ordering_item').removeClass('o_ordering_drop_target');
|
||||||
|
$target.addClass('o_ordering_drop_target');
|
||||||
|
},
|
||||||
|
|
||||||
|
_onOrderingDrop: function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
var $target = $(ev.currentTarget);
|
||||||
|
|
||||||
|
if (!this._draggedOrderItem || $target[0] === this._draggedOrderItem[0]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder in DOM
|
||||||
|
var $container = this.$('.o_ordering_items');
|
||||||
|
var items = $container.children('.o_ordering_item').toArray();
|
||||||
|
var draggedIndex = items.indexOf(this._draggedOrderItem[0]);
|
||||||
|
var targetIndex = items.indexOf($target[0]);
|
||||||
|
|
||||||
|
if (draggedIndex < targetIndex) {
|
||||||
|
$target.after(this._draggedOrderItem);
|
||||||
|
} else {
|
||||||
|
$target.before(this._draggedOrderItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update response
|
||||||
|
this._updateOrderingSequence();
|
||||||
|
|
||||||
|
// Update visual numbers
|
||||||
|
this._updateOrderingNumbers();
|
||||||
|
},
|
||||||
|
|
||||||
|
_onOrderingDragEnd: function (ev) {
|
||||||
|
this.$('.o_ordering_item').removeClass('o_ordering_dragging o_ordering_drop_target');
|
||||||
|
this._draggedOrderItem = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateOrderingSequence: function () {
|
||||||
|
var question = this.questions[this.currentIndex];
|
||||||
|
var response = this.responses[question.id];
|
||||||
|
|
||||||
|
// Get current order from DOM
|
||||||
|
var newSequence = [];
|
||||||
|
this.$('.o_ordering_item').each(function () {
|
||||||
|
newSequence.push(parseInt($(this).data('answer-id')));
|
||||||
|
});
|
||||||
|
|
||||||
|
response.orderSequence = newSequence;
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateOrderingNumbers: function () {
|
||||||
|
this.$('.o_ordering_item').each(function (index) {
|
||||||
|
$(this).find('.o_ordering_number').text(index + 1);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_onNext: function () {
|
||||||
|
if (this.currentIndex < this.questions.length - 1) {
|
||||||
|
this.currentIndex++;
|
||||||
|
this._renderQuestion();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_onPrev: function () {
|
||||||
|
if (this.currentIndex > 0) {
|
||||||
|
this.currentIndex--;
|
||||||
|
this._renderQuestion();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_onSubmit: function () {
|
||||||
|
// Check for unanswered questions
|
||||||
|
var unansweredCount = this._countUnansweredQuestions();
|
||||||
|
|
||||||
|
if (unansweredCount > 0) {
|
||||||
|
// Show custom confirmation overlay instead of native confirm()
|
||||||
|
this._showConfirmationOverlay(unansweredCount);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._submitQuiz();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show custom confirmation overlay for unanswered questions
|
||||||
|
*/
|
||||||
|
_showConfirmationOverlay: function (unansweredCount) {
|
||||||
|
var overlayHtml = QWeb.render('tour_genius.QuizConfirmSubmit', {
|
||||||
|
unansweredCount: unansweredCount,
|
||||||
|
});
|
||||||
|
this.$('.o_quiz_modal').append(overlayHtml);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the confirmation overlay
|
||||||
|
*/
|
||||||
|
_hideConfirmationOverlay: function () {
|
||||||
|
this.$('.o_quiz_confirm_overlay').remove();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User confirmed to submit anyway
|
||||||
|
*/
|
||||||
|
_onConfirmYes: function () {
|
||||||
|
this._hideConfirmationOverlay();
|
||||||
|
this._submitQuiz();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User cancelled submission
|
||||||
|
*/
|
||||||
|
_onConfirmNo: function () {
|
||||||
|
this._hideConfirmationOverlay();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count how many questions have no response
|
||||||
|
*/
|
||||||
|
_countUnansweredQuestions: function () {
|
||||||
|
var self = this;
|
||||||
|
var count = 0;
|
||||||
|
|
||||||
|
this.questions.forEach(function (q) {
|
||||||
|
var response = self.responses[q.id];
|
||||||
|
if (!response) {
|
||||||
|
count++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.type === 'short_answer' || q.type === 'fill_blank') {
|
||||||
|
// Text-based answers: check if text is empty
|
||||||
|
if (!response.textAnswer || response.textAnswer.trim() === '') {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
} else if (q.type === 'ordering') {
|
||||||
|
// Ordering: considered answered if user has interacted (sequence differs from initial)
|
||||||
|
// For simplicity, ordering is always considered "answered" since initial state is valid
|
||||||
|
// The user's current order is their answer
|
||||||
|
} else {
|
||||||
|
// Single/Multiple: check if any selection made
|
||||||
|
if (!response.selectedIds || response.selectedIds.length === 0) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return count;
|
||||||
|
},
|
||||||
|
|
||||||
|
_autoSubmit: function () {
|
||||||
|
// Time ran out
|
||||||
|
this._submitQuiz();
|
||||||
|
},
|
||||||
|
|
||||||
|
_submitQuiz: function () {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
if (this.isSubmitted) return;
|
||||||
|
this.isSubmitted = true;
|
||||||
|
|
||||||
|
// Show loading
|
||||||
|
this.$('.o_quiz_submit').prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> Submitting...');
|
||||||
|
|
||||||
|
// Prepare responses data
|
||||||
|
var responsesData = [];
|
||||||
|
for (var qId in this.responses) {
|
||||||
|
var resp = this.responses[qId];
|
||||||
|
var data = {
|
||||||
|
question_id: parseInt(qId),
|
||||||
|
selected_answer_ids: resp.selectedIds,
|
||||||
|
text_answer: resp.textAnswer,
|
||||||
|
};
|
||||||
|
// For ordering questions, send the sequence as selected_answer_ids
|
||||||
|
// The backend will compare this order with the correct order
|
||||||
|
if (resp.orderSequence && resp.orderSequence.length > 0) {
|
||||||
|
data.selected_answer_ids = resp.orderSequence;
|
||||||
|
}
|
||||||
|
responsesData.push(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc.query({
|
||||||
|
model: 'genius.quiz.attempt',
|
||||||
|
method: 'action_submit_from_popup',
|
||||||
|
args: [[this.attemptId], responsesData],
|
||||||
|
}).then(function (results) {
|
||||||
|
self.results = results;
|
||||||
|
self._renderResults();
|
||||||
|
|
||||||
|
if (self.timerInterval) {
|
||||||
|
clearInterval(self.timerInterval);
|
||||||
|
}
|
||||||
|
}).catch(function (err) {
|
||||||
|
console.error('[GeniusQuizPopup] Submit error:', err);
|
||||||
|
self.$('.o_quiz_submit').prop('disabled', false).html('<i class="fa fa-check"></i> Submit');
|
||||||
|
self.isSubmitted = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_onClose: function () {
|
||||||
|
// Confirm before closing if quiz is in progress
|
||||||
|
if (!this.showCertificate && !this.isSubmitted && this.questions.length > 0) {
|
||||||
|
var confirmed = confirm(_t('Are you sure you want to exit? Your progress will be lost.'));
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genius Logic: Delete the attempt if user aborts (Normal Mode)
|
||||||
|
if (this.attemptId && !this.isPreview) {
|
||||||
|
rpc.query({
|
||||||
|
model: 'genius.quiz.attempt',
|
||||||
|
method: 'action_cancel_attempt',
|
||||||
|
args: [[this.attemptId]],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genius Logic: For Test Mode, delete the attempt when closing ONLY if not submitted
|
||||||
|
// (If submitted, user saw results - we keep for debugging/history, or delete on next open)
|
||||||
|
if (this.isPreview && this.attemptId && !this.isSubmitted) {
|
||||||
|
rpc.query({
|
||||||
|
model: 'genius.quiz.attempt',
|
||||||
|
method: 'action_cancel_attempt',
|
||||||
|
args: [[this.attemptId]],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.destroy();
|
||||||
|
},
|
||||||
|
|
||||||
|
_onRetry: function () {
|
||||||
|
// Reset state
|
||||||
|
this.isSubmitted = false;
|
||||||
|
this.results = null;
|
||||||
|
this.currentIndex = 0;
|
||||||
|
this.responses = {};
|
||||||
|
this.timeRemaining = this.timeLimit * 60;
|
||||||
|
|
||||||
|
// Reset progress bar immediately for visual feedback
|
||||||
|
this.$('.o_quiz_progress_fill').css('width', '0%');
|
||||||
|
this.$('.o_quiz_progress_text').text('1 / ?');
|
||||||
|
|
||||||
|
// Reset timer display
|
||||||
|
this.$('.o_quiz_timer').removeClass('o_quiz_timer_warning o_quiz_timer_critical');
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
this._loadQuizData().then(function () {
|
||||||
|
// Re-render the body using QWeb template for consistency
|
||||||
|
var bodyHtml = QWeb.render('tour_genius.QuizBody', {});
|
||||||
|
self.$('.o_quiz_body').html(bodyHtml);
|
||||||
|
|
||||||
|
self._renderQuestion();
|
||||||
|
self._startTimer();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle Answer Review section when switch is changed
|
||||||
|
*/
|
||||||
|
_onReviewToggle: function (ev) {
|
||||||
|
var self = this;
|
||||||
|
var isChecked = $(ev.target).is(':checked');
|
||||||
|
var $reviewSection = this.$('.o_answer_review_section');
|
||||||
|
|
||||||
|
if (!isChecked) {
|
||||||
|
// Hide the section
|
||||||
|
$reviewSection.slideUp(300);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the section - build content if empty
|
||||||
|
if ($reviewSection.children().length === 0) {
|
||||||
|
var correctAnswers = this.results.correct_answers || {};
|
||||||
|
var html = '<h5 style="color: #667eea; margin-bottom: 15px; text-align: center; font-weight: 600;"><i class="fa fa-list"></i> Answer Review</h5>';
|
||||||
|
|
||||||
|
this.questions.forEach(function (q, idx) {
|
||||||
|
var review = correctAnswers[q.id] || {};
|
||||||
|
var isCorrect = review.is_correct;
|
||||||
|
var statusClass = isCorrect ? 'text-success' : 'text-danger';
|
||||||
|
var statusIcon = isCorrect ? 'fa-check-circle' : 'fa-times-circle';
|
||||||
|
var borderColor = isCorrect ? '#48bb78' : '#e53e3e';
|
||||||
|
var bgColor = isCorrect ? '#f0fff4' : '#fff5f5';
|
||||||
|
|
||||||
|
html += '<div class="o_review_question" style="padding: 15px; margin-bottom: 12px; background: ' + bgColor + '; border-radius: 10px; border-left: 4px solid ' + borderColor + ';">';
|
||||||
|
|
||||||
|
// Question header
|
||||||
|
html += '<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px;">';
|
||||||
|
html += '<strong style="color: #2d3748; flex: 1;">Q' + (idx + 1) + ': ' + self._escapeHtml(q.text || '') + '</strong>';
|
||||||
|
html += '<span class="' + statusClass + '" style="margin-left: 10px; font-size: 18px;"><i class="fa ' + statusIcon + '"></i></span>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Answer details
|
||||||
|
html += '<div style="font-size: 13px; color: #4a5568;">';
|
||||||
|
|
||||||
|
if (isCorrect) {
|
||||||
|
// User is correct -> Clean display (Your answer is the correct one)
|
||||||
|
var displayAnswer = '';
|
||||||
|
if (q.type === 'short_answer' || q.type === 'fill_blank') {
|
||||||
|
displayAnswer = review.user_answer || '(empty)';
|
||||||
|
} else if (q.type === 'ordering') {
|
||||||
|
displayAnswer = (review.user_order || []).join(' → ') || '(not ordered)';
|
||||||
|
} else {
|
||||||
|
var uAnswers = review.user_answers || [];
|
||||||
|
displayAnswer = uAnswers.length > 0 ? uAnswers.join(', ') : '(no answer)';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<div style="margin-bottom: 6px;"><span style="color: #718096;">Your answer:</span> <span style="font-weight: 500;">' + self._escapeHtml(displayAnswer) + '</span> ✅</div>';
|
||||||
|
// Correct answer line HIDDEN for clean look
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// User is incorrect -> Show both
|
||||||
|
if (q.type === 'short_answer' || q.type === 'fill_blank') {
|
||||||
|
html += '<div style="margin-bottom: 6px;"><span style="color: #718096;">Your answer:</span> <span style="font-weight: 500;">' + self._escapeHtml(review.user_answer || '(empty)') + '</span> ❌</div>';
|
||||||
|
html += '<div><span style="color: #718096;">Correct answer:</span> <span style="color: #38a169; font-weight: 500;">' + self._escapeHtml(review.correct_answer || '-') + '</span> ✅</div>';
|
||||||
|
} else if (q.type === 'ordering') {
|
||||||
|
var userOrder = (review.user_order || []).join(' → ') || '(not ordered)';
|
||||||
|
var correctOrder = (review.correct_order || []).join(' → ') || '-';
|
||||||
|
html += '<div style="margin-bottom: 6px;"><span style="color: #718096;">Your order:</span> <span style="font-weight: 500;">' + self._escapeHtml(userOrder) + '</span> ❌</div>';
|
||||||
|
html += '<div><span style="color: #718096;">Correct order:</span> <span style="color: #38a169; font-weight: 500;">' + self._escapeHtml(correctOrder) + '</span> ✅</div>';
|
||||||
|
} else {
|
||||||
|
var userAnswers = review.user_answers || [];
|
||||||
|
var correctAnsArray = review.correct_answers || [];
|
||||||
|
|
||||||
|
var uStr = userAnswers.length > 0 ? userAnswers.map(function(a) { return self._escapeHtml(a); }).join(', ') : '(no answer)';
|
||||||
|
var cStr = correctAnsArray.length > 0 ? correctAnsArray.map(function(a) { return self._escapeHtml(a); }).join(', ') : '-';
|
||||||
|
|
||||||
|
html += '<div style="margin-bottom: 6px;"><span style="color: #718096;">Your answer:</span> <span style="font-weight: 500;">' + uStr + '</span> ❌</div>';
|
||||||
|
html += '<div><span style="color: #718096;">Correct answer:</span> <span style="color: #38a169; font-weight: 500;">' + cStr + '</span> ✅</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Explanation
|
||||||
|
if (review.explanation) {
|
||||||
|
html += '<div style="margin-top: 10px; font-size: 12px; color: #5a67d8; padding: 10px; background: rgba(102, 126, 234, 0.1); border-radius: 6px;">';
|
||||||
|
html += '<i class="fa fa-lightbulb-o" style="color: #f6ad55; margin-right: 5px;"></i>' + review.explanation;
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
$reviewSection.html(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show with animation
|
||||||
|
$reviewSection.slideDown(300);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to escape HTML entities
|
||||||
|
*/
|
||||||
|
_escapeHtml: function (text) {
|
||||||
|
if (!text) return '';
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quiz Preview Client Action
|
||||||
|
* Wrapper that opens the widget in a Dialog
|
||||||
|
*/
|
||||||
|
var AbstractAction = require('web.AbstractAction');
|
||||||
|
var Dialog = require('web.Dialog');
|
||||||
|
|
||||||
|
var GeniusQuizPreviewAction = AbstractAction.extend({
|
||||||
|
init: function (parent, action) {
|
||||||
|
this._super.apply(this, arguments);
|
||||||
|
this.action = action;
|
||||||
|
this.params = action.params || {};
|
||||||
|
},
|
||||||
|
|
||||||
|
start: function () {
|
||||||
|
var self = this;
|
||||||
|
var params = this.params || {};
|
||||||
|
|
||||||
|
if (!params.quiz_id) {
|
||||||
|
this.do_warn('Error', 'No Quiz ID provided for preview');
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
var dialog = new Dialog(this, {
|
||||||
|
title: _t('Test Mode: ') + (params.quiz_name || 'Quiz'),
|
||||||
|
size: 'medium',
|
||||||
|
$content: $('<div>'), // Placeholder
|
||||||
|
buttons: [], // No default buttons, widget handles navigation
|
||||||
|
renderHeader: true,
|
||||||
|
renderFooter: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
var widget = new GeniusQuizPopup(dialog, {
|
||||||
|
quizId: params.quiz_id,
|
||||||
|
quizName: params.quiz_name,
|
||||||
|
isPreview: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return widget.appendTo(dialog.$content).then(function() {
|
||||||
|
dialog.open();
|
||||||
|
|
||||||
|
// Close the client action wrapper (return to previous screen)
|
||||||
|
// BUT wait for dialog close?
|
||||||
|
// Actually, if we are a client action, we replaced the previous screen.
|
||||||
|
// We need to handle "closing" by going back.
|
||||||
|
|
||||||
|
dialog.on('closed', self, function () {
|
||||||
|
self.do_action({type: 'ir.actions.client', tag: 'history_back'});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for widget close request
|
||||||
|
widget.on('close_dialog', self, function() {
|
||||||
|
dialog.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genius Quiz Test Button Widget
|
||||||
|
* A standalone widget to trigger the quiz popup directly from the form view
|
||||||
|
*/
|
||||||
|
var GeniusQuizTestButton = AbstractField.extend({
|
||||||
|
template: null,
|
||||||
|
tagName: 'div',
|
||||||
|
className: 'genius-test-btn-wrapper',
|
||||||
|
|
||||||
|
events: _.extend({}, AbstractField.prototype.events, {
|
||||||
|
'click .btn-genius-test': '_onTestQuiz',
|
||||||
|
}),
|
||||||
|
|
||||||
|
_render: function () {
|
||||||
|
this.$el.html(
|
||||||
|
'<button class="btn btn-genius-test">' +
|
||||||
|
'<i class="fa fa-gamepad"></i> ' +
|
||||||
|
'<span>Test Quiz</span>' +
|
||||||
|
'</button>'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
_onTestQuiz: function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
var recordData = this.record && this.record.data;
|
||||||
|
if (!recordData) {
|
||||||
|
// Fallback if structure is different
|
||||||
|
recordData = self.getParent().state.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
var quizId = recordData.id;
|
||||||
|
var quizName = recordData.name;
|
||||||
|
|
||||||
|
console.log('[GeniusButton] Starting Test Mode for Quiz:', quizId);
|
||||||
|
|
||||||
|
// Instantiate Popup directly
|
||||||
|
var popup = new GeniusQuizPopup(self, {
|
||||||
|
quizId: quizId,
|
||||||
|
quizName: quizName,
|
||||||
|
isPreview: true
|
||||||
|
});
|
||||||
|
|
||||||
|
popup.appendTo(document.body).then(function() {
|
||||||
|
console.log('[GeniusButton] Popup opened successfully');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
core.action_registry.add('genius_quiz_popup', GeniusQuizPopup);
|
||||||
|
core.action_registry.add('genius_quiz_preview', GeniusQuizPreviewAction);
|
||||||
|
|
||||||
|
// AbstractField is better basic class for form widgets but Widget works for simple button behavior
|
||||||
|
// We register it as a widget for fields
|
||||||
|
var FieldRegistry = require('web.field_registry');
|
||||||
|
FieldRegistry.add('genius_quiz_test_button', GeniusQuizTestButton);
|
||||||
|
|
||||||
|
return {
|
||||||
|
GeniusQuizPopup: GeniusQuizPopup,
|
||||||
|
GeniusQuizPreviewAction: GeniusQuizPreviewAction,
|
||||||
|
GeniusQuizTestButton: GeniusQuizTestButton,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,358 @@
|
||||||
|
/**
|
||||||
|
* Genius Tip Widget - Premium Tooltip with SVG Spotlight
|
||||||
|
* =======================================================
|
||||||
|
*
|
||||||
|
* "Got it" button only hides UI - does NOT advance tour
|
||||||
|
* Tour advances only when user performs actual action on target
|
||||||
|
*/
|
||||||
|
odoo.define('tour_genius.GeniusTip', function (require) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var core = require('web.core');
|
||||||
|
var Tip = require('web_tour.Tip');
|
||||||
|
var _t = core._t;
|
||||||
|
|
||||||
|
// Global cleanup function
|
||||||
|
window.cleanupGeniusHand = function() {
|
||||||
|
if (window._geniusPersistentHand) {
|
||||||
|
var hand = window._geniusPersistentHand;
|
||||||
|
if (hand && hand.parentNode) {
|
||||||
|
hand.parentNode.removeChild(hand);
|
||||||
|
}
|
||||||
|
window._geniusPersistentHand = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var GeniusTip = Tip.extend({
|
||||||
|
template: "GeniusTip",
|
||||||
|
xmlDependencies: (Tip.prototype.xmlDependencies || []).concat([
|
||||||
|
'/tour_genius/static/src/xml/genius_tip.xml'
|
||||||
|
]),
|
||||||
|
|
||||||
|
events: _.extend({}, Tip.prototype.events || {}, {
|
||||||
|
'click .genius-tip-skip': '_onSkipTour',
|
||||||
|
'click .genius-tip-next': '_onGotItClick',
|
||||||
|
}),
|
||||||
|
|
||||||
|
init: function (parent, info) {
|
||||||
|
this._super.apply(this, arguments);
|
||||||
|
|
||||||
|
this.geniusStepIndex = info.geniusStepIndex || 0;
|
||||||
|
this.geniusTotalSteps = info.geniusTotalSteps || 1;
|
||||||
|
this.geniusTitle = info.geniusTitle || _t("Step");
|
||||||
|
this.geniusTourName = info.geniusTourName || '';
|
||||||
|
|
||||||
|
this.spotlightPadding = 6;
|
||||||
|
this.spotlightRadius = 6;
|
||||||
|
this.overlayColor = 'rgba(15, 23, 42, 0.75)';
|
||||||
|
|
||||||
|
this._spotlightCreated = false;
|
||||||
|
this._handCreated = false;
|
||||||
|
this._uiHidden = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
start: function () {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
this.el.style.cssText = 'opacity:0 !important; visibility:hidden !important;';
|
||||||
|
|
||||||
|
// Clean up previous persistent hand
|
||||||
|
window.cleanupGeniusHand();
|
||||||
|
|
||||||
|
return this._super.apply(this, arguments).then(function () {
|
||||||
|
requestAnimationFrame(function() {
|
||||||
|
if (self.isDestroyed()) return;
|
||||||
|
|
||||||
|
self._moveToBodyFixed();
|
||||||
|
self._createSpotlightSvg();
|
||||||
|
self._updateSpotlightPosition();
|
||||||
|
self._positionNearAnchor();
|
||||||
|
self._createPointingHand();
|
||||||
|
|
||||||
|
requestAnimationFrame(function() {
|
||||||
|
if (!self.isDestroyed()) {
|
||||||
|
self._showTooltip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
self._onWindowResize = _.debounce(function() {
|
||||||
|
if (!self.isDestroyed() && self._spotlightCreated && !self._uiHidden) {
|
||||||
|
self._updateSpotlightPosition();
|
||||||
|
self._positionNearAnchor();
|
||||||
|
self._updateHandPosition();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
$(window).on('resize.geniusTip', self._onWindowResize);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy: function () {
|
||||||
|
$(window).off('.geniusTip');
|
||||||
|
|
||||||
|
// Always remove spotlight
|
||||||
|
if (this._spotlightSvg) {
|
||||||
|
$(this._spotlightSvg).remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove hand unless it's in "hidden UI" mode (just got it clicked)
|
||||||
|
if (!this._uiHidden) {
|
||||||
|
if (this._handElement) {
|
||||||
|
$(this._handElement).remove();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Store hand for persistence
|
||||||
|
window._geniusPersistentHand = this._handElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._super.apply(this, arguments);
|
||||||
|
},
|
||||||
|
|
||||||
|
update: function ($anchor, $altAnchor) {
|
||||||
|
this._super.apply(this, arguments);
|
||||||
|
|
||||||
|
if (this._uiHidden) return;
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
requestAnimationFrame(function() {
|
||||||
|
if (!self.isDestroyed()) {
|
||||||
|
self._updateSpotlightPosition();
|
||||||
|
self._positionNearAnchor();
|
||||||
|
self._updateHandPosition();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_updatePosition: function () {},
|
||||||
|
_reposition: function () {},
|
||||||
|
_to_bubble_mode: function () {},
|
||||||
|
_to_info_mode: function () {},
|
||||||
|
_build_bubble_mode: function () {},
|
||||||
|
_build_info_mode: function () {},
|
||||||
|
|
||||||
|
_moveToBodyFixed: function () {
|
||||||
|
this.$el.detach();
|
||||||
|
this.$el.appendTo(document.body);
|
||||||
|
this.el.style.cssText = 'position:fixed !important; z-index:99999 !important; opacity:0 !important; visibility:hidden !important; background:transparent !important; border:none !important;';
|
||||||
|
this.tip_opened = true;
|
||||||
|
this.$el.addClass('active genius-tip-always-open o_tooltip_visible genius-tip');
|
||||||
|
},
|
||||||
|
|
||||||
|
_showTooltip: function () {
|
||||||
|
this.el.style.setProperty('opacity', '1', 'important');
|
||||||
|
this.el.style.setProperty('visibility', 'visible', 'important');
|
||||||
|
},
|
||||||
|
|
||||||
|
_positionNearAnchor: function () {
|
||||||
|
if (!this.$anchor || !this.$anchor.length) return;
|
||||||
|
|
||||||
|
var anchorRect = this.$anchor[0].getBoundingClientRect();
|
||||||
|
var tipCard = this.$el.find('.genius-tip-card')[0];
|
||||||
|
var tipWidth = tipCard ? tipCard.offsetWidth : 300;
|
||||||
|
var tipHeight = tipCard ? tipCard.offsetHeight : 150;
|
||||||
|
var spacing = 25;
|
||||||
|
var position = this.info.position || 'bottom';
|
||||||
|
|
||||||
|
var top, left;
|
||||||
|
|
||||||
|
switch (position) {
|
||||||
|
case 'top':
|
||||||
|
top = anchorRect.top - tipHeight - spacing;
|
||||||
|
left = anchorRect.left + (anchorRect.width / 2) - (tipWidth / 2);
|
||||||
|
break;
|
||||||
|
case 'bottom':
|
||||||
|
top = anchorRect.bottom + spacing;
|
||||||
|
left = anchorRect.left + (anchorRect.width / 2) - (tipWidth / 2);
|
||||||
|
break;
|
||||||
|
case 'left':
|
||||||
|
top = anchorRect.top + (anchorRect.height / 2) - (tipHeight / 2);
|
||||||
|
left = anchorRect.left - tipWidth - spacing;
|
||||||
|
break;
|
||||||
|
case 'right':
|
||||||
|
top = anchorRect.top + (anchorRect.height / 2) - (tipHeight / 2);
|
||||||
|
left = anchorRect.right + spacing;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var viewportWidth = window.innerWidth;
|
||||||
|
var viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
if (left < 10) left = 10;
|
||||||
|
if (left + tipWidth > viewportWidth - 10) left = viewportWidth - tipWidth - 10;
|
||||||
|
if (top < 10) top = 10;
|
||||||
|
if (top + tipHeight > viewportHeight - 10) top = viewportHeight - tipHeight - 10;
|
||||||
|
|
||||||
|
this.el.style.setProperty('top', top + 'px', 'important');
|
||||||
|
this.el.style.setProperty('left', left + 'px', 'important');
|
||||||
|
|
||||||
|
this._currentPosition = position;
|
||||||
|
|
||||||
|
if (position === 'top' || position === 'bottom') {
|
||||||
|
var anchorCenterX = anchorRect.left + anchorRect.width / 2;
|
||||||
|
var arrowLeft = anchorCenterX - left;
|
||||||
|
arrowLeft = Math.max(25, Math.min(arrowLeft, tipWidth - 25));
|
||||||
|
if (tipCard) tipCard.style.setProperty('--arrow-left', arrowLeft + 'px');
|
||||||
|
} else {
|
||||||
|
var anchorCenterY = anchorRect.top + anchorRect.height / 2;
|
||||||
|
var arrowTop = anchorCenterY - top;
|
||||||
|
arrowTop = Math.max(25, Math.min(arrowTop, tipHeight - 25));
|
||||||
|
if (tipCard) tipCard.style.setProperty('--arrow-top', arrowTop + 'px');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$el.removeClass('top bottom left right').addClass(position);
|
||||||
|
},
|
||||||
|
|
||||||
|
_createSpotlightSvg: function () {
|
||||||
|
if (this._spotlightSvg) return;
|
||||||
|
|
||||||
|
var windowWidth = window.innerWidth;
|
||||||
|
var windowHeight = window.innerHeight;
|
||||||
|
|
||||||
|
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
svg.setAttribute('class', 'genius-spotlight-svg');
|
||||||
|
svg.setAttribute('viewBox', '0 0 ' + windowWidth + ' ' + windowHeight);
|
||||||
|
svg.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:99990;pointer-events:none;';
|
||||||
|
|
||||||
|
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||||
|
path.style.cssText = 'fill:' + this.overlayColor + ';fill-rule:evenodd;';
|
||||||
|
|
||||||
|
svg.appendChild(path);
|
||||||
|
document.body.appendChild(svg);
|
||||||
|
|
||||||
|
this._spotlightSvg = svg;
|
||||||
|
this._spotlightPath = path;
|
||||||
|
this._spotlightCreated = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateSpotlightPosition: function () {
|
||||||
|
if (!this._spotlightPath || !this.$anchor || !this.$anchor.length) return;
|
||||||
|
|
||||||
|
var rect = this.$anchor[0].getBoundingClientRect();
|
||||||
|
var windowX = window.innerWidth;
|
||||||
|
var windowY = window.innerHeight;
|
||||||
|
var padding = this.spotlightPadding;
|
||||||
|
var radius = this.spotlightRadius;
|
||||||
|
|
||||||
|
var x = Math.max(0, rect.left - padding);
|
||||||
|
var y = Math.max(0, rect.top - padding);
|
||||||
|
var w = Math.min(rect.width + padding * 2, windowX - x);
|
||||||
|
var h = Math.min(rect.height + padding * 2, windowY - y);
|
||||||
|
|
||||||
|
radius = Math.min(radius, w / 2, h / 2);
|
||||||
|
|
||||||
|
this._spotlightSvg.setAttribute('viewBox', '0 0 ' + windowX + ' ' + windowY);
|
||||||
|
this._anchorRect = rect;
|
||||||
|
|
||||||
|
var pathD =
|
||||||
|
'M0,0 L' + windowX + ',0 L' + windowX + ',' + windowY + ' L0,' + windowY + ' Z ' +
|
||||||
|
'M' + (x + radius) + ',' + y +
|
||||||
|
' h' + (w - radius * 2) +
|
||||||
|
' a' + radius + ',' + radius + ' 0 0 1 ' + radius + ',' + radius +
|
||||||
|
' v' + (h - radius * 2) +
|
||||||
|
' a' + radius + ',' + radius + ' 0 0 1 -' + radius + ',' + radius +
|
||||||
|
' h-' + (w - radius * 2) +
|
||||||
|
' a' + radius + ',' + radius + ' 0 0 1 -' + radius + ',-' + radius +
|
||||||
|
' v-' + (h - radius * 2) +
|
||||||
|
' a' + radius + ',' + radius + ' 0 0 1 ' + radius + ',-' + radius +
|
||||||
|
' z';
|
||||||
|
|
||||||
|
this._spotlightPath.setAttribute('d', pathD);
|
||||||
|
},
|
||||||
|
|
||||||
|
_createPointingHand: function () {
|
||||||
|
if (this._handElement) return;
|
||||||
|
|
||||||
|
var hand = document.createElement('div');
|
||||||
|
hand.className = 'genius-pointing-hand';
|
||||||
|
document.body.appendChild(hand);
|
||||||
|
|
||||||
|
this._handElement = hand;
|
||||||
|
this._handCreated = true;
|
||||||
|
|
||||||
|
this._updateHandPosition();
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateHandPosition: function () {
|
||||||
|
if (!this._handElement || !this._anchorRect) return;
|
||||||
|
|
||||||
|
var rect = this._anchorRect;
|
||||||
|
var targetCenterX = rect.left + rect.width / 2;
|
||||||
|
var targetCenterY = rect.top + rect.height / 2;
|
||||||
|
|
||||||
|
var position = this._currentPosition || 'bottom';
|
||||||
|
var gap = 8;
|
||||||
|
var handX, handY, handEmoji, animClass;
|
||||||
|
|
||||||
|
switch (position) {
|
||||||
|
case 'bottom':
|
||||||
|
case 'top':
|
||||||
|
handX = rect.right + gap;
|
||||||
|
handY = targetCenterY - 14;
|
||||||
|
handEmoji = '👈';
|
||||||
|
animClass = 'hand-horizontal';
|
||||||
|
break;
|
||||||
|
case 'left':
|
||||||
|
case 'right':
|
||||||
|
handX = targetCenterX - 14;
|
||||||
|
handY = rect.top - 28 - gap;
|
||||||
|
handEmoji = '👇';
|
||||||
|
animClass = 'hand-vertical';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._handElement.innerHTML = handEmoji;
|
||||||
|
this._handElement.className = 'genius-pointing-hand ' + animClass;
|
||||||
|
this._handElement.style.cssText =
|
||||||
|
'position:fixed;' +
|
||||||
|
'z-index:99998;' +
|
||||||
|
'font-size:26px;' +
|
||||||
|
'pointer-events:none;' +
|
||||||
|
'left:' + handX + 'px;' +
|
||||||
|
'top:' + handY + 'px;' +
|
||||||
|
'filter:drop-shadow(0 2px 6px rgba(0,0,0,0.3)) saturate(1.2);';
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide tooltip and spotlight, keep hand visible
|
||||||
|
* Does NOT trigger tip_consumed - tour advances via natural trigger
|
||||||
|
*/
|
||||||
|
_hideTooltipKeepHand: function () {
|
||||||
|
this._uiHidden = true;
|
||||||
|
|
||||||
|
// Hide the tooltip card
|
||||||
|
this.$el.find('.genius-tip-card').hide();
|
||||||
|
|
||||||
|
// Remove spotlight
|
||||||
|
if (this._spotlightSvg) {
|
||||||
|
$(this._spotlightSvg).remove();
|
||||||
|
this._spotlightSvg = null;
|
||||||
|
this._spotlightPath = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand remains visible - point stored for cleanup by next step
|
||||||
|
console.log('[GeniusTip] UI hidden, hand remains. Waiting for user action on target.');
|
||||||
|
},
|
||||||
|
|
||||||
|
_onSkipTour: function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
this._uiHidden = false;
|
||||||
|
this.trigger('genius_skip_tour', { tourName: this.geniusTourName });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Got it" button - ONLY hides tooltip/spotlight
|
||||||
|
* Does NOT advance the tour!
|
||||||
|
*/
|
||||||
|
_onGotItClick: function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
// Just hide the UI, don't trigger tip_consumed
|
||||||
|
this._hideTooltipKeepHand();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return GeniusTip;
|
||||||
|
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,900 @@
|
||||||
|
odoo.define('tour_genius.RecorderPanel', function (require) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var Widget = require('web.Widget');
|
||||||
|
var core = require('web.core');
|
||||||
|
var rpc = require('web.rpc');
|
||||||
|
var ajax = require('web.ajax');
|
||||||
|
var session = require('web.session');
|
||||||
|
var Dialog = require('web.Dialog');
|
||||||
|
|
||||||
|
var _t = core._t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draggable Recorder Panel Widget
|
||||||
|
* Provides Track On/Stop functionality for capturing CSS selectors
|
||||||
|
*/
|
||||||
|
var RecorderPanel = Widget.extend({
|
||||||
|
template: 'tour_genius.RecorderPanel',
|
||||||
|
|
||||||
|
events: {
|
||||||
|
'mousedown .recorder-header': '_onStartDrag',
|
||||||
|
'click .recorder-close': '_onClose',
|
||||||
|
'click .btn-track-on': '_onToggleTracking',
|
||||||
|
'click .btn-save-step': '_onSaveStep',
|
||||||
|
'click .btn-finish': '_onFinish',
|
||||||
|
'change .position-select': '_onPositionChange',
|
||||||
|
'change .step-type-select': '_onTypeChange',
|
||||||
|
},
|
||||||
|
|
||||||
|
init: function (parent, options) {
|
||||||
|
this._super.apply(this, arguments);
|
||||||
|
this.options = options || {};
|
||||||
|
|
||||||
|
// Detect "New Tour" mode
|
||||||
|
var rawTopicId = options.topicId;
|
||||||
|
if (rawTopicId === 'new' || !rawTopicId) {
|
||||||
|
this.isNewTour = true;
|
||||||
|
this.topicId = null; // Will be set after first save
|
||||||
|
this.topicName = 'New Tour';
|
||||||
|
} else {
|
||||||
|
this.isNewTour = false;
|
||||||
|
this.topicId = rawTopicId;
|
||||||
|
this.topicName = options.topicName || 'Tour';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isTracking = false;
|
||||||
|
this.steps = [];
|
||||||
|
this.currentStep = {
|
||||||
|
css_selector: '',
|
||||||
|
title: '',
|
||||||
|
instruction: '',
|
||||||
|
position: 'bottom',
|
||||||
|
step_type: 'click',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drag state
|
||||||
|
this.isDragging = false;
|
||||||
|
this.dragOffsetX = 0;
|
||||||
|
this.dragOffsetY = 0;
|
||||||
|
|
||||||
|
console.log('[Recorder] Init - isNewTour:', this.isNewTour, 'topicId:', this.topicId);
|
||||||
|
},
|
||||||
|
|
||||||
|
start: function () {
|
||||||
|
var self = this;
|
||||||
|
return this._super.apply(this, arguments).then(function () {
|
||||||
|
self._positionPanel();
|
||||||
|
self._setupGlobalEvents();
|
||||||
|
|
||||||
|
// If New Tour mode, load modules for dropdown
|
||||||
|
if (self.isNewTour) {
|
||||||
|
self._loadModulesDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Recorder] Started - isNewTour:', self.isNewTour);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load modules into the Tour Module dropdown (for New Tour mode)
|
||||||
|
*/
|
||||||
|
_loadModulesDropdown: function () {
|
||||||
|
var self = this;
|
||||||
|
rpc.query({
|
||||||
|
model: 'ir.module.module',
|
||||||
|
method: 'search_read',
|
||||||
|
domain: [['state', '=', 'installed']],
|
||||||
|
fields: ['id', 'name', 'shortdesc'],
|
||||||
|
orderBy: [{name: 'shortdesc', asc: true}],
|
||||||
|
limit: 0,
|
||||||
|
}).then(function (modules) {
|
||||||
|
var $datalist = self.$('#module-datalist');
|
||||||
|
var $input = self.$('.tour-module-input');
|
||||||
|
var $hiddenSelect = self.$('.tour-module-select');
|
||||||
|
|
||||||
|
// Store modules for lookup
|
||||||
|
self._moduleMap = {};
|
||||||
|
|
||||||
|
// Sort modules alphabetically
|
||||||
|
modules.sort(function(a, b) {
|
||||||
|
var nameA = (a.shortdesc || a.name || '').toLowerCase();
|
||||||
|
var nameB = (b.shortdesc || b.name || '').toLowerCase();
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate datalist with options
|
||||||
|
modules.forEach(function (mod) {
|
||||||
|
var displayName = mod.shortdesc || mod.name;
|
||||||
|
self._moduleMap[displayName] = mod.id;
|
||||||
|
$datalist.append($('<option>').attr('value', displayName));
|
||||||
|
});
|
||||||
|
|
||||||
|
// When user selects from datalist, store ID in hidden field
|
||||||
|
$input.on('input change', function () {
|
||||||
|
var selectedText = $(this).val();
|
||||||
|
var moduleId = self._moduleMap[selectedText] || '';
|
||||||
|
$hiddenSelect.val(moduleId);
|
||||||
|
console.log('[Recorder] Module input:', selectedText, '-> ID:', moduleId);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Recorder] Loaded', modules.length, 'modules for search');
|
||||||
|
}).catch(function (err) {
|
||||||
|
console.error('[Recorder] Module load error:', err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy: function () {
|
||||||
|
this._cleanup();
|
||||||
|
this._super.apply(this, arguments);
|
||||||
|
},
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Panel Positioning & Dragging
|
||||||
|
// =====================================================================
|
||||||
|
_positionPanel: function () {
|
||||||
|
this.$el.css({
|
||||||
|
position: 'fixed',
|
||||||
|
top: '100px',
|
||||||
|
right: '20px',
|
||||||
|
zIndex: 9999,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_onStartDrag: function (ev) {
|
||||||
|
if ($(ev.target).hasClass('recorder-close')) return;
|
||||||
|
|
||||||
|
this.isDragging = true;
|
||||||
|
var offset = this.$el.offset();
|
||||||
|
this.dragOffsetX = ev.pageX - offset.left;
|
||||||
|
this.dragOffsetY = ev.pageY - offset.top;
|
||||||
|
|
||||||
|
$(document).on('mousemove.recorder', this._onDrag.bind(this));
|
||||||
|
$(document).on('mouseup.recorder', this._onStopDrag.bind(this));
|
||||||
|
|
||||||
|
ev.preventDefault();
|
||||||
|
},
|
||||||
|
|
||||||
|
_onDrag: function (ev) {
|
||||||
|
if (!this.isDragging) return;
|
||||||
|
|
||||||
|
var newX = ev.pageX - this.dragOffsetX;
|
||||||
|
var newY = ev.pageY - this.dragOffsetY;
|
||||||
|
|
||||||
|
// Keep within viewport
|
||||||
|
newX = Math.max(0, Math.min(newX, window.innerWidth - this.$el.width()));
|
||||||
|
newY = Math.max(0, Math.min(newY, window.innerHeight - this.$el.height()));
|
||||||
|
|
||||||
|
this.$el.css({
|
||||||
|
left: newX + 'px',
|
||||||
|
top: newY + 'px',
|
||||||
|
right: 'auto',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_onStopDrag: function () {
|
||||||
|
this.isDragging = false;
|
||||||
|
$(document).off('mousemove.recorder');
|
||||||
|
$(document).off('mouseup.recorder');
|
||||||
|
},
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Track On/Off - Element Selection
|
||||||
|
// =====================================================================
|
||||||
|
_onToggleTracking: function () {
|
||||||
|
if (this.isTracking) {
|
||||||
|
this._stopTracking();
|
||||||
|
} else {
|
||||||
|
this._startTracking();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_startTracking: function () {
|
||||||
|
console.log('[Recorder] Start Tracking');
|
||||||
|
this.isTracking = true;
|
||||||
|
this.$('.btn-track-on').addClass('active').html('<i class="fa fa-stop"></i> Stop Tracking');
|
||||||
|
|
||||||
|
// Add overlay (pointer-events: none to allow hover on elements)
|
||||||
|
if ($('.genius-tracking-overlay').length === 0) {
|
||||||
|
$('body').append('<div class="genius-tracking-overlay"></div>');
|
||||||
|
}
|
||||||
|
$('body').addClass('genius-tracking-mode');
|
||||||
|
|
||||||
|
// Use native capture listeners to intercept events
|
||||||
|
this._boundHover = this._onElementHover.bind(this);
|
||||||
|
this._boundClick = this._onElementClick.bind(this);
|
||||||
|
this._boundMouseDown = this._preventDefault.bind(this);
|
||||||
|
this._boundMouseUp = this._preventDefault.bind(this);
|
||||||
|
|
||||||
|
document.addEventListener('mouseover', this._boundHover, true);
|
||||||
|
document.addEventListener('click', this._boundClick, true);
|
||||||
|
document.addEventListener('mousedown', this._boundMouseDown, true);
|
||||||
|
document.addEventListener('mouseup', this._boundMouseUp, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
_stopTracking: function () {
|
||||||
|
console.log('[Recorder] Stop Tracking');
|
||||||
|
this.isTracking = false;
|
||||||
|
this.$('.btn-track-on').removeClass('active').html('<i class="fa fa-crosshairs"></i> Track On');
|
||||||
|
|
||||||
|
// Remove overlay and classes
|
||||||
|
$('.genius-tracking-overlay').remove();
|
||||||
|
$('body').removeClass('genius-tracking-mode');
|
||||||
|
$('.genius-tracking-highlight').removeClass('genius-tracking-highlight');
|
||||||
|
|
||||||
|
// Remove native listeners
|
||||||
|
if (this._boundHover) {
|
||||||
|
document.removeEventListener('mouseover', this._boundHover, true);
|
||||||
|
document.removeEventListener('click', this._boundClick, true);
|
||||||
|
document.removeEventListener('mousedown', this._boundMouseDown, true);
|
||||||
|
document.removeEventListener('mouseup', this._boundMouseUp, true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_preventDefault: function(ev) {
|
||||||
|
// checking if target is inside recorder panel
|
||||||
|
if ($(ev.target).closest('.genius-recorder-panel').length) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
},
|
||||||
|
|
||||||
|
_onElementHover: function (ev) {
|
||||||
|
if (!this.isTracking) return;
|
||||||
|
|
||||||
|
var target = ev.target;
|
||||||
|
var $target = $(target);
|
||||||
|
|
||||||
|
// Ignore our panel and overlay
|
||||||
|
if ($target.closest('.genius-recorder-panel').length ||
|
||||||
|
$target.hasClass('genius-tracking-overlay')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('.genius-tracking-highlight').removeClass('genius-tracking-highlight');
|
||||||
|
$target.addClass('genius-tracking-highlight');
|
||||||
|
},
|
||||||
|
|
||||||
|
_onElementClick: function (ev) {
|
||||||
|
if (!this.isTracking) return;
|
||||||
|
|
||||||
|
var target = ev.target;
|
||||||
|
var $target = $(target);
|
||||||
|
|
||||||
|
// Allow clicks on our panel
|
||||||
|
if ($target.closest('.genius-recorder-panel').length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Recorder] Element Clicked:', target);
|
||||||
|
|
||||||
|
// Stop event!
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
// Generate CSS selector
|
||||||
|
var selector = this._generateCSSSelector(target);
|
||||||
|
console.log('[Recorder] Generated Selector:', selector);
|
||||||
|
|
||||||
|
// Update current step
|
||||||
|
this.currentStep.css_selector = selector;
|
||||||
|
|
||||||
|
// Force update UI
|
||||||
|
var $input = this.$('.css-selector-input');
|
||||||
|
$input.val(selector);
|
||||||
|
|
||||||
|
// Auto-fill title from element
|
||||||
|
var title = this._generateStepTitle($target);
|
||||||
|
this.$('.step-title-input').val(title);
|
||||||
|
this.currentStep.title = title;
|
||||||
|
|
||||||
|
// Stop tracking
|
||||||
|
this._stopTracking();
|
||||||
|
},
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// CSS Selector Generation - ODOO STANDARD PATTERNS
|
||||||
|
// Based on analysis of crm.js, mass_mailing_tour.js, tour_step_utils.js
|
||||||
|
// =====================================================================
|
||||||
|
_generateCSSSelector: function (element) {
|
||||||
|
try {
|
||||||
|
if (!(element instanceof Element)) return '';
|
||||||
|
var $el = $(element);
|
||||||
|
|
||||||
|
// ================ PRIORITY 1: Odoo Semantic Patterns ================
|
||||||
|
|
||||||
|
// Check for app icon with data-menu-xmlid (highest priority)
|
||||||
|
var menuXmlId = $el.attr('data-menu-xmlid') ||
|
||||||
|
$el.closest('[data-menu-xmlid]').attr('data-menu-xmlid');
|
||||||
|
if (menuXmlId) {
|
||||||
|
// CRITICAL FIX: Distinguish between App Tiles (home screen) and Menu Items (navbar)
|
||||||
|
// Use Global State Check: Are we in the Home Menu?
|
||||||
|
// In Odoo, the Home Menu has class .o_home_menu and is visible only on the dashboard
|
||||||
|
var isHomeMenu = $('.o_home_menu').length > 0 && $('.o_home_menu').is(':visible');
|
||||||
|
|
||||||
|
// Only use .o_app if we are effectively in the Home Menu AND clicking an app icon
|
||||||
|
if (isHomeMenu && ($el.hasClass('o_app') || $el.closest('.o_apps').length || $el.hasClass('o_app_icon'))) {
|
||||||
|
return '.o_app[data-menu-xmlid="' + menuXmlId + '"]';
|
||||||
|
}
|
||||||
|
// Otherwise, we are likely in a Top Menu (NavBar) or inner menu
|
||||||
|
else {
|
||||||
|
return '[data-menu-xmlid="' + menuXmlId + '"]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL FIX: Group By vs Filter (Dropdowns)
|
||||||
|
// Use text content to distinguish between generic dropdown buttons
|
||||||
|
// Search for button with class o_dropdown_toggler_btn
|
||||||
|
var $btn = $el.hasClass('o_dropdown_toggler_btn') ? $el : $el.closest('.o_dropdown_toggler_btn');
|
||||||
|
if ($btn.length) {
|
||||||
|
var text = $btn.text().trim();
|
||||||
|
// Clean text (remove caret/icon text if any)
|
||||||
|
text = text.replace(/\s+/g, ' ').trim();
|
||||||
|
if (text && ['Filter', 'Group By', 'Favorites', 'Measures'].some(t => text.includes(t))) {
|
||||||
|
// Use contains for partial match (safe for buttons)
|
||||||
|
return 'button.o_dropdown_toggler_btn:contains("' + text + '")';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================ PRIORITY 1.5: Smart Field Detection ================
|
||||||
|
// CRITICAL: Odoo fields need container-level selectors, NOT input-level
|
||||||
|
// This matches Odoo's native tour patterns (e.g., Sales tour, CRM tour)
|
||||||
|
|
||||||
|
// Check if we're inside an Odoo field widget
|
||||||
|
var $fieldWidget = $el.closest('.o_field_widget');
|
||||||
|
if ($fieldWidget.length) {
|
||||||
|
var fieldName = $fieldWidget.attr('name');
|
||||||
|
if (fieldName) {
|
||||||
|
// Determine field type by checking widget classes
|
||||||
|
var isMany2one = $fieldWidget.hasClass('o_field_many2one');
|
||||||
|
var isMany2many = $fieldWidget.hasClass('o_field_many2many') ||
|
||||||
|
$fieldWidget.hasClass('o_field_many2manytags'); // No underscore before 'tags'!
|
||||||
|
var isDate = $fieldWidget.hasClass('o_field_date') ||
|
||||||
|
$fieldWidget.hasClass('o_field_datetime');
|
||||||
|
var isSelection = $fieldWidget.hasClass('o_field_selection');
|
||||||
|
var isRadio = $fieldWidget.hasClass('o_field_radio');
|
||||||
|
var isBoolean = $fieldWidget.hasClass('o_field_boolean');
|
||||||
|
var isX2many = $fieldWidget.hasClass('o_field_one2many') ||
|
||||||
|
$fieldWidget.hasClass('o_field_x2many');
|
||||||
|
|
||||||
|
// Relational fields: Use CONTAINER selector (no input suffix!)
|
||||||
|
// This is the Odoo standard pattern as seen in Sales tour
|
||||||
|
if (isMany2one) {
|
||||||
|
return ".o_form_editable .o_field_many2one[name='" + fieldName + "']";
|
||||||
|
}
|
||||||
|
if (isMany2many) {
|
||||||
|
return ".o_form_editable .o_field_many2many[name='" + fieldName + "']";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date fields: Use container (date picker handles the rest)
|
||||||
|
if (isDate) {
|
||||||
|
return ".o_field_widget[name='" + fieldName + "']";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection fields: Use container
|
||||||
|
if (isSelection) {
|
||||||
|
return ".o_field_widget.o_field_selection[name='" + fieldName + "']";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Radio buttons: Use container
|
||||||
|
if (isRadio) {
|
||||||
|
return ".o_field_widget.o_field_radio[name='" + fieldName + "']";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boolean checkbox: Use container
|
||||||
|
if (isBoolean) {
|
||||||
|
return ".o_field_widget.o_field_boolean[name='" + fieldName + "']";
|
||||||
|
}
|
||||||
|
|
||||||
|
// X2Many (One2many list): Use "Add a line" pattern
|
||||||
|
if (isX2many) {
|
||||||
|
// Check if clicking on Add a line button
|
||||||
|
if ($el.closest('.o_field_x2many_list_row_add').length) {
|
||||||
|
return ".o_field_x2many_list_row_add > a";
|
||||||
|
}
|
||||||
|
return ".o_field_widget[name='" + fieldName + "']";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple text/char/integer fields: Target the input
|
||||||
|
if ($el.is('input, textarea')) {
|
||||||
|
return ".o_field_widget[name='" + fieldName + "'] " + element.tagName.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Just the widget container
|
||||||
|
return ".o_field_widget[name='" + fieldName + "']";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for direct name attribute on input/button
|
||||||
|
var elName = $el.attr('name');
|
||||||
|
if (elName) {
|
||||||
|
if (element.tagName === 'BUTTON') {
|
||||||
|
return 'button[name="' + elName + '"]';
|
||||||
|
}
|
||||||
|
if (element.tagName === 'INPUT') {
|
||||||
|
return 'input[name="' + elName + '"]';
|
||||||
|
}
|
||||||
|
if (element.tagName === 'SELECT') {
|
||||||
|
return 'select[name="' + elName + '"]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for button/a with data-name (common in form views)
|
||||||
|
var dataName = $el.attr('data-name') || $el.closest('[data-name]').attr('data-name');
|
||||||
|
if (dataName && ($el.is('button, a') || $el.closest('button, a').length)) {
|
||||||
|
return '[data-name="' + dataName + '"]';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================ PRIORITY 2: Odoo Semantic Classes ================
|
||||||
|
|
||||||
|
// Common Odoo action classes (from tour_step_utils.js and real tours)
|
||||||
|
var priorityClasses = [
|
||||||
|
'o-kanban-button-new', // Kanban create button
|
||||||
|
'o_kanban_add', // Kanban add button
|
||||||
|
'o_list_button_add', // List create button
|
||||||
|
'o_form_button_create', // Form create button
|
||||||
|
'o_form_button_save', // Form save button
|
||||||
|
'o_form_button_cancel', // Form cancel button
|
||||||
|
'o_back_button', // Back/breadcrumb button
|
||||||
|
'o_statusbar_buttons', // Status bar buttons
|
||||||
|
'o_menu_apps', // Apps menu
|
||||||
|
'o_menu_toggle', // Menu toggle (enterprise)
|
||||||
|
'o_schedule_activity', // Schedule activity button
|
||||||
|
'o_kanban_record', // Kanban record
|
||||||
|
'o_list_record_selector', // List record checkbox
|
||||||
|
'dropdown-toggle', // Bootstrap dropdown
|
||||||
|
];
|
||||||
|
|
||||||
|
for (var i = 0; i < priorityClasses.length; i++) {
|
||||||
|
if ($el.hasClass(priorityClasses[i])) {
|
||||||
|
return '.' + priorityClasses[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================ PRIORITY 3: Contextual Selectors ================
|
||||||
|
|
||||||
|
// Check for modal button (common pattern)
|
||||||
|
if ($el.closest('.modal-footer').length) {
|
||||||
|
var btnText = $el.text().trim();
|
||||||
|
if (btnText.length > 0 && btnText.length < 30) {
|
||||||
|
return '.modal-footer button:contains("' + btnText + '")';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for breadcrumb
|
||||||
|
if ($el.closest('.breadcrumb').length) {
|
||||||
|
return '.breadcrumb-item:not(.active)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================ PRIORITY 4: Class-based Path (no dynamic IDs) ================
|
||||||
|
|
||||||
|
// Build a class-based path, avoiding dynamic IDs
|
||||||
|
var path = [];
|
||||||
|
var current = element;
|
||||||
|
var maxDepth = 5; // Keep selector short
|
||||||
|
|
||||||
|
while (current && current.nodeType === Node.ELEMENT_NODE && maxDepth > 0) {
|
||||||
|
var selector = current.nodeName.toLowerCase();
|
||||||
|
|
||||||
|
// Get meaningful classes (skip dynamic/internal ones)
|
||||||
|
if (current.className && typeof current.className === 'string') {
|
||||||
|
var classes = current.className.trim().split(/\s+/).filter(function(cls) {
|
||||||
|
// Include semantic Odoo classes
|
||||||
|
if (cls.startsWith('o_') && !cls.match(/\d{3,}/)) return true;
|
||||||
|
// Include some Bootstrap/semantic classes
|
||||||
|
if (['btn', 'btn-primary', 'btn-secondary', 'form-control',
|
||||||
|
'dropdown', 'dropdown-menu', 'nav-link', 'card'].includes(cls)) return true;
|
||||||
|
// Exclude dynamic/internal classes
|
||||||
|
if (cls.match(/\d+/) || cls.startsWith('genius-') ||
|
||||||
|
cls.includes('ui-') || cls.includes('--')) return false;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (classes.length > 0) {
|
||||||
|
// Use first meaningful class
|
||||||
|
selector += '.' + classes[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path.unshift(selector);
|
||||||
|
|
||||||
|
// Stop at meaningful container
|
||||||
|
if ($el.hasClass('o_form_view') || $el.hasClass('o_kanban_view') ||
|
||||||
|
$el.hasClass('o_list_view') || $el.hasClass('modal-content') ||
|
||||||
|
current.nodeName.toLowerCase() === 'body') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current.parentNode;
|
||||||
|
maxDepth--;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(' > ');
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Recorder] Selector Gen Error:', e);
|
||||||
|
return element.nodeName.toLowerCase(); // Fallback
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_generateStepTitle: function ($target) {
|
||||||
|
var text = $target.text().trim().substring(0, 30);
|
||||||
|
if (text) return 'Click "' + text + '"';
|
||||||
|
var ph = $target.attr('placeholder');
|
||||||
|
if (ph) return 'Enter ' + ph;
|
||||||
|
var aria = $target.attr('aria-label');
|
||||||
|
if (aria) return aria;
|
||||||
|
return 'Click ' + $target.prop('tagName').toLowerCase();
|
||||||
|
},
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Step Management
|
||||||
|
// =====================================================================
|
||||||
|
_onPositionChange: function (ev) {
|
||||||
|
this.currentStep.position = $(ev.target).val();
|
||||||
|
},
|
||||||
|
|
||||||
|
_onTypeChange: function (ev) {
|
||||||
|
var type = $(ev.target).val();
|
||||||
|
this.currentStep.step_type = type;
|
||||||
|
if (type === 'input') {
|
||||||
|
this.$('.input-value-field').removeClass('d-none');
|
||||||
|
} else {
|
||||||
|
this.$('.input-value-field').addClass('d-none');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_onSaveStep: function () {
|
||||||
|
var self = this;
|
||||||
|
console.log('[Recorder] Save Step Clicked');
|
||||||
|
|
||||||
|
// Validate via UI values, NOT stored values (to allow manual edits)
|
||||||
|
var selector = this.$('.css-selector-input').val() || '';
|
||||||
|
var title = this.$('.step-title-input').val() || '';
|
||||||
|
selector = selector.trim();
|
||||||
|
title = title.trim();
|
||||||
|
|
||||||
|
if (!selector) {
|
||||||
|
this.do_warn(_t('Error'), _t('Please select an element'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!title) {
|
||||||
|
this.do_warn(_t('Error'), _t('Please enter a title'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For existing tours, topicId must be present
|
||||||
|
// For new tours, topicId will be created on first save
|
||||||
|
if (!this.topicId && !this.isNewTour) {
|
||||||
|
this.do_warn(_t('Error'), _t('Topic ID is missing.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// NEW TOUR MODE: Create tour first if it doesn't exist
|
||||||
|
// ============================================================
|
||||||
|
var createTourPromise = Promise.resolve();
|
||||||
|
|
||||||
|
if (this.isNewTour && !this.topicId) {
|
||||||
|
var tourName = this.$('.tour-name-input').val();
|
||||||
|
|
||||||
|
// Get module ID from hidden field (set by input handler)
|
||||||
|
var moduleId = this.$('.tour-module-select').val();
|
||||||
|
|
||||||
|
// DEBUG: Log module selection details
|
||||||
|
console.log('[Recorder] Tour Name:', tourName);
|
||||||
|
console.log('[Recorder] Module ID retrieved:', moduleId);
|
||||||
|
|
||||||
|
// Validate Tour Name
|
||||||
|
if (!tourName || tourName.trim() === '') {
|
||||||
|
this.$('.tour-name-input').addClass('is-invalid');
|
||||||
|
var $alert = this.$('.recorder-alert');
|
||||||
|
$alert.removeClass('d-none alert-success').addClass('alert-danger').text(_t('Please enter a Tour Name.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.$('.tour-name-input').removeClass('is-invalid');
|
||||||
|
|
||||||
|
var tourVals = {
|
||||||
|
'name': tourName.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only add module_id if it's a valid number
|
||||||
|
if (moduleId && moduleId !== '' && !isNaN(parseInt(moduleId))) {
|
||||||
|
tourVals.module_id = parseInt(moduleId);
|
||||||
|
console.log('[Recorder] Module ID added to vals:', tourVals.module_id);
|
||||||
|
} else {
|
||||||
|
console.log('[Recorder] No valid module_id to add');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Recorder] Creating Tour with vals:', JSON.stringify(tourVals));
|
||||||
|
|
||||||
|
createTourPromise = ajax.jsonRpc('/web/dataset/call_kw/genius.topic/create', 'call', {
|
||||||
|
model: 'genius.topic',
|
||||||
|
method: 'create',
|
||||||
|
args: [tourVals],
|
||||||
|
kwargs: { context: session.user_context }
|
||||||
|
}).then(function (newTopicId) {
|
||||||
|
console.log('[Recorder] Tour Created:', newTopicId);
|
||||||
|
self.topicId = newTopicId;
|
||||||
|
self.topicName = tourName.trim();
|
||||||
|
self.isNewTour = false; // No longer new
|
||||||
|
|
||||||
|
// Update header title
|
||||||
|
self.$('.recorder-header h4').html('<i class="fa fa-video-camera"></i> Recording: ' + self.topicName);
|
||||||
|
|
||||||
|
// Hide the Tour Info section
|
||||||
|
self.$('.tour-info-section').slideUp();
|
||||||
|
self.$('.tour-info-section').next('hr').slideUp();
|
||||||
|
|
||||||
|
return newTopicId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SAVE STEP (after tour is guaranteed to exist)
|
||||||
|
// ============================================================
|
||||||
|
createTourPromise.then(function () {
|
||||||
|
var stepData = {
|
||||||
|
topic_id: self.topicId,
|
||||||
|
css_selector: selector,
|
||||||
|
title: title,
|
||||||
|
instruction: self.$('.step-instruction-input').val() || '',
|
||||||
|
position: self.$('.position-select').val() || 'bottom',
|
||||||
|
step_type: self.$('.step-type-select').val() || 'click',
|
||||||
|
input_value: self.$('.input-value-input').val() || '',
|
||||||
|
sequence: (self.steps.length + 1) * 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[Recorder] Saving Step:', stepData);
|
||||||
|
|
||||||
|
return ajax.jsonRpc('/web/dataset/call_kw/genius.topic.step/create', 'call', {
|
||||||
|
model: 'genius.topic.step',
|
||||||
|
method: 'create',
|
||||||
|
args: [stepData],
|
||||||
|
kwargs: { context: session.user_context }
|
||||||
|
}).then(function (stepId) {
|
||||||
|
console.log('[Recorder] Step Saved ID:', stepId);
|
||||||
|
|
||||||
|
// Update local steps array
|
||||||
|
stepData.id = stepId;
|
||||||
|
self.steps.push(stepData);
|
||||||
|
|
||||||
|
// CRITICAL FIX: Only update starting_url for NEW tours, not existing ones!
|
||||||
|
// Previously: self.steps.length === 1 (wrong - triggered for any first step in session)
|
||||||
|
// Now: Check if this is a NEW tour AND it's the first step we're adding
|
||||||
|
if (self.isNewTour && self.steps.length === 1) {
|
||||||
|
// CRITICAL: Capture URL where FIRST STEP was recorded
|
||||||
|
// This should be the page the user navigated to BEFORE selecting element
|
||||||
|
// NOT window.location.hash (which just gives the hash)
|
||||||
|
// Format should be like: /web#action=445&model=stock.picking
|
||||||
|
var fullUrl = window.location.href;
|
||||||
|
var origin = window.location.origin;
|
||||||
|
|
||||||
|
// Remove origin to get path + query + hash
|
||||||
|
var relativeUrl = fullUrl.replace(origin, '');
|
||||||
|
|
||||||
|
// Clean out our recorder params
|
||||||
|
try {
|
||||||
|
var url = new URL(fullUrl);
|
||||||
|
url.searchParams.delete('genius_recorder');
|
||||||
|
url.searchParams.delete('genius_tour');
|
||||||
|
url.searchParams.delete('genius_preview_tour');
|
||||||
|
url.searchParams.delete('genius_tour_run');
|
||||||
|
relativeUrl = url.pathname + url.search + url.hash;
|
||||||
|
// Ensure starts with /web if just a hash
|
||||||
|
if (relativeUrl.startsWith('#') || relativeUrl === '') {
|
||||||
|
relativeUrl = '/web' + relativeUrl;
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
// Fallback for older browsers
|
||||||
|
if (relativeUrl.startsWith('#')) {
|
||||||
|
relativeUrl = '/web' + relativeUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Recorder] Captured starting_url:', relativeUrl);
|
||||||
|
|
||||||
|
var modelName = '';
|
||||||
|
|
||||||
|
// Smart Module Detection from URL hash
|
||||||
|
try {
|
||||||
|
var hashParams = new URLSearchParams(window.location.hash.substring(1));
|
||||||
|
modelName = hashParams.get('model');
|
||||||
|
|
||||||
|
// Fallback to search params
|
||||||
|
if (!modelName) {
|
||||||
|
var searchParams = new URLSearchParams(window.location.search);
|
||||||
|
modelName = searchParams.get('model');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async Action Lookup if model missing but action exists
|
||||||
|
if (!modelName) {
|
||||||
|
var actionId = hashParams.get('action');
|
||||||
|
if (actionId) {
|
||||||
|
rpc.query({
|
||||||
|
model: 'ir.actions.act_window',
|
||||||
|
method: 'read',
|
||||||
|
args: [[parseInt(actionId)], ['res_model']],
|
||||||
|
}).then(function(res) {
|
||||||
|
if (res && res[0] && res[0].res_model) {
|
||||||
|
var m = res[0].res_model;
|
||||||
|
ajax.jsonRpc('/web/dataset/call_kw/genius.topic/write', 'call', {
|
||||||
|
model: 'genius.topic',
|
||||||
|
method: 'write',
|
||||||
|
args: [[parseInt(self.topicId)], {
|
||||||
|
'model_name': m,
|
||||||
|
'module_name': m.split('.')[0]
|
||||||
|
}],
|
||||||
|
kwargs: { context: session.user_context }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) { console.warn('[Recorder] Context detection failed', e); }
|
||||||
|
|
||||||
|
var updateVals = { 'starting_url': relativeUrl };
|
||||||
|
if (modelName) {
|
||||||
|
updateVals.model_name = modelName;
|
||||||
|
updateVals.module_name = modelName.split('.')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to topic
|
||||||
|
ajax.jsonRpc('/web/dataset/call_kw/genius.topic/write', 'call', {
|
||||||
|
model: 'genius.topic',
|
||||||
|
method: 'write',
|
||||||
|
args: [[parseInt(self.topicId)], updateVals],
|
||||||
|
kwargs: { context: session.user_context }
|
||||||
|
}).then(function() {
|
||||||
|
console.log('[Recorder] Topic updated with starting_url:', relativeUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self._updateStepCounter();
|
||||||
|
self._clearForm();
|
||||||
|
|
||||||
|
// Inline Feedback (Button)
|
||||||
|
var $btn = self.$('.btn-save-step');
|
||||||
|
var originalHtml = $btn.html();
|
||||||
|
$btn.html('<i class="fa fa-check"></i> ' + _t("Saved!"));
|
||||||
|
$btn.removeClass('btn-secondary').addClass('btn-success');
|
||||||
|
|
||||||
|
setTimeout(function() {
|
||||||
|
$btn.html(originalHtml);
|
||||||
|
$btn.removeClass('btn-success').addClass('btn-secondary');
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
// Hide alert if visible from previous error
|
||||||
|
self.$('.recorder-alert').addClass('d-none');
|
||||||
|
}).catch(function (error) {
|
||||||
|
console.error('[Recorder] Save Error:', error);
|
||||||
|
|
||||||
|
// Inline Feedback Error
|
||||||
|
var $alert = self.$('.recorder-alert');
|
||||||
|
$alert.removeClass('d-none alert-success').addClass('alert-danger').text(_t("Error: ") + (error.message || 'Unknown')).show();
|
||||||
|
});
|
||||||
|
}).catch(function (error) {
|
||||||
|
// Handle tour creation failure
|
||||||
|
console.error('[Recorder] Tour/Step Error:', error);
|
||||||
|
var $alert = self.$('.recorder-alert');
|
||||||
|
$alert.removeClass('d-none alert-success').addClass('alert-danger').text(_t("Error creating tour: ") + (error.message || 'Unknown')).show();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_clearForm: function () {
|
||||||
|
this.$('.css-selector-input').val('');
|
||||||
|
this.$('.step-title-input').val('');
|
||||||
|
this.$('.step-instruction-input').val('');
|
||||||
|
this.$('.input-value-input').val('');
|
||||||
|
// Reset type and ensure input field is hidden
|
||||||
|
this.$('.step-type-select').val('click');
|
||||||
|
this.$('.input-value-field').addClass('d-none');
|
||||||
|
|
||||||
|
this.currentStep = {
|
||||||
|
css_selector: '',
|
||||||
|
title: '',
|
||||||
|
instruction: '',
|
||||||
|
position: 'bottom',
|
||||||
|
step_type: 'click',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateStepCounter: function () {
|
||||||
|
this.$('.step-counter').text(this.steps.length + ' steps recorded');
|
||||||
|
},
|
||||||
|
|
||||||
|
_onFinish: function () {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
if (this.steps.length === 0) {
|
||||||
|
this.do_warn(_t('Warning'), _t('No steps recorded'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean URL (remove genius_recorder param)
|
||||||
|
try {
|
||||||
|
var url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete('genius_recorder');
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
} catch(e) { console.error(e); }
|
||||||
|
|
||||||
|
// Redirect to the created tour's form view (NOT hardcoded action)
|
||||||
|
var timestamp = new Date().getTime();
|
||||||
|
if (this.topicId) {
|
||||||
|
// Go to the created tour's form
|
||||||
|
window.location.href = '/web?t=' + timestamp + '#model=genius.topic&id=' + this.topicId + '&view_type=form';
|
||||||
|
} else {
|
||||||
|
// Fallback: go to tours list
|
||||||
|
window.location.href = '/web?t=' + timestamp + '#model=genius.topic&view_type=list';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.destroy();
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
_onClose: function () {
|
||||||
|
if (this.steps.length > 0) {
|
||||||
|
if (!confirm(_t('Unsaved steps will be lost. Close?'))) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean URL (remove genius_recorder param)
|
||||||
|
try {
|
||||||
|
var url = new URL(window.location.href);
|
||||||
|
if (url.searchParams.get('genius_recorder')) {
|
||||||
|
url.searchParams.delete('genius_recorder');
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
}
|
||||||
|
} catch(e) { console.error(e); }
|
||||||
|
|
||||||
|
this.destroy();
|
||||||
|
},
|
||||||
|
|
||||||
|
_cleanup: function () {
|
||||||
|
this._stopTracking();
|
||||||
|
$(document).off('.recorder');
|
||||||
|
},
|
||||||
|
|
||||||
|
_setupGlobalEvents: function () {
|
||||||
|
$(document).on('keydown.recorder', function (ev) {
|
||||||
|
if (ev.which === 27) this._onClose(); // Esc
|
||||||
|
}.bind(this));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
core.action_registry.add('genius_recorder_panel', RecorderPanel);
|
||||||
|
|
||||||
|
function openRecorder(topicId, topicName) {
|
||||||
|
var recorder = new RecorderPanel(null, {
|
||||||
|
topicId: topicId,
|
||||||
|
topicName: topicName,
|
||||||
|
});
|
||||||
|
recorder.appendTo($('body'));
|
||||||
|
return recorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function () {
|
||||||
|
var urlParams = new URLSearchParams(window.location.search);
|
||||||
|
var recorderTopicId = urlParams.get('genius_recorder');
|
||||||
|
if (recorderTopicId) {
|
||||||
|
setTimeout(function() {
|
||||||
|
// CLEANUP: Remove parameter immediately so refresh doesn't re-trigger
|
||||||
|
try {
|
||||||
|
var url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete('genius_recorder');
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
} catch(e) { console.error('Failed to clean URL', e); }
|
||||||
|
|
||||||
|
var rpc = require('web.rpc');
|
||||||
|
rpc.query({
|
||||||
|
model: 'genius.topic',
|
||||||
|
method: 'read',
|
||||||
|
args: [[parseInt(recorderTopicId)], ['name']],
|
||||||
|
}).then(function (result) {
|
||||||
|
var topicName = result && result[0] ? result[0].name : 'New Tour';
|
||||||
|
openRecorder(parseInt(recorderTopicId), topicName);
|
||||||
|
});
|
||||||
|
}, 1000); // Increased delay to ensure Odoo is ready
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { RecorderPanel: RecorderPanel, openRecorder: openRecorder };
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,263 @@
|
||||||
|
odoo.define('tour_genius.smart_systray', function (require) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var SystrayMenu = require('web.SystrayMenu');
|
||||||
|
var Widget = require('web.Widget');
|
||||||
|
var rpc = require('web.rpc');
|
||||||
|
var core = require('web.core');
|
||||||
|
var tour = require('web_tour.tour'); // Add tour require
|
||||||
|
var QWeb = core.qweb;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart Systray - Context-aware training icon with dropdown
|
||||||
|
* Shows available training based on current screen
|
||||||
|
* Following Odoo official pattern (mail.systray.ActivityMenu)
|
||||||
|
*/
|
||||||
|
var GeniusSystray = Widget.extend({
|
||||||
|
template: 'GeniusSystrayIcon',
|
||||||
|
// Lower sequence = more to the LEFT in systray (default 50)
|
||||||
|
sequence: 10,
|
||||||
|
|
||||||
|
events: {
|
||||||
|
'show.bs.dropdown': '_onDropdownShow',
|
||||||
|
'hide.bs.dropdown': '_onDropdownHide',
|
||||||
|
'click .genius-run-tour': '_onRunTour',
|
||||||
|
'click .genius-view-all': '_onViewAll',
|
||||||
|
},
|
||||||
|
|
||||||
|
init: function (parent) {
|
||||||
|
this._super.apply(this, arguments);
|
||||||
|
this.badge_count = 0;
|
||||||
|
this.tours = [];
|
||||||
|
},
|
||||||
|
|
||||||
|
start: function () {
|
||||||
|
var self = this;
|
||||||
|
return this._super.apply(this, arguments).then(function () {
|
||||||
|
self._detectContext();
|
||||||
|
|
||||||
|
// Listen for navigation changes
|
||||||
|
core.bus.on('change_title', self, self._detectContext);
|
||||||
|
core.bus.on('action_started', self, self._detectContext);
|
||||||
|
|
||||||
|
// CRITICAL: Listen to hash changes for immediate updates
|
||||||
|
$(window).on('hashchange', function() {
|
||||||
|
self._detectContext();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_detectContext: function () {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
// Debounce to prevent rapid firing during redirects
|
||||||
|
if (this.detectTimeout) clearTimeout(this.detectTimeout);
|
||||||
|
|
||||||
|
this.detectTimeout = setTimeout(function() {
|
||||||
|
// Parse URL hash for context
|
||||||
|
var hash = window.location.hash;
|
||||||
|
var params = {};
|
||||||
|
if (hash) {
|
||||||
|
hash.substring(1).split('&').forEach(function(part) {
|
||||||
|
var kv = part.split('=');
|
||||||
|
if (kv.length === 2) {
|
||||||
|
params[kv[0]] = decodeURIComponent(kv[1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = {
|
||||||
|
action_id: params.action ? parseInt(params.action) : null,
|
||||||
|
model: params.model || null,
|
||||||
|
menu_id: params.menu_id ? parseInt(params.menu_id) : null, // NEW: For reliable app context
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch relevant training
|
||||||
|
rpc.query({
|
||||||
|
model: 'genius.topic',
|
||||||
|
method: 'get_contextual_training',
|
||||||
|
args: [context],
|
||||||
|
}).then(function (result) {
|
||||||
|
self.tours = result.topics || [];
|
||||||
|
// CRITICAL: Only show badge for NEW items to avoid annoyance
|
||||||
|
self.badge_count = result.new_count || 0;
|
||||||
|
self._updateBadge();
|
||||||
|
}).catch(function (err) {
|
||||||
|
// console.warn('[Tour Genius] Failed to fetch contextual training', err);
|
||||||
|
self.tours = [];
|
||||||
|
self.badge_count = 0;
|
||||||
|
self._updateBadge();
|
||||||
|
});
|
||||||
|
}, 300); // Reduced debounce to 300ms for snappier feel
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateBadge: function () {
|
||||||
|
var $badge = this.$('.genius-systray-badge');
|
||||||
|
if (this.badge_count > 0) {
|
||||||
|
$badge.text(this.badge_count).show();
|
||||||
|
this.$('.genius-systray-btn').addClass('has-training');
|
||||||
|
} else {
|
||||||
|
$badge.hide();
|
||||||
|
this.$('.genius-systray-btn').removeClass('has-training');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap dropdown show event - populate tours
|
||||||
|
*/
|
||||||
|
_onDropdownShow: function () {
|
||||||
|
var self = this;
|
||||||
|
var $list = this.$('.genius-tours-list');
|
||||||
|
var $empty = this.$('.genius-empty');
|
||||||
|
|
||||||
|
$list.empty();
|
||||||
|
|
||||||
|
if (this.tours.length > 0) {
|
||||||
|
$empty.hide();
|
||||||
|
this.tours.forEach(function(tour) {
|
||||||
|
var $item = $(QWeb.render('GeniusSystrayItem', {tour: tour}));
|
||||||
|
$list.append($item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$empty.show();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap dropdown hide event
|
||||||
|
*/
|
||||||
|
_onDropdownHide: function () {
|
||||||
|
// Nothing needed - Bootstrap handles the close
|
||||||
|
},
|
||||||
|
|
||||||
|
_onRunTour: function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
var tourId = $(ev.currentTarget).data('tour-id');
|
||||||
|
var tourName = 'genius_tour_' + tourId;
|
||||||
|
this.$('.dropdown-toggle').dropdown('toggle');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// COMPREHENSIVE CLEANUP (same as dashboard)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// 1. Destroy all active tooltips
|
||||||
|
if (tour.active_tooltips) {
|
||||||
|
Object.keys(tour.active_tooltips).forEach(function(key) {
|
||||||
|
try {
|
||||||
|
var tip = tour.active_tooltips[key];
|
||||||
|
if (tip && tip.widget && tip.widget.destroy) {
|
||||||
|
tip.widget.destroy();
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
tour.active_tooltips = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Clear running tour state
|
||||||
|
tour.running_tour = null;
|
||||||
|
tour.paused = false;
|
||||||
|
|
||||||
|
// 3. Clear ALL tour-related localStorage items
|
||||||
|
var keysToRemove = [];
|
||||||
|
for (var i = 0; i < window.localStorage.length; i++) {
|
||||||
|
var key = window.localStorage.key(i);
|
||||||
|
if (key && (
|
||||||
|
key.startsWith('debugging_tour_') ||
|
||||||
|
key.startsWith('tour_manager_') ||
|
||||||
|
key.startsWith('tour_step_') ||
|
||||||
|
key.endsWith('_was_active')
|
||||||
|
)) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keysToRemove.forEach(function(key) {
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Reset all tours
|
||||||
|
if (tour.tours) {
|
||||||
|
Object.keys(tour.tours).forEach(function(name) {
|
||||||
|
var t = tour.tours[name];
|
||||||
|
if (t) {
|
||||||
|
t.current_step = 0;
|
||||||
|
if (name.startsWith('genius_tour_')) {
|
||||||
|
t.ready = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ACTIVATE TOUR
|
||||||
|
// ============================================================
|
||||||
|
var targetTour = tour.tours && tour.tours[tourName];
|
||||||
|
if (targetTour && targetTour.steps && targetTour.steps.length > 0) {
|
||||||
|
var tourUrl = targetTour.url || '/web';
|
||||||
|
|
||||||
|
// Set debugging flag only (NOT running_tour - that triggers automated mode!)
|
||||||
|
window.localStorage.setItem('debugging_tour_' + tourName, 'true');
|
||||||
|
|
||||||
|
// Force reload with timestamp
|
||||||
|
var targetUrl = window.location.origin + tourUrl;
|
||||||
|
try {
|
||||||
|
var url = new URL(targetUrl);
|
||||||
|
url.searchParams.set('genius_tour_run', Date.now());
|
||||||
|
window.location.href = url.href;
|
||||||
|
} catch (e) {
|
||||||
|
var separator = tourUrl.indexOf('?') >= 0 ? '&' : '?';
|
||||||
|
window.location.href = targetUrl + separator + 'genius_tour_run=' + Date.now();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('[Tour Genius] Tour not found or has no steps:', tourName);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_onViewAll: function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.$('.dropdown-toggle').dropdown('toggle');
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
// GENIUS NAVIGATION:
|
||||||
|
// To ensure the user "feels" they left the current module, we must:
|
||||||
|
// 1. Switch the Action (Content)
|
||||||
|
// 2. Switch the Menu (Navbar/Context)
|
||||||
|
// We fetch the IDs dynamically to ensure robustness.
|
||||||
|
|
||||||
|
rpc.query({
|
||||||
|
model: 'ir.model.data',
|
||||||
|
method: 'xmlid_to_res_model_res_id',
|
||||||
|
args: ['tour_genius.action_genius_dashboard', true], // Raise if not found
|
||||||
|
}).then(function (actionResult) {
|
||||||
|
var actionId = actionResult[1];
|
||||||
|
|
||||||
|
rpc.query({
|
||||||
|
model: 'ir.model.data',
|
||||||
|
method: 'xmlid_to_res_model_res_id',
|
||||||
|
args: ['tour_genius.menu_tour_genius_root', true],
|
||||||
|
}).then(function (menuResult) {
|
||||||
|
var menuId = menuResult[1];
|
||||||
|
|
||||||
|
// Construct a clean state
|
||||||
|
// We use window.location to force the ActionManager to process the full context switch
|
||||||
|
// inclusive of the Menu ID, which updates the Top Navbar.
|
||||||
|
var url = '/web#action=' + actionId + '&menu_id=' + menuId;
|
||||||
|
|
||||||
|
// Use pushState to change URL without full reload if possible,
|
||||||
|
// but Odoo 14 sometimes needs a help to update the menu.
|
||||||
|
// The most reliable way for "feeling like a new app" is to let the router handle it.
|
||||||
|
window.location.href = url;
|
||||||
|
|
||||||
|
// Fallback: If hash didn't trigger immediate update (e.g. same hash), reload
|
||||||
|
// window.location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
SystrayMenu.Items.push(GeniusSystray);
|
||||||
|
|
||||||
|
return GeniusSystray;
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
/**
|
||||||
|
* Tour Genius - Client Action
|
||||||
|
* ============================
|
||||||
|
* Client action to run a tour with comprehensive cleanup
|
||||||
|
*/
|
||||||
|
|
||||||
|
odoo.define('tour_genius.client_action', function (require) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var core = require('web.core');
|
||||||
|
var tour = require('web_tour.tour');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client action to run a genius tour
|
||||||
|
* Called from Python action: {'type': 'ir.actions.client', 'tag': 'tour_genius_run_tour', ...}
|
||||||
|
*/
|
||||||
|
function runTourAction(parent, action) {
|
||||||
|
var tourName = action.params && action.params.tour_name;
|
||||||
|
|
||||||
|
if (!tourName) {
|
||||||
|
console.error('[Tour Genius] No tour_name provided in action params');
|
||||||
|
return Promise.reject('No tour_name provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Tour Genius] Running tour via client action:', tourName);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// COMPREHENSIVE CLEANUP
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// 1. Destroy all active tooltips
|
||||||
|
if (tour.active_tooltips) {
|
||||||
|
Object.keys(tour.active_tooltips).forEach(function(key) {
|
||||||
|
try {
|
||||||
|
var tip = tour.active_tooltips[key];
|
||||||
|
if (tip && tip.widget && tip.widget.destroy) {
|
||||||
|
tip.widget.destroy();
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
tour.active_tooltips = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Clear running tour state
|
||||||
|
tour.running_tour = null;
|
||||||
|
tour.paused = false;
|
||||||
|
|
||||||
|
// 3. Clear ALL tour-related localStorage items
|
||||||
|
var keysToRemove = [];
|
||||||
|
for (var i = 0; i < window.localStorage.length; i++) {
|
||||||
|
var key = window.localStorage.key(i);
|
||||||
|
if (key && (
|
||||||
|
key.startsWith('debugging_tour_') ||
|
||||||
|
key.startsWith('tour_manager_') ||
|
||||||
|
key.startsWith('tour_step_') ||
|
||||||
|
key.endsWith('_was_active')
|
||||||
|
)) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keysToRemove.forEach(function(key) {
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Reset all tours
|
||||||
|
if (tour.tours) {
|
||||||
|
Object.keys(tour.tours).forEach(function(name) {
|
||||||
|
var t = tour.tours[name];
|
||||||
|
if (t) {
|
||||||
|
t.current_step = 0;
|
||||||
|
if (name.startsWith('genius_tour_')) {
|
||||||
|
t.ready = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ACTIVATE TOUR
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Check if we need to dynamically register (test mode for draft tours)
|
||||||
|
var testMode = action.params && action.params.test_mode;
|
||||||
|
var tourData = action.params && action.params.tour_data;
|
||||||
|
|
||||||
|
if (testMode && tourData && tourData.steps) {
|
||||||
|
// Dynamic registration for test mode (draft tours)
|
||||||
|
console.log('[Tour Genius] Test Mode: Storing tour data for post-redirect registration:', tourName);
|
||||||
|
|
||||||
|
// Store tour data in localStorage for post-redirect registration
|
||||||
|
// This is needed because draft tours aren't in get_tours_for_registration()
|
||||||
|
window.localStorage.setItem('genius_test_tour_data_' + tourName, JSON.stringify(tourData));
|
||||||
|
|
||||||
|
// Also register now in case we don't redirect (unlikely but safe)
|
||||||
|
if (!tour.tours) {
|
||||||
|
tour.tours = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the tour dynamically
|
||||||
|
tour.register(tourName, {
|
||||||
|
url: tourData.url || '/web',
|
||||||
|
wait_for: Promise.resolve(),
|
||||||
|
}, tourData.steps);
|
||||||
|
|
||||||
|
console.log('[Tour Genius] Tour registered dynamically:', tourName);
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetTour = tour.tours && tour.tours[tourName];
|
||||||
|
if (targetTour && targetTour.steps && targetTour.steps.length > 0) {
|
||||||
|
var tourUrl = targetTour.url || '/web';
|
||||||
|
|
||||||
|
// Set debugging flag only (NOT running_tour - that triggers automated mode!)
|
||||||
|
window.localStorage.setItem('debugging_tour_' + tourName, 'true');
|
||||||
|
|
||||||
|
// Mark as test mode in localStorage if applicable
|
||||||
|
if (testMode) {
|
||||||
|
window.localStorage.setItem('genius_test_mode_' + tourName, 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force reload with timestamp
|
||||||
|
var targetUrl = window.location.origin + tourUrl;
|
||||||
|
try {
|
||||||
|
var url = new URL(targetUrl);
|
||||||
|
url.searchParams.set('genius_tour_run', Date.now());
|
||||||
|
window.location.href = url.href;
|
||||||
|
} catch (e) {
|
||||||
|
var separator = tourUrl.indexOf('?') >= 0 ? '&' : '?';
|
||||||
|
window.location.href = targetUrl + separator + 'genius_tour_run=' + Date.now();
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
} else {
|
||||||
|
console.error('[Tour Genius] Tour not found or has no steps:', tourName);
|
||||||
|
console.log('[Tour Genius] Available tours:', Object.keys(tour.tours || {}));
|
||||||
|
return Promise.reject('Tour not found: ' + tourName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the client action
|
||||||
|
core.action_registry.add('tour_genius_run_tour', runTourAction);
|
||||||
|
|
||||||
|
return {
|
||||||
|
runTourAction: runTourAction,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,631 @@
|
||||||
|
/**
|
||||||
|
* Tour Genius - Static Tour Registration
|
||||||
|
* =======================================
|
||||||
|
* Registers all genius tours at page load, then manually triggers
|
||||||
|
* the activation check for each tour.
|
||||||
|
*
|
||||||
|
* FOLLOWS ODOO PATTERN (from tour_manager.js):
|
||||||
|
* - Tours registered via tour.register()
|
||||||
|
* - After registration, tour._register() is called to check localStorage
|
||||||
|
* - If debugging_tour_X flag exists, tooltip is activated
|
||||||
|
* - tour.reset() sets this flag and redirects to tour URL
|
||||||
|
*/
|
||||||
|
|
||||||
|
odoo.define('tour_genius.tour_loader', function (require) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var rpc = require('web.rpc');
|
||||||
|
var tour = require('web_tour.tour');
|
||||||
|
var core = require('web.core');
|
||||||
|
|
||||||
|
var _t = core._t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all genius tours and trigger activation check
|
||||||
|
* Called once on page load
|
||||||
|
*/
|
||||||
|
// Force tooltips to be on top of everything (especially navbars/dropdowns)
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.innerHTML = '.o_tooltip { z-index: 10000 !important; }';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
// CRITICAL: Store quiz data locally to prevent Odoo from stripping it during tour processing
|
||||||
|
var geniusToursData = {};
|
||||||
|
|
||||||
|
function registerAllTours() {
|
||||||
|
console.log('[Tour Genius] Starting tour registration...');
|
||||||
|
|
||||||
|
// GENIUS FIX: Pre-emptive check for pending genius tours to prevent overlap
|
||||||
|
// This prevents standard tours from flashing while we fetch our tours
|
||||||
|
var antiFlashInterval;
|
||||||
|
try {
|
||||||
|
var isGeniusPending = false;
|
||||||
|
// Use native localStorage for iteration as Odoo's wrapper might not support key iteration easily
|
||||||
|
if (window.localStorage) {
|
||||||
|
for (var i = 0; i < window.localStorage.length; i++) {
|
||||||
|
var key = window.localStorage.key(i);
|
||||||
|
if (key && key.indexOf('debugging_tour_genius_') > -1) {
|
||||||
|
isGeniusPending = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGeniusPending) {
|
||||||
|
console.log('[Tour Genius] Pending genius tour detected. Aggressively clearing others.');
|
||||||
|
deactivateNonGeniusTours();
|
||||||
|
// Repeat deactivation every 100ms until RPC returns to catch any stragglers
|
||||||
|
antiFlashInterval = setInterval(deactivateNonGeniusTours, 100);
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('[Tour Genius] Pre-emptive check failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc.query({
|
||||||
|
model: 'genius.topic',
|
||||||
|
method: 'get_tours_for_registration',
|
||||||
|
args: [],
|
||||||
|
}).catch(function(err) {
|
||||||
|
console.warn('[Tour Genius] Failed to fetch tours (likely public page/access denied):', err);
|
||||||
|
return []; // Return empty array to allow chain to continue (e.g. for test tours)
|
||||||
|
}).then(function(topics) {
|
||||||
|
// Stop the aggressive clearing once we have data
|
||||||
|
if (antiFlashInterval) clearInterval(antiFlashInterval);
|
||||||
|
|
||||||
|
// Initialize registeredTours array FIRST (before test tour registration)
|
||||||
|
var registeredTours = [];
|
||||||
|
|
||||||
|
// GENIUS: Check for stored test tour data (for testing draft tours)
|
||||||
|
try {
|
||||||
|
for (var i = 0; i < window.localStorage.length; i++) {
|
||||||
|
var key = window.localStorage.key(i);
|
||||||
|
if (key && key.startsWith('genius_test_tour_data_')) {
|
||||||
|
var tourName = key.replace('genius_test_tour_data_', '');
|
||||||
|
var storedData = window.localStorage.getItem(key);
|
||||||
|
if (storedData) {
|
||||||
|
var testTourData = JSON.parse(storedData);
|
||||||
|
console.log('[Tour Genius] Found stored test tour data:', tourName);
|
||||||
|
|
||||||
|
// Register the test tour dynamically
|
||||||
|
if (testTourData.steps && testTourData.steps.length > 0) {
|
||||||
|
console.log('[Tour Genius] Registering test tour with steps:', testTourData.steps);
|
||||||
|
console.log('[Tour Genius] Step 0 Content CHECK:', testTourData.steps[0].content);
|
||||||
|
tour.register(tourName, {
|
||||||
|
url: testTourData.url || '/web',
|
||||||
|
wait_for: Promise.resolve(),
|
||||||
|
}, testTourData.steps);
|
||||||
|
console.log('[Tour Genius] Registered test tour from localStorage:', tourName);
|
||||||
|
|
||||||
|
// CRITICAL: Add to registeredTours so it goes through activation!
|
||||||
|
registeredTours.push(tourName);
|
||||||
|
} else {
|
||||||
|
console.error('[Tour Genius] Test tour data found but NO STEPS:', testTourData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up after registration (will be used once)
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Tour Genius] Error processing stored test tour data:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!topics || !topics.length) {
|
||||||
|
console.log('[Tour Genius] No published tours to register (test tours may still work)');
|
||||||
|
} else {
|
||||||
|
console.log('[Tour Genius] Registering', topics.length, 'tours');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safely iterate over topics (may be empty array or null)
|
||||||
|
(topics || []).forEach(function(topic) {
|
||||||
|
var tourName = 'genius_tour_' + topic.id;
|
||||||
|
|
||||||
|
// Store quiz data permanently
|
||||||
|
geniusToursData[tourName] = topic.quiz;
|
||||||
|
|
||||||
|
// Skip if already registered (e.g. by test mode loader above)
|
||||||
|
if (tour.tours && tour.tours[tourName]) {
|
||||||
|
console.log('[Tour Genius] Tour already registered (likely test mode):', tourName);
|
||||||
|
// Only add to registeredTours if NOT already there to prevent double activation
|
||||||
|
if (registeredTours.indexOf(tourName) === -1) {
|
||||||
|
registeredTours.push(tourName);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if no steps
|
||||||
|
if (!topic.steps || !topic.steps.length) {
|
||||||
|
console.log('[Tour Genius] Skipping tour with no steps:', tourName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GENIUS FIX: Auto-inject showAppsMenuItem() when first step targets an app icon
|
||||||
|
// This ensures the home menu is visible before clicking on app icons
|
||||||
|
var stepsToRegister = topic.steps.slice(); // Clone array
|
||||||
|
var firstStep = stepsToRegister[0];
|
||||||
|
var needsAppsMenu = firstStep && firstStep.trigger &&
|
||||||
|
(firstStep.trigger.includes('.o_app[data-menu-xmlid') ||
|
||||||
|
firstStep.trigger.includes('.o_app[data-menu-xmlid='));
|
||||||
|
|
||||||
|
if (needsAppsMenu && tour.stepUtils && tour.stepUtils.showAppsMenuItem) {
|
||||||
|
// Check if showAppsMenuItem is not already the first step (avoid dupes)
|
||||||
|
var firstStepIsApps = firstStep && firstStep.content === "Home Menu"; // Odoo standard name
|
||||||
|
if (!firstStepIsApps) {
|
||||||
|
console.log('[Tour Genius] Injecting showAppsMenuItem for tour:', tourName);
|
||||||
|
stepsToRegister.unshift(tour.stepUtils.showAppsMenuItem());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register with Odoo's tour manager
|
||||||
|
tour.register(tourName, {
|
||||||
|
test: false, // Interactive mode (not test)
|
||||||
|
url: topic.starting_url || '/web',
|
||||||
|
rainbowMan: false, // DISABLED: We use our own GeniusCelebration widget
|
||||||
|
skip_enabled: true,
|
||||||
|
sequence: 1, // Priority 1: Override standard Odoo tours (which are usually 10/50)
|
||||||
|
}, stepsToRegister);
|
||||||
|
|
||||||
|
registeredTours.push(tourName);
|
||||||
|
|
||||||
|
console.log('[Tour Genius] Registered tour:', tourName, 'with', topic.steps.length, 'steps');
|
||||||
|
});
|
||||||
|
|
||||||
|
// CRITICAL FIX: For tours with debugging flags, we need to properly call _register()
|
||||||
|
// This ensures:
|
||||||
|
// 1. tour.wait_for promise is awaited
|
||||||
|
// 2. tour.steps are filtered for edition/mobile
|
||||||
|
// 3. tour.current_step is initialized from localStorage
|
||||||
|
// 4. tour.ready is set correctly
|
||||||
|
// 5. _to_next_step is called to set up active_tooltips
|
||||||
|
var localStorage = require('web.local_storage');
|
||||||
|
|
||||||
|
registeredTours.forEach(function(tourName) {
|
||||||
|
var registeredTour = tour.tours[tourName];
|
||||||
|
if (!registeredTour) return;
|
||||||
|
|
||||||
|
// Check if this tour has a debugging flag (set by tour.reset())
|
||||||
|
var debuggingKey = 'debugging_tour_' + tourName;
|
||||||
|
var isDebugging = localStorage.getItem(debuggingKey);
|
||||||
|
|
||||||
|
console.log('[Tour Genius] Checking activation for:', tourName, 'debugging:', !!isDebugging);
|
||||||
|
|
||||||
|
if (isDebugging) {
|
||||||
|
// Tour was started via reset() - call _register to properly initialize it
|
||||||
|
console.log('[Tour Genius] Activating tour from debugging flag:', tourName);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DEEP CLEANUP: Force Fresh Start
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// 1. Remove from Odoo's 'consumed_tours' (Storage & Memory)
|
||||||
|
// This prevents Odoo from skipping the tour because it thinks it's done
|
||||||
|
var consumedKey = 'tour_manager_consumed_tours';
|
||||||
|
try {
|
||||||
|
var consumedStr = window.localStorage.getItem(consumedKey);
|
||||||
|
if (consumedStr) {
|
||||||
|
var consumed = JSON.parse(consumedStr);
|
||||||
|
var idx = consumed.indexOf(tourName);
|
||||||
|
if (idx > -1) {
|
||||||
|
consumed.splice(idx, 1);
|
||||||
|
window.localStorage.setItem(consumedKey, JSON.stringify(consumed));
|
||||||
|
console.log('[Tour Genius] Removed from localStorage consumed list:', tourName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
if (tour.consumed_tours) {
|
||||||
|
var idx = tour.consumed_tours.indexOf(tourName);
|
||||||
|
if (idx > -1) {
|
||||||
|
tour.consumed_tours.splice(idx, 1);
|
||||||
|
console.log('[Tour Genius] Removed from memory consumed list:', tourName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1.5 Deep Clean Step Persistence (CRITICAL)
|
||||||
|
// Odoo stores the current step in localStorage using the format:
|
||||||
|
// tour_<tourName>_current_step
|
||||||
|
// We MUST delete this BEFORE calling _register to force Step 0
|
||||||
|
var stepKey = 'tour_' + tourName + '_current_step';
|
||||||
|
if (window.localStorage.getItem(stepKey) !== null) {
|
||||||
|
window.localStorage.removeItem(stepKey);
|
||||||
|
console.log('[Tour Genius] Wiped step persistence key:', stepKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also clean any other patterns just in case
|
||||||
|
for (var i = window.localStorage.length - 1; i >= 0; i--) {
|
||||||
|
var key = window.localStorage.key(i);
|
||||||
|
if (!key) continue;
|
||||||
|
|
||||||
|
// Avoid deleting the debugging flag itself!
|
||||||
|
if (key === 'debugging_tour_' + tourName) continue;
|
||||||
|
|
||||||
|
// Delete if it matches tour name patterns
|
||||||
|
if (key === tourName ||
|
||||||
|
key.indexOf(tourName) >= 0 && (key.indexOf('step') >= 0 || key.indexOf('current') >= 0)) {
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
console.log('[Tour Genius] Wiped additional persistence key:', key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Destroy Zombie Tooltips
|
||||||
|
// If a tooltip exists from a previous page load, it might be detached. Kill it.
|
||||||
|
if (tour.active_tooltips[tourName]) {
|
||||||
|
console.log('[Tour Genius] Destroying zombie tooltip before activation');
|
||||||
|
try {
|
||||||
|
if (tour.active_tooltips[tourName].destroy)
|
||||||
|
tour.active_tooltips[tourName].destroy();
|
||||||
|
else if (tour.active_tooltips[tourName].widget && tour.active_tooltips[tourName].widget.destroy)
|
||||||
|
tour.active_tooltips[tourName].widget.destroy();
|
||||||
|
} catch(e) {}
|
||||||
|
delete tour.active_tooltips[tourName];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Reset Step State
|
||||||
|
if (tour.tours[tourName]) {
|
||||||
|
tour.tours[tourName].current_step = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Call _register with do_update=true
|
||||||
|
// This will check the debugging flag and call _to_next_step
|
||||||
|
tour._register(true, registeredTour, tourName).then(function() {
|
||||||
|
console.log('[Tour Genius] Tour registered promise resolved:', tourName);
|
||||||
|
|
||||||
|
// CRITICAL: Deactivate ALL non-genius tours
|
||||||
|
deactivateNonGeniusTours();
|
||||||
|
|
||||||
|
// Check if this is a test mode run (skip progress tracking)
|
||||||
|
var isTestMode = window.localStorage.getItem('genius_test_mode_' + tourName) === 'true';
|
||||||
|
|
||||||
|
// Track start time ONLY if not in test mode
|
||||||
|
if (!isTestMode) {
|
||||||
|
var tourId = parseInt(tourName.replace('genius_tour_', ''));
|
||||||
|
rpc.query({
|
||||||
|
model: 'genius.topic',
|
||||||
|
method: 'action_track_start',
|
||||||
|
args: [[tourId]],
|
||||||
|
}).catch(function(e) {
|
||||||
|
console.warn('[Tour Genius] Failed to track start time:', e);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('[Tour Genius] Test mode - skipping progress tracking start');
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORCE ACTIVATION CHECK
|
||||||
|
// Sometimes Odoo's _register doesn't trigger _to_next_step if timing is off
|
||||||
|
if (!tour.active_tooltips[tourName]) {
|
||||||
|
console.warn('[Tour Genius] Tooltip missing after register. Forcing activation of Step 0.');
|
||||||
|
|
||||||
|
if (tour.tours[tourName]) {
|
||||||
|
tour.tours[tourName].current_step = 0;
|
||||||
|
// Manually trigger internal method to stage the first tooltip
|
||||||
|
if (tour._to_next_step) {
|
||||||
|
tour._to_next_step(tourName, 0);
|
||||||
|
console.log('[Tour Genius] Forced _to_next_step(0) success.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After registration, call update to display the tooltip
|
||||||
|
setTimeout(function() {
|
||||||
|
console.log('[Tour Genius] Calling tour.update() for:', tourName);
|
||||||
|
console.log('[Tour Genius] Active Tooltip State:', tour.active_tooltips[tourName]);
|
||||||
|
tour.update(tourName);
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// For non-debugging tours, do nothing - they don't need activation
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Tour Genius] All tours registered and activation checks initiated');
|
||||||
|
|
||||||
|
// CRITICAL: Override Odoo's _consume_tour to show our GeniusCelebration
|
||||||
|
patchConsumeTour(registeredTours);
|
||||||
|
patchActivateTip(); // GENIUS: Enable custom GeniusTip for genius tours
|
||||||
|
|
||||||
|
// Monitor for tour completions and handle redirect + mark consumed
|
||||||
|
setupTourCompletionListener(registeredTours);
|
||||||
|
|
||||||
|
}).catch(function(err) {
|
||||||
|
if (antiFlashInterval) clearInterval(antiFlashInterval);
|
||||||
|
console.error('[Tour Genius] Error registering tours:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch Odoo's _consume_tour to show GeniusCelebration for genius tours
|
||||||
|
* This is the most reliable way to intercept tour completion
|
||||||
|
*/
|
||||||
|
function patchConsumeTour(geniusTourNames) {
|
||||||
|
if (tour._consume_tour_patched) return; // Already patched
|
||||||
|
|
||||||
|
var originalConsumeTour = tour._consume_tour.bind(tour);
|
||||||
|
|
||||||
|
tour._consume_tour = function(tour_name, error) {
|
||||||
|
var isGeniusTour = tour_name.startsWith('genius_tour_');
|
||||||
|
var tourData = this.tours[tour_name];
|
||||||
|
|
||||||
|
// Retrieve Genius Quiz Data
|
||||||
|
var geniusQuiz = geniusToursData[tour_name];
|
||||||
|
|
||||||
|
// Determine if tour was COMPLETED vs SKIPPED
|
||||||
|
var wasCompleted = false;
|
||||||
|
var wasSkipped = false;
|
||||||
|
|
||||||
|
if (isGeniusTour && tourData) {
|
||||||
|
wasCompleted = !error && tourData.current_step === tourData.steps.length;
|
||||||
|
wasSkipped = !error && tourData.current_step < tourData.steps.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Check test mode BEFORE calling original consume
|
||||||
|
// This prevents Odoo's rainbow flag from showing in test mode
|
||||||
|
var isTestMode = false;
|
||||||
|
if (isGeniusTour) {
|
||||||
|
isTestMode = window.localStorage.getItem('genius_test_mode_' + tour_name) === 'true';
|
||||||
|
|
||||||
|
if (isTestMode) {
|
||||||
|
console.log('[Tour Genius] Test mode - performing manual cleanup to skip Odoo effects');
|
||||||
|
|
||||||
|
// 1. UNCONDITIONAL DELETE: This is critical.
|
||||||
|
// _to_next_step sets the value to undefined but keeps the key.
|
||||||
|
// We must delete the key to prevent Class.update from crashing on tip.hidden.
|
||||||
|
delete tour.active_tooltips[tour_name];
|
||||||
|
|
||||||
|
// 2. Clean localStorage
|
||||||
|
window.localStorage.removeItem('genius_test_mode_' + tour_name);
|
||||||
|
window.localStorage.removeItem('debugging_tour_' + tour_name);
|
||||||
|
// Also clean Odoo's standard keys
|
||||||
|
var tourKey = tour_name; // tour_name is correct key
|
||||||
|
// Odoo uses helper functions for keys, but standard is:
|
||||||
|
// 'tour_current_step' -> not used in registered tours
|
||||||
|
// 'tour_step_NAME' -> likely key format for some versions, or just rely on reset
|
||||||
|
|
||||||
|
// 3. Reset tour state
|
||||||
|
if (tourData) {
|
||||||
|
tourData.current_step = 0;
|
||||||
|
tourData.ready = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Clear running tour if applicable
|
||||||
|
if (tour.running_tour === tour_name) {
|
||||||
|
tour.running_tour = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Show our celebration
|
||||||
|
try {
|
||||||
|
var GeniusCelebration = require('tour_genius.GeniusCelebration');
|
||||||
|
new GeniusCelebration({
|
||||||
|
message: _t('<div style="text-align: center;"><strong>✨ Test Run Successful! ✨</strong><br/><span style="font-size: 14px; display: block; margin-top: 8px;">The tour functioned perfectly.</span><span style="font-size: 13px; font-weight: normal; opacity: 0.8; display: block; margin-top: 4px;">(Simulation Mode: No progress recorded)</span></div>'),
|
||||||
|
fadeout: 'slow', // Slow fadeout to give time to read
|
||||||
|
quiz: null,
|
||||||
|
}).appendTo($('body'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Tour Genius] Error showing test celebration:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return; // Exit early! Do NOT call originalConsumeTour
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call original only for non-test mode (this resets current_step to 0)
|
||||||
|
var result = originalConsumeTour(tour_name, error);
|
||||||
|
|
||||||
|
// Handle genius tour outcomes (only for non-test mode)
|
||||||
|
if (isGeniusTour) {
|
||||||
|
var tourId = parseInt(tour_name.replace('genius_tour_', ''));
|
||||||
|
|
||||||
|
if (wasCompleted) {
|
||||||
|
// Show quiz button if quiz has a valid ID
|
||||||
|
var hasQuiz = geniusQuiz && geniusQuiz.id && geniusQuiz.id !== false;
|
||||||
|
|
||||||
|
console.log('[Tour Genius] Completed tour:', tourId, 'Has Quiz:', hasQuiz);
|
||||||
|
|
||||||
|
// Mark as consumed
|
||||||
|
rpc.query({
|
||||||
|
model: 'genius.topic',
|
||||||
|
method: 'action_mark_consumed',
|
||||||
|
args: [[tourId]],
|
||||||
|
}).catch(function() {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
var GeniusCelebration = require('tour_genius.GeniusCelebration');
|
||||||
|
new GeniusCelebration({
|
||||||
|
message: _t('<strong>Well Done!</strong> You completed the tour!'),
|
||||||
|
fadeout: hasQuiz ? 'no' : 'medium',
|
||||||
|
quiz: hasQuiz ? geniusQuiz : null,
|
||||||
|
}).appendTo($('body'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Tour Genius] Error showing celebration:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (wasSkipped) {
|
||||||
|
rpc.query({
|
||||||
|
model: 'genius.topic',
|
||||||
|
method: 'action_mark_skipped',
|
||||||
|
args: [[tourId]],
|
||||||
|
}).catch(function() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
tour._consume_tour_patched = true;
|
||||||
|
console.log('[Tour Genius] Patched _consume_tour for GeniusCelebration');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch Odoo's _activate_tip to use GeniusTip for genius tours
|
||||||
|
* This enables our custom tooltip with spotlight and always-visible content
|
||||||
|
*/
|
||||||
|
function patchActivateTip() {
|
||||||
|
if (tour._activate_tip_patched) return; // Already patched
|
||||||
|
|
||||||
|
var originalActivateTip = tour._activate_tip.bind(tour);
|
||||||
|
var GeniusTip = null;
|
||||||
|
|
||||||
|
// Lazy load GeniusTip to avoid circular dependencies
|
||||||
|
try {
|
||||||
|
GeniusTip = require('tour_genius.GeniusTip');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Tour Genius] GeniusTip not available, using standard tooltips');
|
||||||
|
}
|
||||||
|
|
||||||
|
tour._activate_tip = function(tip, tour_name, $anchor, $alt_trigger) {
|
||||||
|
var isGeniusTour = tour_name.startsWith('genius_tour_');
|
||||||
|
|
||||||
|
// For non-genius tours, use original behavior
|
||||||
|
if (!isGeniusTour || !GeniusTip) {
|
||||||
|
return originalActivateTip(tip, tour_name, $anchor, $alt_trigger);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For genius tours, use GeniusTip widget
|
||||||
|
var tourData = this.tours[tour_name];
|
||||||
|
var tip_info = tip;
|
||||||
|
|
||||||
|
// Add skip link functionality
|
||||||
|
if (tourData && tourData.skip_link) {
|
||||||
|
tip_info = _.extend(_.omit(tip_info, 'content'), {
|
||||||
|
content: tip.content + tourData.skip_link,
|
||||||
|
event_handlers: [{
|
||||||
|
event: 'click',
|
||||||
|
selector: '.o_skip_tour',
|
||||||
|
handler: tourData.skip_handler.bind(this, tip),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhance tip_info with Genius-specific data
|
||||||
|
var stepIndex = tourData ? tourData.current_step : 0;
|
||||||
|
var totalSteps = tourData && tourData.steps ? tourData.steps.length : 1;
|
||||||
|
var stepTitle = tip.geniusTitle || tip.title || _t("Step");
|
||||||
|
|
||||||
|
tip_info = _.extend({}, tip_info, {
|
||||||
|
geniusStepIndex: stepIndex,
|
||||||
|
geniusTotalSteps: totalSteps,
|
||||||
|
geniusTitle: stepTitle,
|
||||||
|
geniusTourName: tour_name,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create GeniusTip widget instead of standard Tip
|
||||||
|
tip.widget = new GeniusTip(this, tip_info);
|
||||||
|
|
||||||
|
// Handle tip_consumed event (standard behavior)
|
||||||
|
if (this.running_tour !== tour_name) {
|
||||||
|
tip.widget.on('tip_consumed', this, this._consume_tip.bind(this, tip, tour_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle genius_skip_tour event
|
||||||
|
tip.widget.on('genius_skip_tour', this, function(data) {
|
||||||
|
console.log('[Tour Genius] Skip tour triggered:', data.tourName);
|
||||||
|
this._deactivate_tip(tip);
|
||||||
|
this._consume_tour(data.tourName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach to anchor
|
||||||
|
tip.widget.attach_to($anchor, $alt_trigger).then(
|
||||||
|
this._to_next_running_step.bind(this, tip, tour_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('[Tour Genius] Activated GeniusTip for step', stepIndex + 1, 'of', totalSteps);
|
||||||
|
};
|
||||||
|
|
||||||
|
tour._activate_tip_patched = true;
|
||||||
|
console.log('[Tour Genius] Patched _activate_tip for GeniusTip');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivate ALL non-genius tours to prevent them from showing
|
||||||
|
* Called when a genius tour is being activated
|
||||||
|
*/
|
||||||
|
function deactivateNonGeniusTours() {
|
||||||
|
console.log('[Tour Genius] Deactivating non-genius tours...');
|
||||||
|
|
||||||
|
if (!tour.active_tooltips) return;
|
||||||
|
|
||||||
|
var deactivatedCount = 0;
|
||||||
|
|
||||||
|
Object.keys(tour.active_tooltips).forEach(function(tourName) {
|
||||||
|
// Skip genius tours
|
||||||
|
if (tourName.startsWith('genius_tour_')) return;
|
||||||
|
|
||||||
|
var tip = tour.active_tooltips[tourName];
|
||||||
|
|
||||||
|
// Destroy the tooltip widget if it exists
|
||||||
|
if (tip && tip.widget && tip.widget.destroy) {
|
||||||
|
try {
|
||||||
|
tip.widget.destroy();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Tour Genius] Error destroying tip widget:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from active_tooltips
|
||||||
|
delete tour.active_tooltips[tourName];
|
||||||
|
deactivatedCount++;
|
||||||
|
|
||||||
|
console.log('[Tour Genius] Deactivated tour:', tourName);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Tour Genius] Deactivated', deactivatedCount, 'non-genius tours');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up listener for tour completion to:
|
||||||
|
* 1. Mark tour as consumed via RPC
|
||||||
|
* 2. Redirect to dashboard after a delay
|
||||||
|
*/
|
||||||
|
function setupTourCompletionListener(registeredTours) {
|
||||||
|
var localStorage = require('web.local_storage');
|
||||||
|
var dashboardAction = 581; // Tour Genius Dashboard action ID
|
||||||
|
|
||||||
|
// Check periodically if any genius tour was completed
|
||||||
|
var checkInterval = setInterval(function() {
|
||||||
|
registeredTours.forEach(function(tourName) {
|
||||||
|
if (!tourName.startsWith('genius_tour_')) return;
|
||||||
|
|
||||||
|
var registeredTour = tour.tours[tourName];
|
||||||
|
if (!registeredTour) return;
|
||||||
|
|
||||||
|
// Tour is complete when current_step >= steps.length and debugging flag is gone
|
||||||
|
var debuggingKey = 'debugging_tour_' + tourName;
|
||||||
|
var wasDebugging = localStorage.getItem(debuggingKey + '_was_active');
|
||||||
|
var isNowDebugging = localStorage.getItem(debuggingKey);
|
||||||
|
|
||||||
|
// Detect when debugging flag gets cleared (tour consumed)
|
||||||
|
if (wasDebugging && !isNowDebugging) {
|
||||||
|
localStorage.removeItem(debuggingKey + '_was_active');
|
||||||
|
|
||||||
|
// NOTE: Tour completion/skip handling is done in patchConsumeTour
|
||||||
|
// - Completion shows GeniusCelebration and marks consumed
|
||||||
|
// - Skip just marks as skipped (no celebration)
|
||||||
|
console.log('[Tour Genius] Tour consumed (completion listener):', tourName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track that debugging is active
|
||||||
|
if (isNowDebugging) {
|
||||||
|
localStorage.setItem(debuggingKey + '_was_active', 'true');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// Clean up after 5 minutes (tours shouldn't take longer)
|
||||||
|
setTimeout(function() {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on DOM ready with delay for Odoo to fully load
|
||||||
|
$(function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
registerAllTours();
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export for external use
|
||||||
|
return {
|
||||||
|
registerAllTours: registerAllTours,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,235 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates id="template" xml:space="preserve">
|
||||||
|
|
||||||
|
<!-- Main Dashboard Template -->
|
||||||
|
<t t-name="tour_genius.Dashboard">
|
||||||
|
<div class="o_genius_dashboard">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="o_genius_dashboard_header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1 class="dashboard-title">
|
||||||
|
<span class="title-icon"><i class="fa fa-lightbulb-o"/></span>
|
||||||
|
Tour Genius
|
||||||
|
</h1>
|
||||||
|
<p class="dashboard-subtitle">Interactive Training Platform</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<div class="search-box">
|
||||||
|
<i class="fa fa-search search-icon"/>
|
||||||
|
<input type="text" class="search-input" placeholder="Search tours..."/>
|
||||||
|
</div>
|
||||||
|
<t t-if="widget.isAdmin">
|
||||||
|
<button class="btn btn-primary btn-new-tour">
|
||||||
|
<i class="fa fa-plus"/>
|
||||||
|
New Tour
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard Content -->
|
||||||
|
<div class="o_genius_dashboard_content">
|
||||||
|
<!-- Will be rendered by DashboardContent template -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Dashboard Content Template -->
|
||||||
|
<t t-name="tour_genius.DashboardContent">
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat-card" data-filter="all">
|
||||||
|
<div class="stat-icon"><i class="fa fa-bar-chart"/></div>
|
||||||
|
<div class="stat-value"><t t-esc="stats.total_tours or 0"/></div>
|
||||||
|
<div class="stat-label">Total Tours</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card" data-filter="completed">
|
||||||
|
<div class="stat-icon"><i class="fa fa-check-circle text-success"/></div>
|
||||||
|
<div class="stat-value"><t t-esc="stats.completed or 0"/></div>
|
||||||
|
<div class="stat-label">Completed</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card" data-filter="verified">
|
||||||
|
<div class="stat-icon"><i class="fa fa-certificate text-primary"/></div>
|
||||||
|
<div class="stat-value"><t t-esc="stats.certified or 0"/></div>
|
||||||
|
<div class="stat-label">Certified</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card" data-filter="in_progress">
|
||||||
|
<div class="stat-icon"><i class="fa fa-clock-o text-warning"/></div>
|
||||||
|
<div class="stat-value"><t t-esc="stats.in_progress or 0"/></div>
|
||||||
|
<div class="stat-label">In Progress</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card progress-card" data-filter="progress">
|
||||||
|
<div class="stat-icon"><i class="fa fa-line-chart"/></div>
|
||||||
|
<div class="stat-value"><t t-esc="Math.round(userProgress)"/>%</div>
|
||||||
|
<div class="stat-label">Your Progress</div>
|
||||||
|
<div class="progress-bar-mini">
|
||||||
|
<div class="progress-fill" t-attf-style="width: #{userProgress}%"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tours Grid -->
|
||||||
|
<div class="section-title">
|
||||||
|
<h2>Available Tours</h2>
|
||||||
|
<span class="tour-count">(<t t-esc="tours.length"/> tours)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tours-grid">
|
||||||
|
<t t-foreach="tours" t-as="tour">
|
||||||
|
<div class="tour-card" t-att-data-tour-id="tour.id" t-att-data-status="tour.user_status || 'new'" t-att-data-module="tour.module_name">
|
||||||
|
<!-- Tour Header (Icon + Info) -->
|
||||||
|
<div class="tour-card-header">
|
||||||
|
<!-- Tour Icon -->
|
||||||
|
<div class="tour-icon">
|
||||||
|
<i t-attf-class="fa #{tour.icon or 'fa-graduation-cap'}"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tour Info -->
|
||||||
|
<div class="tour-info">
|
||||||
|
<h3 class="tour-name"><t t-esc="tour.name"/></h3>
|
||||||
|
<div class="tour-meta">
|
||||||
|
<span class="tour-module">
|
||||||
|
<i class="fa fa-cube"/> <t t-esc="tour.module_name or 'General'"/>
|
||||||
|
</span>
|
||||||
|
<span class="tour-steps">
|
||||||
|
<i class="fa fa-list-ol"/> <t t-esc="tour.step_count"/> steps
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ALL USERS: Personal status badge below module name -->
|
||||||
|
<t t-if="tour.user_status and tour.user_status != 'new'">
|
||||||
|
<div t-attf-class="tour-status-inline status-#{tour.user_status}">
|
||||||
|
<t t-if="tour.user_status == 'verified'"><i class="fa fa-certificate"/> Certified</t>
|
||||||
|
<t t-if="tour.user_status == 'completed'"><i class="fa fa-check"/> Completed</t>
|
||||||
|
<t t-if="tour.user_status == 'in_progress'"><i class="fa fa-clock-o"/> In Progress</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<t t-if="tour.tags and tour.tags.length">
|
||||||
|
<div class="tour-tags">
|
||||||
|
<t t-foreach="tour.tags" t-as="tag">
|
||||||
|
<span class="tour-tag" t-att-style="'background-color: ' + (tag.color || '#6c757d')">
|
||||||
|
<t t-esc="tag.name"/>
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ADMIN ONLY: Shows tour state (Draft) + certified count in CORNER -->
|
||||||
|
<t t-if="isAdmin">
|
||||||
|
<t t-if="tour.state == 'draft'">
|
||||||
|
<div class="tour-status status-draft">
|
||||||
|
<i class="fa fa-pencil"/> Draft
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<!-- Show certified count for published tours (only if > 1 OR admin not personally certified) -->
|
||||||
|
<t t-if="tour.state == 'published' and tour.verified_count > 0 and (tour.verified_count > 1 or tour.user_status != 'verified')">
|
||||||
|
<div class="tour-status status-verified-count">
|
||||||
|
<i class="fa fa-users"/> <t t-esc="tour.verified_count"/> certified
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="tour-actions">
|
||||||
|
<!-- Run Tour Button (only for published tours) -->
|
||||||
|
<t t-if="tour.state == 'published'">
|
||||||
|
<button class="btn btn-success btn-run-tour" t-att-data-tour-id="tour.id" title="Run Tour">
|
||||||
|
<i class="fa fa-play"/>
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Quiz Button (conditional) - ONLY for published tours -->
|
||||||
|
<t t-if="tour.quiz_id and tour.state == 'published'">
|
||||||
|
<!-- Perfect Score: Mastered Badge (no button) -->
|
||||||
|
<t t-if="tour.is_perfect">
|
||||||
|
<span class="btn btn-quiz btn-quiz-mastered" title="Perfect Score!">
|
||||||
|
<i class="fa fa-star"/> <span class="quiz-btn-text">100%</span>
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Passed but not perfect: Improve button -->
|
||||||
|
<t t-elif="tour.quiz_status == 'passed' and tour.can_retry">
|
||||||
|
<button class="btn btn-quiz btn-quiz-improve"
|
||||||
|
t-att-data-tour-id="tour.id"
|
||||||
|
t-att-title="'Improve your score (' + tour.quiz_score + '%)'">
|
||||||
|
<i class="fa fa-arrow-up"/> <span class="quiz-btn-text"><t t-esc="tour.quiz_score"/>%</span>
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Passed, no more attempts: View Certificate (PDF) -->
|
||||||
|
<t t-elif="tour.quiz_status == 'passed' and !tour.can_retry">
|
||||||
|
<a class="btn btn-quiz btn-quiz-certificate"
|
||||||
|
t-attf-href="/tour_genius/certificate/view/#{tour.certificate_attempt_id}"
|
||||||
|
target="_blank"
|
||||||
|
title="View Certificate">
|
||||||
|
<i class="fa fa-trophy"/> <span class="quiz-btn-text"><t t-esc="tour.quiz_score"/>%</span>
|
||||||
|
</a>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Failed but can retry -->
|
||||||
|
<t t-elif="tour.quiz_status == 'failed' and tour.can_retry">
|
||||||
|
<button class="btn btn-quiz btn-quiz-retry"
|
||||||
|
t-att-data-tour-id="tour.id"
|
||||||
|
t-att-title="'Retry Quiz (' + (tour.attempts_remaining == -1 ? '∞' : tour.attempts_remaining) + ' left)'">
|
||||||
|
<i class="fa fa-refresh"/> <span class="quiz-btn-text">Retry</span>
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Failed and max attempts reached -->
|
||||||
|
<t t-elif="tour.quiz_status == 'failed' and !tour.can_retry">
|
||||||
|
<button class="btn btn-quiz btn-quiz-disabled" disabled="disabled" title="Max attempts reached">
|
||||||
|
<i class="fa fa-ban"/>
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Not attempted yet -->
|
||||||
|
<t t-elif="tour.quiz_status == 'not_attempted'">
|
||||||
|
<button class="btn btn-quiz btn-quiz-start"
|
||||||
|
t-att-data-tour-id="tour.id"
|
||||||
|
title="Take Quiz">
|
||||||
|
<i class="fa fa-graduation-cap"/> <span class="quiz-btn-text">Quiz</span>
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Download Certificate Button (for any passed quiz) - Only for published tours -->
|
||||||
|
<t t-if="tour.has_certificate and tour.certificate_attempt_id and tour.state == 'published'">
|
||||||
|
<a class="btn btn-quiz btn-quiz-download"
|
||||||
|
t-attf-href="/tour_genius/certificate/download/#{tour.certificate_attempt_id}"
|
||||||
|
target="_blank"
|
||||||
|
title="Download Certificate">
|
||||||
|
<i class="fa fa-download"/>
|
||||||
|
</a>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Admin Actions -->
|
||||||
|
<t t-if="isAdmin">
|
||||||
|
<button class="btn btn-secondary btn-edit-tour" t-att-data-tour-id="tour.id" title="Edit">
|
||||||
|
<i class="fa fa-edit"/>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger btn-delete-tour" t-att-data-tour-id="tour.id" title="Delete">
|
||||||
|
<i class="fa fa-trash"/>
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<t t-if="!tours or tours.length === 0">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon"><i class="fa fa-inbox fa-3x text-muted"/></div>
|
||||||
|
<h3>No Tours Available</h3>
|
||||||
|
<p t-if="isAdmin">Create your first tour by clicking the "New Tour" button above.</p>
|
||||||
|
<p t-else="">No training tours are available for your modules yet.</p>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<!-- Genius Celebration - Unique lightbulb moment effect -->
|
||||||
|
<t t-name="tour_genius.genius_celebration_v2">
|
||||||
|
<div class="o_genius_reward">
|
||||||
|
<!-- Particle explosion background -->
|
||||||
|
<div class="o_genius_particles">
|
||||||
|
<span class="particle p1"/>
|
||||||
|
<span class="particle p2"/>
|
||||||
|
<span class="particle p3"/>
|
||||||
|
<span class="particle p4"/>
|
||||||
|
<span class="particle p5"/>
|
||||||
|
<span class="particle p6"/>
|
||||||
|
<span class="particle p7"/>
|
||||||
|
<span class="particle p8"/>
|
||||||
|
<span class="particle p9"/>
|
||||||
|
<span class="particle p10"/>
|
||||||
|
<span class="particle p11"/>
|
||||||
|
<span class="particle p12"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Central celebration container -->
|
||||||
|
<div class="o_genius_center">
|
||||||
|
<!-- Glowing lightbulb icon -->
|
||||||
|
<div class="o_genius_bulb_container">
|
||||||
|
<div class="o_genius_bulb_rays"/>
|
||||||
|
<div class="o_genius_bulb">
|
||||||
|
<i class="fa fa-lightbulb-o"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_genius_bulb_pulse"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success badge -->
|
||||||
|
<div class="o_genius_badge">
|
||||||
|
<i class="fa fa-check"/>
|
||||||
|
<span>GENIUS!</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message card -->
|
||||||
|
<div class="o_genius_card">
|
||||||
|
<!-- Close button -->
|
||||||
|
<button class="o_genius_close_btn" title="Close" style="position: absolute; top: 8px; right: 8px; background: none; border: none; font-size: 18px; color: #999; cursor: pointer; z-index: 10; padding: 5px 10px;">
|
||||||
|
<i class="fa fa-times"/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="o_genius_card_header">
|
||||||
|
<span class="o_genius_emoji">🎉</span>
|
||||||
|
<span class="o_genius_title">Tour Completed!</span>
|
||||||
|
<span class="o_genius_emoji">🎉</span>
|
||||||
|
</div>
|
||||||
|
<div class="o_genius_msg_content"/>
|
||||||
|
|
||||||
|
<!-- QUIZ ACTIONS -->
|
||||||
|
<!-- ACTIONS SECTION: Flattened Logic for Reliability -->
|
||||||
|
<div class="o_genius_actions" t-if="widget.options.quiz">
|
||||||
|
|
||||||
|
<!-- 1. PASSED -->
|
||||||
|
<t t-if="widget.options.quiz.status == 'passed'">
|
||||||
|
<div class="o_genius_certified" style="margin-top: 15px; text-align: center;">
|
||||||
|
<div style="color: #28a745; font-weight: bold; font-size: 1.3em; margin-bottom: 8px;">
|
||||||
|
<i class="fa fa-certificate" style="font-size: 1.5em;"/> Certified Genius!
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.9em; color: #666; margin-bottom: 10px;">
|
||||||
|
Score: <strong><t t-esc="Math.round(widget.options.quiz.score || 0)"/>%</strong>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-success o_view_certificate_btn" style="border-radius: 20px; font-weight: bold; padding: 8px 20px;">
|
||||||
|
<i class="fa fa-trophy"/> View Certificate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- 2. FAILED -->
|
||||||
|
<t t-if="widget.options.quiz.status == 'failed'">
|
||||||
|
<div style="text-align: center; margin-top: 15px;">
|
||||||
|
<div style="margin-bottom: 8px; color: #dc3545; font-weight: 600;">
|
||||||
|
Quiz Failed (<t t-esc="Math.round(widget.options.quiz.score || 0)"/>%)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<t t-if="widget.options.quiz.attempts_remaining == -1 or widget.options.quiz.attempts_remaining > 0">
|
||||||
|
<button class="btn btn-warning o_take_quiz_btn" style="border-radius: 20px; font-weight: bold; padding: 10px 24px;">
|
||||||
|
<i class="fa fa-refresh"/> Retry Quiz
|
||||||
|
</button>
|
||||||
|
<div t-if="widget.options.quiz.max_attempts > 0" style="font-size: 0.85em; color: #666; margin-top: 6px;">
|
||||||
|
<t t-esc="widget.options.quiz.attempts_remaining"/> attempts remaining
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<button class="btn btn-secondary" disabled="disabled" style="border-radius: 20px; padding: 10px 24px; cursor: not-allowed;">
|
||||||
|
<i class="fa fa-ban"/> Max Attempts Reached
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- 3. NOT ATTEMPTED (or incomplete) -->
|
||||||
|
<t t-if="widget.options.quiz.status != 'passed' and widget.options.quiz.status != 'failed'">
|
||||||
|
<div style="text-align: center; margin-top: 15px;">
|
||||||
|
<t t-if="widget.options.quiz.attempts_remaining == 0">
|
||||||
|
<button class="btn btn-secondary" disabled="disabled" style="border-radius: 20px; padding: 10px 24px; cursor: not-allowed;">
|
||||||
|
<i class="fa fa-ban"/> No Attempts Available
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<button class="btn btn-primary o_take_quiz_btn" style="border-radius: 20px; font-weight: bold; padding: 12px 28px; box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); font-size: 1.1em;">
|
||||||
|
<i class="fa fa-graduation-cap"/> Take Quiz Now
|
||||||
|
</button>
|
||||||
|
<div t-if="widget.options.quiz.max_attempts > 0" style="font-size: 0.85em; color: #666; margin-top: 6px;">
|
||||||
|
<t t-esc="widget.options.quiz.max_attempts"/> attempts allowed
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_genius_footer">
|
||||||
|
<span class="o_genius_stars">⭐⭐⭐</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Floating icons -->
|
||||||
|
<div class="o_genius_floating">
|
||||||
|
<i class="floating-icon fi1 fa fa-star"/>
|
||||||
|
<i class="floating-icon fi2 fa fa-trophy"/>
|
||||||
|
<i class="floating-icon fi3 fa fa-thumbs-up"/>
|
||||||
|
<i class="floating-icon fi4 fa fa-star"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,325 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<!-- GENIUS QUIZ POPUP WIDGET -->
|
||||||
|
<t t-name="tour_genius.GeniusQuizPopup_v2">
|
||||||
|
<div class="o_genius_quiz_popup">
|
||||||
|
<div class="o_quiz_overlay"/>
|
||||||
|
<div class="o_quiz_modal">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="o_quiz_header">
|
||||||
|
<div class="o_quiz_title_section">
|
||||||
|
<i class="fa fa-graduation-cap o_quiz_icon"/>
|
||||||
|
<span class="o_quiz_title"><t t-esc="widget.quizName"/></span>
|
||||||
|
</div>
|
||||||
|
<div class="o_quiz_header_right">
|
||||||
|
<div class="o_quiz_timer">
|
||||||
|
<i class="fa fa-clock-o"/>
|
||||||
|
<span class="o_quiz_timer_value">00:00</span>
|
||||||
|
</div>
|
||||||
|
<button class="o_quiz_close" title="Close">
|
||||||
|
<i class="fa fa-times"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Bar -->
|
||||||
|
<div class="o_quiz_progress">
|
||||||
|
<div class="o_quiz_progress_bar">
|
||||||
|
<div class="o_quiz_progress_fill"/>
|
||||||
|
</div>
|
||||||
|
<span class="o_quiz_progress_text">1 / ?</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="o_quiz_body">
|
||||||
|
<t t-call="tour_genius.QuizBody"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Quiz Body Template (Reusable for Retry) -->
|
||||||
|
<t t-name="tour_genius.QuizBody">
|
||||||
|
<div class="o_quiz_content">
|
||||||
|
<div class="o_quiz_question_container"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<div class="o_quiz_navigation">
|
||||||
|
<button class="btn o_quiz_prev" disabled="disabled">
|
||||||
|
<i class="fa fa-chevron-left"/> Previous
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary o_quiz_next">
|
||||||
|
Next <i class="fa fa-chevron-right"/>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-success o_quiz_submit" style="display: none;">
|
||||||
|
<i class="fa fa-check"/> Submit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Individual Question Template -->
|
||||||
|
<t t-name="tour_genius.QuizQuestion">
|
||||||
|
<div class="o_quiz_question">
|
||||||
|
<!-- Question Number Badge -->
|
||||||
|
<div class="o_question_badge">
|
||||||
|
Question <t t-esc="index + 1"/> of <t t-esc="total"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Question Text -->
|
||||||
|
<div class="o_question_text">
|
||||||
|
<t t-esc="question.text"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Question Image (if any) -->
|
||||||
|
<t t-if="question.image">
|
||||||
|
<div class="o_question_image">
|
||||||
|
<img t-att-src="'data:image/png;base64,' + question.image" alt="Question Image"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Answer Options Based on Type -->
|
||||||
|
|
||||||
|
<!-- Short Answer / Fill in the Blank -->
|
||||||
|
<t t-if="question.type === 'short_answer' || question.type === 'fill_blank'">
|
||||||
|
<div class="o_short_answer_container">
|
||||||
|
<input type="text" class="o_short_answer_input"
|
||||||
|
t-att-placeholder="question.type === 'fill_blank' ? 'Fill in the blank...' : 'Type your answer here...'"
|
||||||
|
t-att-value="response.textAnswer"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Ordering (Drag and Drop List) -->
|
||||||
|
<t t-elif="question.type === 'ordering'">
|
||||||
|
<div class="o_ordering_container" data-question-id="question.id">
|
||||||
|
<div class="o_ordering_items">
|
||||||
|
<t t-foreach="question.answers" t-as="answer">
|
||||||
|
<div class="o_ordering_item"
|
||||||
|
t-att-data-answer-id="answer.id"
|
||||||
|
draggable="true">
|
||||||
|
<span class="o_ordering_handle">
|
||||||
|
<i class="fa fa-bars"/>
|
||||||
|
</span>
|
||||||
|
<span class="o_ordering_number" t-esc="answer_index + 1"/>
|
||||||
|
<span class="o_ordering_text" t-esc="answer.text"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Single Choice / Multiple Choice -->
|
||||||
|
<t t-else="">
|
||||||
|
<div class="o_answers_grid">
|
||||||
|
<t t-foreach="question.answers" t-as="answer">
|
||||||
|
<div class="o_answer_option"
|
||||||
|
t-att-data-answer-id="answer.id"
|
||||||
|
t-att-class="response.selectedIds.indexOf(answer.id) >= 0 ? 'selected' : ''">
|
||||||
|
<div class="o_answer_indicator">
|
||||||
|
<t t-if="question.type === 'multiple'">
|
||||||
|
<i t-att-class="response.selectedIds.indexOf(answer.id) >= 0 ? 'fa fa-check-square' : 'fa fa-square-o'"/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<i t-att-class="response.selectedIds.indexOf(answer.id) >= 0 ? 'fa fa-dot-circle-o' : 'fa fa-circle-o'"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div class="o_answer_text">
|
||||||
|
<t t-esc="answer.text"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Question Type Hint -->
|
||||||
|
<div class="o_question_hint">
|
||||||
|
<t t-if="question.type === 'multiple'">
|
||||||
|
<i class="fa fa-info-circle"/> Select all that apply
|
||||||
|
</t>
|
||||||
|
<t t-elif="question.type === 'ordering'">
|
||||||
|
<i class="fa fa-info-circle"/> Drag items to arrange in correct order
|
||||||
|
</t>
|
||||||
|
<t t-elif="question.type === 'fill_blank'">
|
||||||
|
<i class="fa fa-info-circle"/> Fill in the blank
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Quiz Results Template -->
|
||||||
|
<t t-name="tour_genius.QuizResults_v2">
|
||||||
|
<div class="o_quiz_results">
|
||||||
|
<!-- Score Display with Integrated Trophy -->
|
||||||
|
<div class="o_results_score_container">
|
||||||
|
<div t-att-class="'o_results_circle ' + (results.is_passed ? 'passed' : 'failed')">
|
||||||
|
<t t-if="results.is_passed">
|
||||||
|
<i class="fa fa-trophy o_circle_trophy"/>
|
||||||
|
</t>
|
||||||
|
<div class="o_results_percentage">
|
||||||
|
<span class="o_score_value"><t t-esc="Math.round(results.score)"/></span><span class="o_score_percent">%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Message -->
|
||||||
|
<div class="o_results_status">
|
||||||
|
<t t-if="results.is_passed">
|
||||||
|
<div class="o_status_passed">
|
||||||
|
<!-- Trophy moved to circle -->
|
||||||
|
<h3>Excellent Work!</h3>
|
||||||
|
<!-- Custom success message or default -->
|
||||||
|
<t t-if="results.success_message">
|
||||||
|
<div class="o_custom_message" t-raw="results.success_message"/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<p>You passed the <strong><t t-esc="quizName"/></strong></p>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<!-- GENIUS UNIFIED FEEDBACK CARD for Failed State -->
|
||||||
|
<div class="genius-feedback-card">
|
||||||
|
<!-- Header: Keep Learning + passing score on same line -->
|
||||||
|
<div class="feedback-header">
|
||||||
|
<i class="fa fa-refresh feedback-icon"/>
|
||||||
|
<span class="feedback-title">Keep Learning!</span>
|
||||||
|
<span class="feedback-separator">•</span>
|
||||||
|
<span class="feedback-requirement">
|
||||||
|
<t t-if="results.fail_message">
|
||||||
|
<span t-raw="results.fail_message"/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
Need <strong><t t-esc="passingScore"/>%</strong>
|
||||||
|
</t>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attempts Status (only if limited attempts) -->
|
||||||
|
<t t-if="results.max_attempts > 0">
|
||||||
|
<div class="feedback-attempts">
|
||||||
|
<!-- Visual Dots -->
|
||||||
|
<div class="attempts-dots">
|
||||||
|
<t t-foreach="results.max_attempts" t-as="i">
|
||||||
|
<span t-att-class="'attempt-dot ' + (i < (results.max_attempts - results.attempts_remaining) ? 'used' : 'available')"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dynamic Message -->
|
||||||
|
<t t-if="results.attempts_remaining > 0">
|
||||||
|
<div t-att-class="'attempts-message ' + (results.attempts_remaining === 1 ? 'last-chance' : '')">
|
||||||
|
<i class="fa fa-heart"/>
|
||||||
|
<t t-if="results.attempts_remaining === 1">
|
||||||
|
<span>Last chance! Make it count.</span>
|
||||||
|
</t>
|
||||||
|
<t t-elif="results.attempts_remaining === 2">
|
||||||
|
<span>2 attempts left</span>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<span><t t-esc="results.attempts_remaining"/> attempts remaining</span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<div class="attempts-message exhausted">
|
||||||
|
<i class="fa fa-times-circle"/>
|
||||||
|
<span>No attempts remaining</span>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<!-- Unlimited attempts: encouraging message -->
|
||||||
|
<t t-else="">
|
||||||
|
<div class="feedback-unlimited">
|
||||||
|
<i class="fa fa-infinity"/> You can try again anytime!
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="o_results_stats">
|
||||||
|
<div class="genius_stat_item">
|
||||||
|
<span class="genius_stat_value"><t t-esc="results.points_earned"/></span>
|
||||||
|
<span class="genius_stat_label">Points Earned</span>
|
||||||
|
</div>
|
||||||
|
<div class="genius_stat_item">
|
||||||
|
<span class="genius_stat_value"><t t-esc="results.points_possible"/></span>
|
||||||
|
<span class="genius_stat_label">Total Points</span>
|
||||||
|
</div>
|
||||||
|
<div class="genius_stat_item">
|
||||||
|
<span class="genius_stat_value"><t t-esc="results.time_formatted"/></span>
|
||||||
|
<span class="genius_stat_label">Time Taken</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Review Answers Toggle - Genius Style -->
|
||||||
|
<t t-if="results.show_correct_answers and results.correct_answers">
|
||||||
|
<div class="o_review_toggle_container">
|
||||||
|
<label class="genius-toggle">
|
||||||
|
<input type="checkbox" class="genius-toggle-input o_review_answers_toggle"/>
|
||||||
|
<span class="genius-toggle-slider"></span>
|
||||||
|
<span class="genius-toggle-label">
|
||||||
|
<i class="fa fa-list-ol"/> Show Answer Review
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="o_answer_review_section" style="display: none;">
|
||||||
|
<!-- Will be populated by JS -->
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Actions - Genius Styled and Centered -->
|
||||||
|
<div class="o_results_actions genius-results-actions">
|
||||||
|
<t t-if="results.is_passed">
|
||||||
|
<!-- Download Certificate -->
|
||||||
|
<t t-if="results.attempt_id">
|
||||||
|
<a class="btn genius-btn-download o_quiz_download_pdf"
|
||||||
|
t-attf-href="/tour_genius/certificate/download/#{results.attempt_id}"
|
||||||
|
target="_blank">
|
||||||
|
<i class="fa fa-certificate" style="margin-right: 8px;"></i> View Certificate
|
||||||
|
</a>
|
||||||
|
</t>
|
||||||
|
<button class="btn genius-btn-done o_quiz_close">
|
||||||
|
<i class="fa fa-check"/> Done
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<button class="btn genius-btn-retry o_quiz_retry" t-if="results.can_retry">
|
||||||
|
<i class="fa fa-refresh"/> Try Again
|
||||||
|
</button>
|
||||||
|
<button class="btn genius-btn-close o_quiz_close">
|
||||||
|
<i class="fa fa-times"/> Close
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Submit Confirmation Overlay -->
|
||||||
|
<t t-name="tour_genius.QuizConfirmSubmit">
|
||||||
|
<div class="o_quiz_confirm_overlay">
|
||||||
|
<div class="o_quiz_confirm_dialog">
|
||||||
|
<div class="o_confirm_icon">
|
||||||
|
<i class="fa fa-exclamation-triangle"/>
|
||||||
|
</div>
|
||||||
|
<h4 class="o_confirm_title">Unanswered Questions</h4>
|
||||||
|
<p class="o_confirm_message">
|
||||||
|
You have <strong><t t-esc="unansweredCount"/></strong> unanswered question<t t-if="unansweredCount > 1">s</t>.
|
||||||
|
<br/>Submit anyway?
|
||||||
|
</p>
|
||||||
|
<div class="o_confirm_actions">
|
||||||
|
<button class="btn o_confirm_no">
|
||||||
|
<i class="fa fa-arrow-left"/> Go Back
|
||||||
|
</button>
|
||||||
|
<button class="btn o_confirm_yes">
|
||||||
|
<i class="fa fa-check"/> Submit Anyway
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates id="template" xml:space="preserve">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
GeniusTip Template - Premium tooltip with glassmorphism design.
|
||||||
|
-->
|
||||||
|
<div t-name="GeniusTip" t-attf-class="o_tooltip genius-tip #{widget.info.position} #{widget.is_anchor_fixed_position ? 'o_tooltip_fixed' : ''}">
|
||||||
|
|
||||||
|
<!-- Odoo overlay for mouse detection -->
|
||||||
|
<div class="o_tooltip_overlay"/>
|
||||||
|
|
||||||
|
<!-- Genius Premium Card -->
|
||||||
|
<div class="genius-tip-card">
|
||||||
|
|
||||||
|
<!-- Step Badge - No icon, just text -->
|
||||||
|
<div class="genius-tip-step-badge">
|
||||||
|
<span>Step <t t-esc="widget.geniusStepIndex + 1"/> of <t t-esc="widget.geniusTotalSteps"/></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title Row with Lightbulb -->
|
||||||
|
<div class="genius-tip-title-row">
|
||||||
|
<span class="genius-tip-title-icon" style="font-size: 22px;">💡</span>
|
||||||
|
<h3 class="genius-tip-title"><t t-esc="widget.geniusTitle"/></h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="genius-tip-content o_tooltip_content">
|
||||||
|
<t t-raw="widget.info.content"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="genius-tip-actions">
|
||||||
|
<button type="button" class="genius-tip-skip">✕ Skip Tour</button>
|
||||||
|
<button type="button" class="genius-tip-next">GOT IT! →</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates id="template" xml:space="preserve">
|
||||||
|
|
||||||
|
<!-- Recorder Panel Template -->
|
||||||
|
<t t-name="tour_genius.RecorderPanel">
|
||||||
|
<div class="genius-recorder-panel">
|
||||||
|
<!-- Header (Draggable) -->
|
||||||
|
<div class="recorder-header">
|
||||||
|
<h4>
|
||||||
|
<i class="fa fa-video-camera"></i>
|
||||||
|
Recording: <t t-esc="widget.topicName"/>
|
||||||
|
</h4>
|
||||||
|
<button class="recorder-close" title="Close">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="recorder-body">
|
||||||
|
<!-- Inline Feedback Alert -->
|
||||||
|
<div class="recorder-alert alert d-none mb-2" role="alert"></div>
|
||||||
|
|
||||||
|
<!-- Tour Info Section (Visible for New Tours) -->
|
||||||
|
<t t-if="widget.isNewTour">
|
||||||
|
<div class="tour-info-section mb-3 p-2" style="background: #f8f9fa; border-radius: 8px; border: 1px solid #e2e8f0;">
|
||||||
|
<div class="recorder-field mb-2">
|
||||||
|
<label>Tour Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="tour-name-input form-control"
|
||||||
|
placeholder="e.g., Sales Order Training"/>
|
||||||
|
</div>
|
||||||
|
<div class="recorder-field">
|
||||||
|
<label>Target Module (Optional)</label>
|
||||||
|
<input type="text" class="tour-module-input form-control"
|
||||||
|
list="module-datalist"
|
||||||
|
placeholder="Search module..."/>
|
||||||
|
<datalist id="module-datalist" class="tour-module-datalist">
|
||||||
|
</datalist>
|
||||||
|
<input type="hidden" class="tour-module-select"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="my-2"/>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Track On Button -->
|
||||||
|
<div class="recorder-field track-section">
|
||||||
|
<button class="btn btn-block btn-track-on">
|
||||||
|
<i class="fa fa-crosshairs"></i>
|
||||||
|
Track On
|
||||||
|
</button>
|
||||||
|
<small class="text-muted mt-1 d-block">
|
||||||
|
Click to select any element on the page
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CSS Selector (read-only) -->
|
||||||
|
<div class="recorder-field">
|
||||||
|
<label>CSS Selector</label>
|
||||||
|
<input type="text" class="css-selector-input css-selector-display"
|
||||||
|
placeholder="Click Track On, then click an element..."/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step Title -->
|
||||||
|
<div class="recorder-field">
|
||||||
|
<label>Step Title <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="step-title-input"
|
||||||
|
placeholder="e.g., Click the Save button"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Instruction -->
|
||||||
|
<div class="recorder-field">
|
||||||
|
<label>Instructions (optional)</label>
|
||||||
|
<textarea class="step-instruction-input" rows="2"
|
||||||
|
placeholder="Additional help text..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Position & Type -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="recorder-field">
|
||||||
|
<label>Tooltip Position</label>
|
||||||
|
<select class="position-select form-control">
|
||||||
|
<option value="bottom">Bottom</option>
|
||||||
|
<option value="top">Top</option>
|
||||||
|
<option value="left">Left</option>
|
||||||
|
<option value="right">Right</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="recorder-field">
|
||||||
|
<label>Action Type</label>
|
||||||
|
<select class="step-type-select form-control">
|
||||||
|
<option value="click">Click</option>
|
||||||
|
<option value="input">Input Text</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input Value (Hidden by default) -->
|
||||||
|
<div class="recorder-field input-value-field d-none">
|
||||||
|
<label>Text to Enter <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="input-value-input"
|
||||||
|
placeholder="Value to type..."/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save Step Button -->
|
||||||
|
<div class="recorder-actions">
|
||||||
|
<button class="btn btn-save-step">
|
||||||
|
<i class="fa fa-plus"></i>
|
||||||
|
Save Step
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="recorder-footer">
|
||||||
|
<span class="step-counter">0 steps recorded</span>
|
||||||
|
<button class="btn btn-finish">
|
||||||
|
<i class="fa fa-check"></i>
|
||||||
|
Finish
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates id="template" xml:space="preserve">
|
||||||
|
<!-- Systray Icon - Following Odoo official pattern (li as root) -->
|
||||||
|
<t t-name="GeniusSystrayIcon">
|
||||||
|
<li class="o_mail_systray_item genius-systray">
|
||||||
|
<a class="dropdown-toggle o-no-caret" data-toggle="dropdown"
|
||||||
|
data-display="static" aria-expanded="false"
|
||||||
|
title="Tour Genius Training" href="#" role="button">
|
||||||
|
<i class="fa fa-lightbulb-o" role="img" aria-label="Tours"/>
|
||||||
|
<span class="o_notification_counter badge badge-pill"
|
||||||
|
t-att-style="badge_count ? '' : 'display:none;'">
|
||||||
|
<t t-esc="badge_count or 0"/>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<div class="genius-dropdown-menu dropdown-menu dropdown-menu-right" role="menu">
|
||||||
|
<div class="genius-dropdown-header px-3 py-2 border-bottom">
|
||||||
|
<strong>Tour Genius Training</strong>
|
||||||
|
</div>
|
||||||
|
<div class="genius-dropdown-content">
|
||||||
|
<div class="genius-loading" style="display:none;">
|
||||||
|
<i class="fa fa-spinner fa-spin"/> Loading...
|
||||||
|
</div>
|
||||||
|
<div class="genius-tours-list">
|
||||||
|
<!-- Tours will be inserted here -->
|
||||||
|
</div>
|
||||||
|
<div class="genius-empty p-3 text-center" style="display:none;">
|
||||||
|
<p class="text-muted mb-0">No training available for this screen</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="genius-dropdown-footer px-3 py-2 border-top">
|
||||||
|
<a href="#" class="genius-view-all btn btn-sm btn-primary w-100">
|
||||||
|
<i class="fa fa-book"/> View All Training
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-name="GeniusSystrayItem">
|
||||||
|
<div t-attf-class="genius-tour-item #{tour.is_strict_match ? 'highlight-item' : ''}" t-att-data-tour-id="tour.id">
|
||||||
|
<div class="d-flex align-items-center w-100">
|
||||||
|
<!-- Icon -->
|
||||||
|
<div class="genius-tour-icon mr-3">
|
||||||
|
<i t-attf-class="fa #{tour.icon} fa-2x text-primary"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="genius-tour-info flex-grow-1" style="min-width: 0;"> <!-- min-width: 0 is crucial for flex child truncation -->
|
||||||
|
<div class="d-flex align-items-center mb-1">
|
||||||
|
<span class="genius-tour-name font-weight-bold text-dark" t-esc="tour.name"/>
|
||||||
|
<!-- Status Badges - Now strictly next to name -->
|
||||||
|
<span class="d-flex flex-shrink-0 align-items-center">
|
||||||
|
<t t-if="tour.status == 'new'">
|
||||||
|
<span class="badge badge-pill badge-info text-uppercase ml-2" style="font-size: 0.6rem; padding: 2px 6px;">New</span>
|
||||||
|
</t>
|
||||||
|
<t t-if="tour.status == 'verified'">
|
||||||
|
<span class="badge badge-pill badge-warning text-uppercase ml-2" style="font-size: 0.6rem; padding: 2px 6px;" title="Certified Genius"><i class="fa fa-star"/> Mastered</span>
|
||||||
|
</t>
|
||||||
|
<t t-if="tour.is_strict_match and tour.status == 'new'">
|
||||||
|
<span class="badge badge-pill badge-danger text-uppercase ml-2" style="font-size: 0.6rem; padding: 2px 6px;"><i class="fa fa-thumbs-up"/> Top Pick</span>
|
||||||
|
</t>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<span class="genius-tour-meta text-muted small">
|
||||||
|
<i class="fa fa-clock-o"/> <t t-esc="tour.duration_minutes"/>m • <t t-esc="tour.step_count"/> steps
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Bar (Only for In Progress) -->
|
||||||
|
<t t-if="tour.status == 'in_progress'">
|
||||||
|
<div class="progress mt-1" style="height: 4px;">
|
||||||
|
<div class="progress-bar bg-primary" role="progressbar" t-att-style="'width: ' + tour.progress + '%;'" aria-valuenow="50" aria-valuemin="0" aria-valuemax="100"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Button - Fixed Width & No Shrink -->
|
||||||
|
<div class="genius-tour-action ml-3 flex-shrink-0">
|
||||||
|
<button t-attf-class="btn btn-sm btn-icon #{tour.status == 'in_progress' ? 'btn-primary' : 'btn-light'} genius-run-tour"
|
||||||
|
t-att-data-tour-id="tour.id"
|
||||||
|
t-att-title="tour.status == 'in_progress' ? 'Resume Tour' : 'Start Tour'">
|
||||||
|
<t t-if="tour.status == 'in_progress'">
|
||||||
|
<i class="fa fa-play"/>
|
||||||
|
</t>
|
||||||
|
<t t-elif="tour.status == 'verified' or tour.status == 'done'">
|
||||||
|
<i class="fa fa-refresh text-muted"/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<i class="fa fa-play text-primary"/>
|
||||||
|
</t>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from . import test_training
|
||||||
|
from . import test_quiz
|
||||||
|
from . import test_security
|
||||||
|
from . import test_advanced
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Advanced Features Tests (Simplified)
|
||||||
|
=====================================
|
||||||
|
Tests for gamification and reminders.
|
||||||
|
Removed tests for deleted models (Recorder, DraftStep, StepTiming, Certificate).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo.tests import common, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'tour_genius')
|
||||||
|
class TestAdvancedFeatures(common.TransactionCase):
|
||||||
|
"""Test cases for Advanced Features (Leaderboard, Reminder)"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(TestAdvancedFeatures, self).setUp()
|
||||||
|
self.Plan = self.env['genius.plan']
|
||||||
|
self.Topic = self.env['genius.topic']
|
||||||
|
self.Leaderboard = self.env['genius.leaderboard']
|
||||||
|
self.Reminder = self.env['genius.reminder']
|
||||||
|
|
||||||
|
# Create test user
|
||||||
|
self.test_user = self.env['res.users'].create({
|
||||||
|
'name': 'Advanced Test User',
|
||||||
|
'login': 'advanced_test_user',
|
||||||
|
'email': 'advanced@test.com',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create base data
|
||||||
|
self.test_plan = self.Plan.create({'name': 'Advanced Test Plan'})
|
||||||
|
self.test_topic = self.Topic.create({
|
||||||
|
'name': 'Advanced Test Topic',
|
||||||
|
'plan_id': self.test_plan.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Leaderboard Tests
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def test_leaderboard_creation(self):
|
||||||
|
"""Test creating leaderboard entry"""
|
||||||
|
entry = self.Leaderboard.create({
|
||||||
|
'user_id': self.test_user.id,
|
||||||
|
'period_type': 'alltime',
|
||||||
|
'points': 100,
|
||||||
|
'topics_completed': 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertTrue(entry.id)
|
||||||
|
self.assertEqual(entry.points, 100)
|
||||||
|
|
||||||
|
def test_leaderboard_get_method(self):
|
||||||
|
"""Test leaderboard get API method"""
|
||||||
|
# Create an entry
|
||||||
|
self.Leaderboard.create({
|
||||||
|
'user_id': self.test_user.id,
|
||||||
|
'period_type': 'alltime',
|
||||||
|
'points': 100,
|
||||||
|
'topics_completed': 5,
|
||||||
|
'rank': 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get leaderboard
|
||||||
|
results = self.Leaderboard.get_leaderboard('alltime', limit=10)
|
||||||
|
self.assertIsInstance(results, list)
|
||||||
|
|
||||||
|
def test_leaderboard_calculate_stats(self):
|
||||||
|
"""Test leaderboard stats calculation"""
|
||||||
|
# Create progress for user
|
||||||
|
self.env['genius.progress'].create({
|
||||||
|
'user_id': self.test_user.id,
|
||||||
|
'topic_id': self.test_topic.id,
|
||||||
|
'state': 'done',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Calculate stats
|
||||||
|
stats = self.Leaderboard.calculate_user_stats(
|
||||||
|
self.test_user.id, 'alltime'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(stats['topics_completed'], 1)
|
||||||
|
self.assertTrue(stats['points'] > 0)
|
||||||
|
|
||||||
|
def test_leaderboard_user_rank(self):
|
||||||
|
"""Test get user rank method"""
|
||||||
|
result = self.Leaderboard.get_user_rank(self.test_user.id, 'alltime')
|
||||||
|
self.assertIsInstance(result, dict)
|
||||||
|
self.assertIn('rank', result)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Reminder Tests
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def test_reminder_creation(self):
|
||||||
|
"""Test creating a reminder"""
|
||||||
|
reminder = self.Reminder.create({
|
||||||
|
'name': 'Test Reminder',
|
||||||
|
'user_id': self.test_user.id,
|
||||||
|
'reminder_type': 'incomplete_topic',
|
||||||
|
'topic_id': self.test_topic.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertTrue(reminder.id)
|
||||||
|
self.assertEqual(reminder.state, 'pending')
|
||||||
|
self.assertTrue(reminder.message_body)
|
||||||
|
|
||||||
|
def test_reminder_cancel(self):
|
||||||
|
"""Test canceling a reminder"""
|
||||||
|
reminder = self.Reminder.create({
|
||||||
|
'name': 'Cancel Test',
|
||||||
|
'user_id': self.test_user.id,
|
||||||
|
'reminder_type': 'streak',
|
||||||
|
})
|
||||||
|
|
||||||
|
reminder.action_cancel()
|
||||||
|
self.assertEqual(reminder.state, 'cancelled')
|
||||||
|
|
||||||
|
def test_reminder_types(self):
|
||||||
|
"""Test different reminder types"""
|
||||||
|
# Test incomplete topic
|
||||||
|
r1 = self.Reminder.create({
|
||||||
|
'user_id': self.test_user.id,
|
||||||
|
'reminder_type': 'incomplete_topic',
|
||||||
|
'topic_id': self.test_topic.id,
|
||||||
|
})
|
||||||
|
self.assertTrue(r1.message_body)
|
||||||
|
|
||||||
|
# Test streak reminder
|
||||||
|
r2 = self.Reminder.create({
|
||||||
|
'user_id': self.test_user.id,
|
||||||
|
'reminder_type': 'streak',
|
||||||
|
})
|
||||||
|
self.assertTrue(r2.message_body)
|
||||||
|
|
||||||
|
# Test custom reminder
|
||||||
|
r3 = self.Reminder.create({
|
||||||
|
'user_id': self.test_user.id,
|
||||||
|
'reminder_type': 'custom',
|
||||||
|
'custom_message': '<p>Custom message here</p>',
|
||||||
|
})
|
||||||
|
self.assertEqual(r3.message_body, '<p>Custom message here</p>')
|
||||||
|
|
||||||
|
def test_reminder_recurring(self):
|
||||||
|
"""Test recurring reminder setting"""
|
||||||
|
reminder = self.Reminder.create({
|
||||||
|
'name': 'Recurring Test',
|
||||||
|
'user_id': self.test_user.id,
|
||||||
|
'reminder_type': 'streak',
|
||||||
|
'is_recurring': True,
|
||||||
|
'recurrence_interval': 7,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertTrue(reminder.is_recurring)
|
||||||
|
self.assertEqual(reminder.recurrence_interval, 7)
|
||||||
|
|
@ -0,0 +1,304 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from odoo.tests import common, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'tour_genius')
|
||||||
|
class TestQuizModels(common.TransactionCase):
|
||||||
|
"""Test cases for Quiz Layer models"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(TestQuizModels, self).setUp()
|
||||||
|
self.Quiz = self.env['genius.quiz']
|
||||||
|
self.Question = self.env['genius.quiz.question']
|
||||||
|
self.Answer = self.env['genius.quiz.answer']
|
||||||
|
self.Attempt = self.env['genius.quiz.attempt']
|
||||||
|
self.Response = self.env['genius.quiz.response']
|
||||||
|
|
||||||
|
# Create test user
|
||||||
|
self.test_user = self.env['res.users'].create({
|
||||||
|
'name': 'Test Quiz User',
|
||||||
|
'login': 'test_quiz_user',
|
||||||
|
'email': 'quizuser@test.com',
|
||||||
|
})
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Quiz Tests
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def test_quiz_creation(self):
|
||||||
|
"""Test creating a quiz"""
|
||||||
|
quiz = self.Quiz.create({
|
||||||
|
'name': 'Sales Quiz',
|
||||||
|
'passing_score': 70.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertTrue(quiz.id)
|
||||||
|
self.assertEqual(quiz.passing_score, 70.0)
|
||||||
|
self.assertEqual(quiz.question_count, 0)
|
||||||
|
|
||||||
|
def test_quiz_question_count(self):
|
||||||
|
"""Test question count computation"""
|
||||||
|
quiz = self.Quiz.create({'name': 'Test Quiz'})
|
||||||
|
|
||||||
|
self.Question.create({
|
||||||
|
'quiz_id': quiz.id,
|
||||||
|
'question_text': '<p>What is Odoo?</p>',
|
||||||
|
'question_type': 'single',
|
||||||
|
})
|
||||||
|
self.Question.create({
|
||||||
|
'quiz_id': quiz.id,
|
||||||
|
'question_text': '<p>How to create a record?</p>',
|
||||||
|
'question_type': 'single',
|
||||||
|
})
|
||||||
|
|
||||||
|
quiz.invalidate_cache()
|
||||||
|
self.assertEqual(quiz.question_count, 2)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Question Tests
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def test_question_creation_single_choice(self):
|
||||||
|
"""Test creating a single choice question"""
|
||||||
|
quiz = self.Quiz.create({'name': 'Test Quiz'})
|
||||||
|
|
||||||
|
question = self.Question.create({
|
||||||
|
'quiz_id': quiz.id,
|
||||||
|
'question_text': '<p>What is 2+2?</p>',
|
||||||
|
'question_type': 'single',
|
||||||
|
'points': 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertTrue(question.id)
|
||||||
|
self.assertEqual(question.question_type, 'single')
|
||||||
|
self.assertEqual(question.points, 5)
|
||||||
|
|
||||||
|
def test_question_with_answers(self):
|
||||||
|
"""Test question with answer options"""
|
||||||
|
quiz = self.Quiz.create({'name': 'Test Quiz'})
|
||||||
|
question = self.Question.create({
|
||||||
|
'quiz_id': quiz.id,
|
||||||
|
'question_text': '<p>What is Odoo?</p>',
|
||||||
|
'question_type': 'single',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add answers
|
||||||
|
correct = self.Answer.create({
|
||||||
|
'question_id': question.id,
|
||||||
|
'answer_text': 'An ERP system',
|
||||||
|
'is_correct': True,
|
||||||
|
})
|
||||||
|
wrong = self.Answer.create({
|
||||||
|
'question_id': question.id,
|
||||||
|
'answer_text': 'A game',
|
||||||
|
'is_correct': False,
|
||||||
|
})
|
||||||
|
|
||||||
|
correct_answers = question.get_correct_answers()
|
||||||
|
self.assertEqual(len(correct_answers), 1)
|
||||||
|
self.assertEqual(correct_answers[0], correct)
|
||||||
|
|
||||||
|
def test_question_short_answer(self):
|
||||||
|
"""Test short answer question type"""
|
||||||
|
quiz = self.Quiz.create({'name': 'Test Quiz'})
|
||||||
|
|
||||||
|
question = self.Question.create({
|
||||||
|
'quiz_id': quiz.id,
|
||||||
|
'question_text': '<p>What is the capital of France?</p>',
|
||||||
|
'question_type': 'short_answer',
|
||||||
|
'correct_short_answer': 'Paris',
|
||||||
|
})
|
||||||
|
|
||||||
|
correct_answers = question.get_correct_answers()
|
||||||
|
self.assertEqual(correct_answers, ['Paris'])
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Attempt Tests
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def test_attempt_creation(self):
|
||||||
|
"""Test creating a quiz attempt"""
|
||||||
|
quiz = self.Quiz.create({
|
||||||
|
'name': 'Test Quiz',
|
||||||
|
'passing_score': 70.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
attempt = self.Attempt.create({
|
||||||
|
'quiz_id': quiz.id,
|
||||||
|
'user_id': self.test_user.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertTrue(attempt.id)
|
||||||
|
self.assertEqual(attempt.state, 'in_progress')
|
||||||
|
|
||||||
|
def test_attempt_scoring(self):
|
||||||
|
"""Test attempt scoring calculation"""
|
||||||
|
quiz = self.Quiz.create({
|
||||||
|
'name': 'Test Quiz',
|
||||||
|
'passing_score': 50.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
question1 = self.Question.create({
|
||||||
|
'quiz_id': quiz.id,
|
||||||
|
'question_text': '<p>Question 1</p>',
|
||||||
|
'question_type': 'single',
|
||||||
|
'points': 10,
|
||||||
|
})
|
||||||
|
correct1 = self.Answer.create({
|
||||||
|
'question_id': question1.id,
|
||||||
|
'answer_text': 'Correct',
|
||||||
|
'is_correct': True,
|
||||||
|
})
|
||||||
|
|
||||||
|
question2 = self.Question.create({
|
||||||
|
'quiz_id': quiz.id,
|
||||||
|
'question_text': '<p>Question 2</p>',
|
||||||
|
'question_type': 'single',
|
||||||
|
'points': 10,
|
||||||
|
})
|
||||||
|
correct2 = self.Answer.create({
|
||||||
|
'question_id': question2.id,
|
||||||
|
'answer_text': 'Correct',
|
||||||
|
'is_correct': True,
|
||||||
|
})
|
||||||
|
|
||||||
|
attempt = self.Attempt.create({
|
||||||
|
'quiz_id': quiz.id,
|
||||||
|
'user_id': self.test_user.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Answer first question correctly
|
||||||
|
response1 = self.Response.create({
|
||||||
|
'attempt_id': attempt.id,
|
||||||
|
'question_id': question1.id,
|
||||||
|
'selected_answer_ids': [(6, 0, [correct1.id])],
|
||||||
|
})
|
||||||
|
response1._score_response()
|
||||||
|
self.assertTrue(response1.is_correct)
|
||||||
|
|
||||||
|
# Answer second question incorrectly (no answer selected)
|
||||||
|
response2 = self.Response.create({
|
||||||
|
'attempt_id': attempt.id,
|
||||||
|
'question_id': question2.id,
|
||||||
|
'selected_answer_ids': [(6, 0, [])],
|
||||||
|
})
|
||||||
|
response2._score_response()
|
||||||
|
self.assertFalse(response2.is_correct)
|
||||||
|
|
||||||
|
# Check score
|
||||||
|
attempt.invalidate_cache()
|
||||||
|
self.assertEqual(attempt.points_earned, 10)
|
||||||
|
self.assertEqual(attempt.points_possible, 20)
|
||||||
|
self.assertEqual(attempt.score, 50.0)
|
||||||
|
self.assertTrue(attempt.is_passed)
|
||||||
|
|
||||||
|
def test_attempt_submit(self):
|
||||||
|
"""Test submitting an attempt"""
|
||||||
|
quiz = self.Quiz.create({'name': 'Test Quiz'})
|
||||||
|
attempt = self.Attempt.create({
|
||||||
|
'quiz_id': quiz.id,
|
||||||
|
'user_id': self.test_user.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(attempt.state, 'in_progress')
|
||||||
|
|
||||||
|
attempt.action_submit()
|
||||||
|
|
||||||
|
self.assertEqual(attempt.state, 'submitted')
|
||||||
|
self.assertTrue(attempt.submitted_at)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Response Tests
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def test_response_scoring_single_choice(self):
|
||||||
|
"""Test response scoring for single choice"""
|
||||||
|
quiz = self.Quiz.create({'name': 'Test Quiz'})
|
||||||
|
question = self.Question.create({
|
||||||
|
'quiz_id': quiz.id,
|
||||||
|
'question_text': '<p>Test?</p>',
|
||||||
|
'question_type': 'single',
|
||||||
|
'points': 5,
|
||||||
|
})
|
||||||
|
correct = self.Answer.create({
|
||||||
|
'question_id': question.id,
|
||||||
|
'answer_text': 'Correct',
|
||||||
|
'is_correct': True,
|
||||||
|
})
|
||||||
|
wrong = self.Answer.create({
|
||||||
|
'question_id': question.id,
|
||||||
|
'answer_text': 'Wrong',
|
||||||
|
'is_correct': False,
|
||||||
|
})
|
||||||
|
|
||||||
|
attempt = self.Attempt.create({
|
||||||
|
'quiz_id': quiz.id,
|
||||||
|
'user_id': self.test_user.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Correct response
|
||||||
|
response = self.Response.create({
|
||||||
|
'attempt_id': attempt.id,
|
||||||
|
'question_id': question.id,
|
||||||
|
'selected_answer_ids': [(6, 0, [correct.id])],
|
||||||
|
})
|
||||||
|
response._score_response()
|
||||||
|
|
||||||
|
self.assertTrue(response.is_correct)
|
||||||
|
self.assertEqual(response.points_awarded, 5)
|
||||||
|
|
||||||
|
def test_response_scoring_short_answer(self):
|
||||||
|
"""Test response scoring for short answer"""
|
||||||
|
quiz = self.Quiz.create({'name': 'Test Quiz'})
|
||||||
|
question = self.Question.create({
|
||||||
|
'quiz_id': quiz.id,
|
||||||
|
'question_text': '<p>Capital of France?</p>',
|
||||||
|
'question_type': 'short_answer',
|
||||||
|
'correct_short_answer': 'Paris',
|
||||||
|
'case_sensitive': False,
|
||||||
|
'points': 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
attempt = self.Attempt.create({
|
||||||
|
'quiz_id': quiz.id,
|
||||||
|
'user_id': self.test_user.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Correct response (different case)
|
||||||
|
response = self.Response.create({
|
||||||
|
'attempt_id': attempt.id,
|
||||||
|
'question_id': question.id,
|
||||||
|
'text_answer': 'paris',
|
||||||
|
})
|
||||||
|
response._score_response()
|
||||||
|
|
||||||
|
self.assertTrue(response.is_correct)
|
||||||
|
self.assertEqual(response.points_awarded, 10)
|
||||||
|
|
||||||
|
def test_response_scoring_short_answer_case_sensitive(self):
|
||||||
|
"""Test case-sensitive short answer"""
|
||||||
|
quiz = self.Quiz.create({'name': 'Test Quiz'})
|
||||||
|
question = self.Question.create({
|
||||||
|
'quiz_id': quiz.id,
|
||||||
|
'question_text': '<p>Password?</p>',
|
||||||
|
'question_type': 'short_answer',
|
||||||
|
'correct_short_answer': 'Secret123',
|
||||||
|
'case_sensitive': True,
|
||||||
|
'points': 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
attempt = self.Attempt.create({
|
||||||
|
'quiz_id': quiz.id,
|
||||||
|
'user_id': self.test_user.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Wrong case
|
||||||
|
response = self.Response.create({
|
||||||
|
'attempt_id': attempt.id,
|
||||||
|
'question_id': question.id,
|
||||||
|
'text_answer': 'secret123',
|
||||||
|
})
|
||||||
|
response._score_response()
|
||||||
|
|
||||||
|
self.assertFalse(response.is_correct)
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Security Tests (Simplified)
|
||||||
|
============================
|
||||||
|
Tests for security groups and access control.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo.tests import common, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'tour_genius')
|
||||||
|
class TestSecurity(common.TransactionCase):
|
||||||
|
"""Test cases for Security and Access Control"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(TestSecurity, self).setUp()
|
||||||
|
self.Plan = self.env['genius.plan']
|
||||||
|
self.Topic = self.env['genius.topic']
|
||||||
|
self.Progress = self.env['genius.progress']
|
||||||
|
|
||||||
|
# Get security groups
|
||||||
|
self.group_user = self.env.ref('tour_genius.group_genius_user')
|
||||||
|
self.group_instructor = self.env.ref('tour_genius.group_genius_instructor')
|
||||||
|
self.group_admin = self.env.ref('tour_genius.group_genius_admin')
|
||||||
|
|
||||||
|
# Create test users with different roles
|
||||||
|
self.user_trainee = self.env['res.users'].create({
|
||||||
|
'name': 'Trainee User',
|
||||||
|
'login': 'trainee_test',
|
||||||
|
'email': 'trainee@test.com',
|
||||||
|
'groups_id': [(6, 0, [self.group_user.id])],
|
||||||
|
})
|
||||||
|
|
||||||
|
self.user_instructor = self.env['res.users'].create({
|
||||||
|
'name': 'Instructor User',
|
||||||
|
'login': 'instructor_test',
|
||||||
|
'email': 'instructor@test.com',
|
||||||
|
'groups_id': [(6, 0, [self.group_instructor.id])],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create base data
|
||||||
|
self.test_plan = self.Plan.create({'name': 'Security Test Plan'})
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Group Tests
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def test_groups_exist(self):
|
||||||
|
"""Test that security groups are created"""
|
||||||
|
self.assertTrue(self.group_user)
|
||||||
|
self.assertTrue(self.group_instructor)
|
||||||
|
self.assertTrue(self.group_admin)
|
||||||
|
|
||||||
|
def test_group_hierarchy(self):
|
||||||
|
"""Test group inheritance"""
|
||||||
|
# Instructor implies User
|
||||||
|
self.assertIn(self.group_user, self.group_instructor.implied_ids)
|
||||||
|
|
||||||
|
# Admin implies Instructor
|
||||||
|
self.assertIn(self.group_instructor, self.group_admin.implied_ids)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Access Rights Tests
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def test_trainee_can_read_topic(self):
|
||||||
|
"""Test trainee can read topics"""
|
||||||
|
topic = self.Topic.create({'name': 'Public Topic'})
|
||||||
|
|
||||||
|
# Switch to trainee user and try to read
|
||||||
|
topic_as_trainee = topic.with_user(self.user_trainee)
|
||||||
|
self.assertTrue(topic_as_trainee.name)
|
||||||
|
|
||||||
|
def test_instructor_can_create_plan(self):
|
||||||
|
"""Test instructor can create plans"""
|
||||||
|
Plan = self.Plan.with_user(self.user_instructor)
|
||||||
|
|
||||||
|
plan = Plan.create({'name': 'Instructor Plan'})
|
||||||
|
self.assertTrue(plan.id)
|
||||||
|
|
||||||
|
def test_instructor_can_create_topic(self):
|
||||||
|
"""Test instructor can create topics"""
|
||||||
|
Plan = self.Plan.with_user(self.user_instructor)
|
||||||
|
Topic = self.Topic.with_user(self.user_instructor)
|
||||||
|
|
||||||
|
plan = Plan.create({'name': 'Instructor Test Plan'})
|
||||||
|
|
||||||
|
topic = Topic.create({
|
||||||
|
'name': 'Instructor Topic',
|
||||||
|
'plan_id': plan.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertTrue(topic.id)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Progress Access Tests
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def test_trainee_can_create_own_progress(self):
|
||||||
|
"""Test trainee can create their own progress"""
|
||||||
|
topic = self.Topic.create({'name': 'Test Topic Progress'})
|
||||||
|
|
||||||
|
Progress = self.Progress.with_user(self.user_trainee)
|
||||||
|
|
||||||
|
progress = Progress.create({
|
||||||
|
'user_id': self.user_trainee.id,
|
||||||
|
'topic_id': topic.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertTrue(progress.id)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Module Access Mixin Tests
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def test_user_accessible_modules_method(self):
|
||||||
|
"""Test _get_accessible_modules method exists on users"""
|
||||||
|
modules = self.user_trainee._get_accessible_modules()
|
||||||
|
self.assertIsInstance(modules, list)
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Training Models Tests (Simplified)
|
||||||
|
===================================
|
||||||
|
Tests for tour genius training layer - updated for flat structure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo.tests import common, tagged
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'tour_genius')
|
||||||
|
class TestTrainingModels(common.TransactionCase):
|
||||||
|
"""Test cases for Training Layer models"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(TestTrainingModels, self).setUp()
|
||||||
|
self.Plan = self.env['genius.plan']
|
||||||
|
self.Topic = self.env['genius.topic']
|
||||||
|
self.Step = self.env['genius.topic.step']
|
||||||
|
self.Progress = self.env['genius.progress']
|
||||||
|
self.Tag = self.env['genius.tour.tag']
|
||||||
|
|
||||||
|
# Create test user
|
||||||
|
self.test_user = self.env['res.users'].create({
|
||||||
|
'name': 'Test Trainee',
|
||||||
|
'login': 'test_trainee',
|
||||||
|
'email': 'trainee@test.com',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create base plan
|
||||||
|
self.test_plan = self.Plan.create({'name': 'Test Plan'})
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Plan Tests
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def test_plan_creation(self):
|
||||||
|
"""Test creating a training plan"""
|
||||||
|
plan = self.Plan.create({
|
||||||
|
'name': 'Test Training Plan',
|
||||||
|
})
|
||||||
|
self.assertTrue(plan.id)
|
||||||
|
self.assertEqual(plan.state, 'draft')
|
||||||
|
|
||||||
|
def test_plan_publish_workflow(self):
|
||||||
|
"""Test plan state workflow"""
|
||||||
|
plan = self.Plan.create({'name': 'Test Plan Workflow'})
|
||||||
|
|
||||||
|
# Initial state
|
||||||
|
self.assertEqual(plan.state, 'draft')
|
||||||
|
|
||||||
|
# Publish
|
||||||
|
plan.action_publish()
|
||||||
|
self.assertEqual(plan.state, 'published')
|
||||||
|
|
||||||
|
# Archive
|
||||||
|
plan.action_archive()
|
||||||
|
self.assertEqual(plan.state, 'archived')
|
||||||
|
|
||||||
|
# Back to draft
|
||||||
|
plan.action_draft()
|
||||||
|
self.assertEqual(plan.state, 'draft')
|
||||||
|
|
||||||
|
def test_plan_topic_count(self):
|
||||||
|
"""Test topic count computation"""
|
||||||
|
plan = self.Plan.create({'name': 'Test Plan Topics'})
|
||||||
|
self.assertEqual(plan.topic_count, 0)
|
||||||
|
|
||||||
|
# Add topics
|
||||||
|
self.Topic.create({'name': 'Topic 1', 'plan_id': plan.id})
|
||||||
|
self.Topic.create({'name': 'Topic 2', 'plan_id': plan.id})
|
||||||
|
|
||||||
|
plan.invalidate_cache()
|
||||||
|
self.assertEqual(plan.topic_count, 2)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Topic Tests
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def test_topic_creation(self):
|
||||||
|
"""Test creating a topic"""
|
||||||
|
topic = self.Topic.create({
|
||||||
|
'name': 'Getting Started',
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertTrue(topic.id)
|
||||||
|
|
||||||
|
def test_topic_step_count(self):
|
||||||
|
"""Test step count computation"""
|
||||||
|
topic = self.Topic.create({'name': 'Test Topic Steps'})
|
||||||
|
self.assertEqual(topic.step_count, 0)
|
||||||
|
|
||||||
|
# Add steps
|
||||||
|
self.Step.create({'topic_id': topic.id, 'title': 'Step 1'})
|
||||||
|
self.Step.create({'topic_id': topic.id, 'title': 'Step 2'})
|
||||||
|
|
||||||
|
topic.invalidate_cache()
|
||||||
|
self.assertEqual(topic.step_count, 2)
|
||||||
|
|
||||||
|
def test_topic_consumed_tracking(self):
|
||||||
|
"""Test consumed by tracking"""
|
||||||
|
topic = self.Topic.create({'name': 'Test Topic Consumed'})
|
||||||
|
self.assertEqual(topic.consumed_count, 0)
|
||||||
|
|
||||||
|
# Mark as consumed
|
||||||
|
topic.consumed_user_ids = [(4, self.test_user.id)]
|
||||||
|
topic.invalidate_cache()
|
||||||
|
|
||||||
|
self.assertEqual(topic.consumed_count, 1)
|
||||||
|
|
||||||
|
def test_topic_default_values(self):
|
||||||
|
"""Test topic default values"""
|
||||||
|
topic = self.Topic.create({'name': 'Defaults Test'})
|
||||||
|
|
||||||
|
self.assertEqual(topic.duration_minutes, 15)
|
||||||
|
self.assertTrue(topic.active)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Topic Step Tests
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def test_step_creation(self):
|
||||||
|
"""Test creating a topic step"""
|
||||||
|
topic = self.Topic.create({'name': 'Test Topic'})
|
||||||
|
|
||||||
|
step = self.Step.create({
|
||||||
|
'topic_id': topic.id,
|
||||||
|
'title': 'Click Save Button',
|
||||||
|
'step_type': 'click',
|
||||||
|
'css_selector': '.o_form_button_save',
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertTrue(step.id)
|
||||||
|
self.assertEqual(step.step_type, 'click')
|
||||||
|
|
||||||
|
def test_step_css_selector(self):
|
||||||
|
"""Test CSS selector field"""
|
||||||
|
topic = self.Topic.create({'name': 'Test Topic'})
|
||||||
|
|
||||||
|
step = self.Step.create({
|
||||||
|
'topic_id': topic.id,
|
||||||
|
'title': 'Custom Step',
|
||||||
|
'css_selector': '.my-custom-selector',
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(step.css_selector, '.my-custom-selector')
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Progress Tests
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def test_progress_creation(self):
|
||||||
|
"""Test creating progress record"""
|
||||||
|
topic = self.Topic.create({'name': 'Test Topic'})
|
||||||
|
|
||||||
|
progress = self.Progress.create({
|
||||||
|
'user_id': self.test_user.id,
|
||||||
|
'topic_id': topic.id,
|
||||||
|
'state': 'pending',
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertTrue(progress.id)
|
||||||
|
self.assertEqual(progress.state, 'pending')
|
||||||
|
|
||||||
|
def test_progress_unique_constraint(self):
|
||||||
|
"""Test unique constraint on user/topic"""
|
||||||
|
topic = self.Topic.create({'name': 'Test Topic Unique'})
|
||||||
|
|
||||||
|
self.Progress.create({
|
||||||
|
'user_id': self.test_user.id,
|
||||||
|
'topic_id': topic.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Try to create duplicate
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
self.Progress.create({
|
||||||
|
'user_id': self.test_user.id,
|
||||||
|
'topic_id': topic.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Tag Tests
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def test_tag_creation(self):
|
||||||
|
"""Test creating a tag"""
|
||||||
|
tag = self.Tag.create({'name': 'Beginner Tag Test'})
|
||||||
|
self.assertTrue(tag.id)
|
||||||
|
|
||||||
|
def test_tag_unique_name(self):
|
||||||
|
"""Test tag unique name constraint"""
|
||||||
|
self.Tag.create({'name': 'Unique Tag Test 123'})
|
||||||
|
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
self.Tag.create({'name': 'Unique Tag Test 123'})
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- Leaderboard Views -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<record id="view_genius_leaderboard_tree" model="ir.ui.view">
|
||||||
|
<field name="name">genius.leaderboard.tree</field>
|
||||||
|
<field name="model">genius.leaderboard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree string="Leaderboard" decoration-bf="rank <= 3" decoration-success="rank == 1">
|
||||||
|
<field name="rank"/>
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="points"/>
|
||||||
|
<field name="topics_completed"/>
|
||||||
|
<field name="quizzes_passed"/>
|
||||||
|
<field name="time_spent_hours"/>
|
||||||
|
<field name="period_type"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Leaderboard Action -->
|
||||||
|
<record id="action_genius_leaderboard" model="ir.actions.act_window">
|
||||||
|
<field name="name">Leaderboard</field>
|
||||||
|
<field name="res_model">genius.leaderboard</field>
|
||||||
|
<field name="view_mode">tree</field>
|
||||||
|
<field name="domain">[('period_type', '=', 'alltime')]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- Reminder Views -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<record id="view_genius_reminder_tree" model="ir.ui.view">
|
||||||
|
<field name="name">genius.reminder.tree</field>
|
||||||
|
<field name="model">genius.reminder</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree string="Reminders">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="reminder_type"/>
|
||||||
|
<field name="scheduled_date"/>
|
||||||
|
<field name="state" widget="badge" decoration-success="state == 'sent'"
|
||||||
|
decoration-warning="state == 'pending'" decoration-danger="state == 'failed'"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_genius_reminder_form" model="ir.ui.view">
|
||||||
|
<field name="name">genius.reminder.form</field>
|
||||||
|
<field name="model">genius.reminder</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Reminder">
|
||||||
|
<header>
|
||||||
|
<button name="action_send" type="object"
|
||||||
|
string="Send Now" class="btn-primary"
|
||||||
|
attrs="{'invisible': [('state', '!=', 'pending')]}"/>
|
||||||
|
<button name="action_cancel" type="object"
|
||||||
|
string="Cancel"
|
||||||
|
attrs="{'invisible': [('state', '!=', 'pending')]}"/>
|
||||||
|
<field name="state" widget="statusbar"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="reminder_type"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="scheduled_date"/>
|
||||||
|
<field name="sent_date"/>
|
||||||
|
<field name="is_recurring"/>
|
||||||
|
<field name="recurrence_interval" attrs="{'invisible': [('is_recurring', '=', False)]}"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="topic_id" attrs="{'invisible': [('reminder_type', 'not in', ['incomplete_topic', 'new_topic'])]}"/>
|
||||||
|
<field name="plan_id" attrs="{'invisible': [('reminder_type', '!=', 'incomplete_plan')]}"/>
|
||||||
|
</group>
|
||||||
|
<field name="custom_message" attrs="{'invisible': [('reminder_type', '!=', 'custom')]}"/>
|
||||||
|
<field name="message_body" readonly="1"/>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Reminder Action -->
|
||||||
|
<record id="action_genius_reminder" model="ir.actions.act_window">
|
||||||
|
<field name="name">Reminders</field>
|
||||||
|
<field name="res_model">genius.reminder</field>
|
||||||
|
<field name="view_mode">tree,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Menu Items -->
|
||||||
|
<menuitem id="menu_analytics_leaderboard"
|
||||||
|
name="Leaderboard"
|
||||||
|
parent="menu_analytics"
|
||||||
|
action="action_genius_leaderboard"
|
||||||
|
sequence="10"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_analytics_reminders"
|
||||||
|
name="Reminders"
|
||||||
|
parent="menu_analytics"
|
||||||
|
action="action_genius_reminder"
|
||||||
|
sequence="20"/>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Backend Assets -->
|
||||||
|
<template id="assets_backend_inherit_tour_genius" inherit_id="web.assets_backend" name="Tour Genius backend assets">
|
||||||
|
<xpath expr="link[last()]" position="after">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/tour_genius/static/src/css/tour_genius.css"/>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="script[last()]" position="after">
|
||||||
|
<script type="text/javascript" src="/tour_genius/static/src/js/genius_celebration.js"/>
|
||||||
|
<script type="text/javascript" src="/tour_genius/static/src/js/genius_quiz_popup.js"/>
|
||||||
|
<script type="text/javascript" src="/tour_genius/static/src/js/dashboard.js"/>
|
||||||
|
<script type="text/javascript" src="/tour_genius/static/src/js/recorder_panel.js"/>
|
||||||
|
<script type="text/javascript" src="/tour_genius/static/src/js/tour_client_action.js"/>
|
||||||
|
<script type="text/javascript" src="/tour_genius/static/src/js/genius_tip.js"/>
|
||||||
|
<script type="text/javascript" src="/tour_genius/static/src/js/tour_loader.js"/>
|
||||||
|
<script type="text/javascript" src="/tour_genius/static/src/js/smart_systray.js"/>
|
||||||
|
</xpath>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Frontend Assets (Website/Login) -->
|
||||||
|
<template id="assets_frontend_inherit_tour_genius" inherit_id="web.assets_frontend" name="Tour Genius frontend assets">
|
||||||
|
<xpath expr="link[last()]" position="after">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/tour_genius/static/src/css/tour_genius.css"/>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="script[last()]" position="after">
|
||||||
|
<script type="text/javascript" src="/tour_genius/static/src/js/genius_tip.js"/>
|
||||||
|
<script type="text/javascript" src="/tour_genius/static/src/js/tour_loader.js"/>
|
||||||
|
<!-- Also load celebration and popup widgets for frontend usage -->
|
||||||
|
<script type="text/javascript" src="/tour_genius/static/src/js/genius_celebration.js"/>
|
||||||
|
<script type="text/javascript" src="/tour_genius/static/src/js/genius_quiz_popup.js"/>
|
||||||
|
</xpath>
|
||||||
|
</template>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- Dashboard Client Action -->
|
||||||
|
<record id="action_genius_dashboard" model="ir.actions.client">
|
||||||
|
<field name="name">Tour Genius Dashboard</field>
|
||||||
|
<field name="tag">genius_dashboard</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Dashboard Menu Item (will be first in menu) -->
|
||||||
|
<menuitem
|
||||||
|
id="menu_genius_dashboard"
|
||||||
|
name="Dashboard"
|
||||||
|
action="action_genius_dashboard"
|
||||||
|
parent="menu_tour_genius_root"
|
||||||
|
sequence="1"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- TREE VIEW -->
|
||||||
|
<record id="view_genius_leaderboard_tree" model="ir.ui.view">
|
||||||
|
<field name="name">genius.leaderboard.tree</field>
|
||||||
|
<field name="model">genius.leaderboard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree string="Leaderboard" decoration-success="rank <= 3" decoration-bf="rank <= 3">
|
||||||
|
<field name="rank"/>
|
||||||
|
<field name="user_id" widget="many2one_avatar_user"/>
|
||||||
|
<field name="points" sum="Total Points"/>
|
||||||
|
<field name="topics_completed"/>
|
||||||
|
<field name="quizzes_passed"/>
|
||||||
|
<field name="badge_count"/>
|
||||||
|
<field name="streak_days" optional="hide"/>
|
||||||
|
<field name="period_type" optional="hide"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- SEARCH VIEW -->
|
||||||
|
<record id="view_genius_leaderboard_search" model="ir.ui.view">
|
||||||
|
<field name="name">genius.leaderboard.search</field>
|
||||||
|
<field name="model">genius.leaderboard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Leaderboard">
|
||||||
|
<field name="user_id"/>
|
||||||
|
<filter string="All Time" name="period_alltime" domain="[('period_type', '=', 'alltime')]"/>
|
||||||
|
<filter string="Monthly" name="period_monthly" domain="[('period_type', '=', 'monthly')]"/>
|
||||||
|
<filter string="Weekly" name="period_weekly" domain="[('period_type', '=', 'weekly')]"/>
|
||||||
|
<group expand="0" string="Group By">
|
||||||
|
<filter string="Period" name="group_period" context="{'group_by': 'period_type'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ACTION -->
|
||||||
|
<record id="action_genius_leaderboard" model="ir.actions.act_window">
|
||||||
|
<field name="name">Leaderboard</field>
|
||||||
|
<field name="res_model">genius.leaderboard</field>
|
||||||
|
<field name="view_mode">tree</field>
|
||||||
|
<field name="context">{'search_default_period_alltime': 1}</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Join the competition!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Complete tours and pass quizzes to earn points and climb the leaderboard.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- MENU -->
|
||||||
|
<menuitem id="menu_genius_leaderboard"
|
||||||
|
name="Leaderboard"
|
||||||
|
parent="menu_training"
|
||||||
|
action="action_genius_leaderboard"
|
||||||
|
sequence="30"
|
||||||
|
groups="tour_genius.group_genius_user"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- ROOT MENU (No actions - just structure) -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<menuitem id="menu_tour_genius_root"
|
||||||
|
name="Tour Genius"
|
||||||
|
sequence="100"
|
||||||
|
web_icon="tour_genius,static/description/icon.png"/>
|
||||||
|
|
||||||
|
<!-- Parent Menu Items (no actions) -->
|
||||||
|
<menuitem id="menu_training"
|
||||||
|
name="Training"
|
||||||
|
parent="menu_tour_genius_root"
|
||||||
|
sequence="10"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_analytics"
|
||||||
|
name="Analytics"
|
||||||
|
parent="menu_tour_genius_root"
|
||||||
|
sequence="30"
|
||||||
|
groups="tour_genius.group_genius_instructor"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_configuration"
|
||||||
|
name="Configuration"
|
||||||
|
parent="menu_tour_genius_root"
|
||||||
|
sequence="90"
|
||||||
|
groups="tour_genius.group_genius_admin"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- Training Plan Views -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<!-- Plan Tree View -->
|
||||||
|
<record id="view_genius_plan_tree" model="ir.ui.view">
|
||||||
|
<field name="name">genius.plan.tree</field>
|
||||||
|
<field name="model">genius.plan</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree string="Training Plans" decoration-info="state == 'draft'" decoration-muted="state == 'archived'">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="topic_count"/>
|
||||||
|
<field name="attendee_count"/>
|
||||||
|
<field name="state" widget="badge"
|
||||||
|
decoration-info="state == 'draft'"
|
||||||
|
decoration-success="state == 'published'"
|
||||||
|
decoration-muted="state == 'archived'"/>
|
||||||
|
<field name="overall_progress" widget="progressbar"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Plan Kanban View -->
|
||||||
|
<record id="view_genius_plan_kanban" model="ir.ui.view">
|
||||||
|
<field name="name">genius.plan.kanban</field>
|
||||||
|
<field name="model">genius.plan</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<kanban class="o_kanban_mobile" default_group_by="state">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="state"/>
|
||||||
|
<field name="topic_count"/>
|
||||||
|
<field name="attendee_count"/>
|
||||||
|
<field name="overall_progress"/>
|
||||||
|
<field name="cover_image"/>
|
||||||
|
<templates>
|
||||||
|
<t t-name="kanban-box">
|
||||||
|
<div class="oe_kanban_card oe_kanban_global_click">
|
||||||
|
<div class="o_kanban_image" t-if="record.cover_image.raw_value">
|
||||||
|
<img t-att-src="kanban_image('genius.plan', 'cover_image', record.id.raw_value)" alt="Cover"/>
|
||||||
|
</div>
|
||||||
|
<div class="oe_kanban_details">
|
||||||
|
<strong class="o_kanban_record_title">
|
||||||
|
<field name="name"/>
|
||||||
|
</strong>
|
||||||
|
<div class="o_kanban_tags_section">
|
||||||
|
<span class="badge badge-pill">
|
||||||
|
<field name="topic_count"/> tours
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-pill">
|
||||||
|
<field name="attendee_count"/> trainees
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="o_kanban_record_bottom">
|
||||||
|
<field name="overall_progress" widget="progressbar"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
</kanban>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Plan Form View -->
|
||||||
|
<record id="view_genius_plan_form" model="ir.ui.view">
|
||||||
|
<field name="name">genius.plan.form</field>
|
||||||
|
<field name="model">genius.plan</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Training Plan">
|
||||||
|
<header>
|
||||||
|
<button name="action_publish" type="object" string="Publish"
|
||||||
|
class="btn-primary" states="draft"/>
|
||||||
|
<button name="action_archive" type="object" string="Archive"
|
||||||
|
states="published"/>
|
||||||
|
<button name="action_draft" type="object" string="Reset to Draft"
|
||||||
|
states="archived"/>
|
||||||
|
<field name="state" widget="statusbar" statusbar_visible="draft,published,archived"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_button_box" name="button_box">
|
||||||
|
<button name="action_view_topics" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-list-ol">
|
||||||
|
<field name="topic_count" widget="statinfo" string="Tours"/>
|
||||||
|
</button>
|
||||||
|
<button name="action_view_attendees" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-users">
|
||||||
|
<field name="attendee_count" widget="statinfo" string="Trainees"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<field name="cover_image" widget="image" class="oe_avatar"/>
|
||||||
|
<div class="oe_title">
|
||||||
|
<label for="name"/>
|
||||||
|
<h1>
|
||||||
|
<field name="name" placeholder="Training Plan Name"/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="date_start"/>
|
||||||
|
<field name="date_end"/>
|
||||||
|
<field name="estimated_hours"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="is_public"/>
|
||||||
|
<field name="is_template"/>
|
||||||
|
<field name="company_id" groups="base.group_multi_company"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Tours" name="topics">
|
||||||
|
<field name="topic_ids">
|
||||||
|
<tree editable="bottom">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="icon"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="step_count"/>
|
||||||
|
<field name="duration_minutes"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
<page string="Modules Covered" name="modules">
|
||||||
|
<field name="target_modules" placeholder="sale, purchase, account"/>
|
||||||
|
</page>
|
||||||
|
<page string="Participants" name="participants">
|
||||||
|
<group>
|
||||||
|
<group string="Instructors">
|
||||||
|
<field name="instructor_ids" widget="many2many_tags" nolabel="1"/>
|
||||||
|
</group>
|
||||||
|
<group string="Trainees">
|
||||||
|
<field name="attendee_ids" widget="many2many_tags" nolabel="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
<page string="Description" name="description">
|
||||||
|
<field name="description" placeholder="Describe this training plan..."/>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
<div class="oe_chatter">
|
||||||
|
<field name="message_follower_ids"/>
|
||||||
|
<field name="activity_ids"/>
|
||||||
|
<field name="message_ids"/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Plan Search View -->
|
||||||
|
<record id="view_genius_plan_search" model="ir.ui.view">
|
||||||
|
<field name="name">genius.plan.search</field>
|
||||||
|
<field name="model">genius.plan</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Plans">
|
||||||
|
<field name="name"/>
|
||||||
|
<filter name="my_plans" string="My Plans"
|
||||||
|
domain="['|', ('attendee_ids', 'in', [uid]), ('instructor_ids', 'in', [uid])]"/>
|
||||||
|
<filter name="published" string="Published" domain="[('state', '=', 'published')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="archived" string="Archived" domain="[('active', '=', False)]"/>
|
||||||
|
<group expand="0" string="Group By">
|
||||||
|
<filter string="State" name="group_by_state" context="{'group_by': 'state'}"/>
|
||||||
|
<filter string="Company" name="group_by_company" context="{'group_by': 'company_id'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Plan Action -->
|
||||||
|
<record id="action_genius_plan" model="ir.actions.act_window">
|
||||||
|
<field name="name">Training Plans</field>
|
||||||
|
<field name="res_model">genius.plan</field>
|
||||||
|
<field name="view_mode">kanban,tree,form</field>
|
||||||
|
<field name="search_view_id" ref="view_genius_plan_search"/>
|
||||||
|
<field name="context">{'search_default_my_plans': 1}</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Create your first training plan!
|
||||||
|
</p>
|
||||||
|
<p>Training plans organize tours for structured learning.</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Menu Item -->
|
||||||
|
<menuitem id="menu_training_plans"
|
||||||
|
name="Training Plans"
|
||||||
|
parent="menu_training"
|
||||||
|
action="action_genius_plan"
|
||||||
|
sequence="10"/>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- Progress Views (Admin View for Tracking Completions) -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<!-- Progress Tree View -->
|
||||||
|
<record id="view_genius_progress_tree" model="ir.ui.view">
|
||||||
|
<field name="name">genius.progress.tree</field>
|
||||||
|
<field name="model">genius.progress</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree string="User Progress" decoration-success="state in ('done', 'verified')" decoration-warning="state=='skipped'" decoration-info="state=='in_progress'">
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="topic_id"/>
|
||||||
|
<field name="plan_id" optional="hide"/>
|
||||||
|
<field name="state"/>
|
||||||
|
<field name="date_started"/>
|
||||||
|
<field name="date_completed"/>
|
||||||
|
<field name="duration_display" string="Duration"/>
|
||||||
|
<field name="completion_count" optional="show"/>
|
||||||
|
<field name="quiz_score" optional="show"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Progress Form View -->
|
||||||
|
<record id="view_genius_progress_form" model="ir.ui.view">
|
||||||
|
<field name="name">genius.progress.form</field>
|
||||||
|
<field name="model">genius.progress</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="User Progress">
|
||||||
|
<header>
|
||||||
|
<button name="action_verify" type="object" string="Verify Completion"
|
||||||
|
class="btn-primary" states="done"
|
||||||
|
groups="tour_genius.group_genius_admin"/>
|
||||||
|
<button name="action_reset" type="object" string="Reset Progress"
|
||||||
|
class="btn-secondary"
|
||||||
|
groups="tour_genius.group_genius_admin"/>
|
||||||
|
<field name="state" widget="statusbar" statusbar_visible="pending,in_progress,skipped,done,verified"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group string="User Information">
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="topic_id"/>
|
||||||
|
<field name="plan_id"/>
|
||||||
|
</group>
|
||||||
|
<group string="Progress Details">
|
||||||
|
<field name="date_started"/>
|
||||||
|
<field name="date_completed"/>
|
||||||
|
<field name="date_skipped" attrs="{'invisible': [('date_skipped', '=', False)]}"/>
|
||||||
|
<field name="date_verified"/>
|
||||||
|
<field name="duration_display"/>
|
||||||
|
<field name="completion_count"/>
|
||||||
|
<field name="time_spent_seconds" groups="base.group_no_one"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Quiz Results" attrs="{'invisible': [('quiz_score', '=', 0)]}">
|
||||||
|
<field name="quiz_score"/>
|
||||||
|
<field name="quiz_attempt_id"/>
|
||||||
|
</group>
|
||||||
|
<group string="Trainer Notes">
|
||||||
|
<field name="notes" placeholder="Add notes about this user's progress..."/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Progress Search View -->
|
||||||
|
<record id="view_genius_progress_search" model="ir.ui.view">
|
||||||
|
<field name="name">genius.progress.search</field>
|
||||||
|
<field name="model">genius.progress</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Progress">
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="topic_id"/>
|
||||||
|
<field name="plan_id"/>
|
||||||
|
<filter name="completed" string="Completed" domain="[('state', '=', 'done')]"/>
|
||||||
|
<filter name="skipped" string="Skipped" domain="[('state', '=', 'skipped')]"/>
|
||||||
|
<filter name="in_progress" string="In Progress" domain="[('state', '=', 'in_progress')]"/>
|
||||||
|
<filter name="verified" string="Certified" domain="[('state', '=', 'verified')]"/>
|
||||||
|
<separator/>
|
||||||
|
<group expand="0" string="Group By">
|
||||||
|
<filter string="User" name="group_by_user" context="{'group_by': 'user_id'}"/>
|
||||||
|
<filter string="Tour" name="group_by_tour" context="{'group_by': 'topic_id'}"/>
|
||||||
|
<filter string="Status" name="group_by_state" context="{'group_by': 'state'}"/>
|
||||||
|
<filter string="Completion Date" name="group_by_completed" context="{'group_by': 'date_completed:month'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Progress Action -->
|
||||||
|
<record id="action_genius_progress" model="ir.actions.act_window">
|
||||||
|
<field name="name">User Progress</field>
|
||||||
|
<field name="res_model">genius.progress</field>
|
||||||
|
<field name="view_mode">tree,form</field>
|
||||||
|
<field name="search_view_id" ref="view_genius_progress_search"/>
|
||||||
|
<field name="context">{'search_default_completed': 0}</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
No progress records yet
|
||||||
|
</p>
|
||||||
|
<p>Progress is recorded automatically when users complete tours.</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Menu Item for Progress (Admin only) -->
|
||||||
|
<menuitem id="menu_training_progress"
|
||||||
|
name="User Progress"
|
||||||
|
parent="menu_training"
|
||||||
|
action="action_genius_progress"
|
||||||
|
sequence="50"
|
||||||
|
groups="tour_genius.group_genius_admin"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,355 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- Quiz Views -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<record id="view_genius_quiz_tree" model="ir.ui.view">
|
||||||
|
<field name="name">genius.quiz.tree</field>
|
||||||
|
<field name="model">genius.quiz</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree string="Quizzes">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="question_count"/>
|
||||||
|
<field name="passing_score"/>
|
||||||
|
<field name="attempt_count"/>
|
||||||
|
<field name="avg_score"/>
|
||||||
|
<field name="pass_rate"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_genius_quiz_form" model="ir.ui.view">
|
||||||
|
<field name="name">genius.quiz.form</field>
|
||||||
|
<field name="model">genius.quiz</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Quiz">
|
||||||
|
<header>
|
||||||
|
<button name="action_analyze_difficulty" string="Analyze" type="object"
|
||||||
|
icon="fa-bar-chart"
|
||||||
|
attrs="{'invisible': [('attempt_count', '=', 0)]}"/>
|
||||||
|
<button name="action_reset_statistics" string="Reset Stats" type="object"
|
||||||
|
icon="fa-refresh"
|
||||||
|
confirm="This will delete all attempts and statistics. Continue?"
|
||||||
|
attrs="{'invisible': [('attempt_count', '=', 0)]}"/>
|
||||||
|
<button name="action_duplicate_quiz" string="Duplicate" type="object" icon="fa-copy"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_button_box" name="button_box">
|
||||||
|
<button class="oe_stat_button" icon="fa-question-circle">
|
||||||
|
<field name="question_count" widget="statinfo" string="Questions"/>
|
||||||
|
</button>
|
||||||
|
<button name="action_view_attempts" class="oe_stat_button" icon="fa-pencil-square" type="object">
|
||||||
|
<field name="attempt_count" widget="statinfo" string="Attempts"/>
|
||||||
|
</button>
|
||||||
|
<button class="oe_stat_button" icon="fa-check-circle">
|
||||||
|
<field name="pass_rate" widget="statinfo" string="Pass Rate"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="oe_title d-flex align-items-center">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<label for="name" class="oe_edit_only"/>
|
||||||
|
<h1>
|
||||||
|
<field name="name" placeholder="Quiz Title"/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<!-- Genius Test Button - Right side of title -->
|
||||||
|
<div class="genius-test-mode-container ml-auto" attrs="{'invisible': [('question_count', '=', 0)]}">
|
||||||
|
<field name="test_mode_opener" widget="genius_quiz_test_button"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group string="Configuration">
|
||||||
|
<field name="passing_score"/>
|
||||||
|
<field name="time_limit_minutes"/>
|
||||||
|
<field name="max_attempts"/>
|
||||||
|
</group>
|
||||||
|
<group string="Behavior">
|
||||||
|
<field name="shuffle_questions"/>
|
||||||
|
<field name="show_correct_answers"/>
|
||||||
|
<field name="sample_size"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Questions" name="questions">
|
||||||
|
<field name="question_ids" context="{'default_quiz_id': active_id}">
|
||||||
|
<tree>
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="question_text"/>
|
||||||
|
<field name="question_type"/>
|
||||||
|
<field name="points"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
<page string="Feedback & Customization" name="feedback">
|
||||||
|
<group>
|
||||||
|
<separator string="Success Feedback" colspan="2"/>
|
||||||
|
<field name="success_message" nolabel="1" placeholder="Message shown when user passes..."/>
|
||||||
|
|
||||||
|
<separator string="Failure Feedback" colspan="2"/>
|
||||||
|
<field name="fail_message" nolabel="1" placeholder="Message shown when user fails..."/>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
<page string="Description" name="description">
|
||||||
|
<field name="description" placeholder="Internal notes or description..."/>
|
||||||
|
</page>
|
||||||
|
<page string="Statistics" name="stats">
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="attempt_count" readonly="1"/>
|
||||||
|
<field name="avg_score" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="pass_rate" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
<page string="Certificate" name="certificate">
|
||||||
|
<group>
|
||||||
|
<group string="Branding">
|
||||||
|
<field name="certificate_logo" widget="image" class="oe_avatar" options="{'size': [90, 90]}"/>
|
||||||
|
<field name="certificate_secondary_logo" widget="image" class="oe_avatar" options="{'size': [90, 90]}"/>
|
||||||
|
<field name="certificate_title" placeholder="CERTIFICATE"/>
|
||||||
|
<field name="certificate_issuer" placeholder="Training Department"/>
|
||||||
|
</group>
|
||||||
|
<group string="Signature & Stamp">
|
||||||
|
<field name="certificate_signature_image" widget="image" class="oe_avatar" options="{'size': [150, 60]}"/>
|
||||||
|
<field name="certificate_stamp" widget="image" class="oe_avatar" options="{'size': [90, 90]}"/>
|
||||||
|
<field name="certificate_signature_name" placeholder="Training Director"/>
|
||||||
|
<field name="certificate_signature_title" placeholder="Director of Training"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Achievement Description">
|
||||||
|
<field name="certificate_body_template" nolabel="1" colspan="2"
|
||||||
|
placeholder="has successfully completed the training course and demonstrated mastery..."/>
|
||||||
|
</group>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="fa fa-info-circle"/> <strong>Variables:</strong>
|
||||||
|
<code>{user_name}</code> <code>{topic_name}</code> <code>{quiz_name}</code>
|
||||||
|
<br/><small class="text-muted">Note: Score and Date are displayed automatically in the certificate footer.</small>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<button name="action_preview_certificate" type="object" class="btn btn-primary" icon="fa-eye">
|
||||||
|
<i class="fa fa-eye"/> Preview Certificate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_genius_quiz" model="ir.actions.act_window">
|
||||||
|
<field name="name">Quizzes</field>
|
||||||
|
<field name="res_model">genius.quiz</field>
|
||||||
|
<field name="view_mode">tree,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Menu Item -->
|
||||||
|
<menuitem id="menu_quizzes"
|
||||||
|
name="Quizzes"
|
||||||
|
parent="menu_tour_genius_root"
|
||||||
|
action="action_genius_quiz"
|
||||||
|
sequence="20"
|
||||||
|
groups="tour_genius.group_genius_instructor"/>
|
||||||
|
|
||||||
|
<record id="view_genius_question_form" model="ir.ui.view">
|
||||||
|
<field name="name">genius.quiz.question.form</field>
|
||||||
|
<field name="model">genius.quiz.question</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Question">
|
||||||
|
<sheet>
|
||||||
|
<!-- Question Preview Title -->
|
||||||
|
<div class="oe_title">
|
||||||
|
<label for="name" class="oe_edit_only" string="Question Preview"/>
|
||||||
|
<h1>
|
||||||
|
<field name="name" readonly="1"/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Configuration -->
|
||||||
|
<group>
|
||||||
|
<group string="Configuration">
|
||||||
|
<field name="question_type" widget="radio"/>
|
||||||
|
<field name="points"/>
|
||||||
|
</group>
|
||||||
|
<group string="Status">
|
||||||
|
<field name="active" widget="boolean_toggle"/>
|
||||||
|
<field name="sequence" groups="base.group_no_one"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- Question Content -->
|
||||||
|
<separator string="Question Content"/>
|
||||||
|
<field name="question_text" nolabel="1" placeholder="Enter your question here..."/>
|
||||||
|
|
||||||
|
<!-- Dynamic Hint Based on Type -->
|
||||||
|
<div class="alert alert-info" role="alert" attrs="{'invisible': [('question_type', 'not in', ['fill_blank'])]}">
|
||||||
|
<i class="fa fa-info-circle"/> <strong>Tip:</strong> Write your question with a blank to fill, e.g., "The capital of France is ___".
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info" role="alert" attrs="{'invisible': [('question_type', '!=', 'ordering')]}">
|
||||||
|
<i class="fa fa-info-circle"/> <strong>Tip:</strong> Add items below. The sequence order you set here is the <em>correct</em> order.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<notebook>
|
||||||
|
<!-- Answer Options (Single/Multiple Choice) -->
|
||||||
|
<page string="Answer Options" name="answers"
|
||||||
|
attrs="{'invisible': [('question_type', 'not in', ['single', 'multiple'])]}">
|
||||||
|
<field name="answer_ids">
|
||||||
|
<tree editable="bottom">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="answer_text" placeholder="Enter answer option..."/>
|
||||||
|
<field name="is_correct"/>
|
||||||
|
<field name="feedback" placeholder="Optional feedback..."/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
<div class="text-muted mt-2">
|
||||||
|
<i class="fa fa-lightbulb-o"/>
|
||||||
|
<span attrs="{'invisible': [('question_type', '!=', 'single')]}">
|
||||||
|
Mark exactly <strong>one</strong> answer as correct.
|
||||||
|
</span>
|
||||||
|
<span attrs="{'invisible': [('question_type', '!=', 'multiple')]}">
|
||||||
|
Mark <strong>all</strong> correct answers.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<!-- Ordering Items -->
|
||||||
|
<page string="Items to Order" name="ordering"
|
||||||
|
attrs="{'invisible': [('question_type', '!=', 'ordering')]}">
|
||||||
|
<field name="answer_ids">
|
||||||
|
<tree editable="bottom">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="answer_text" placeholder="Enter item..."/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
<div class="text-muted mt-2">
|
||||||
|
<i class="fa fa-lightbulb-o"/> Drag items to set the <strong>correct order</strong>. Users will see them shuffled.
|
||||||
|
</div>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<!-- Text Answer (Short Answer / Fill in the Blank) -->
|
||||||
|
<page string="Correct Answer" name="text_answer"
|
||||||
|
attrs="{'invisible': [('question_type', 'not in', ['short_answer', 'fill_blank'])]}">
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="correct_short_answer" placeholder="Expected answer..." attrs="{'required': [('question_type', 'in', ['short_answer', 'fill_blank'])]}"/>
|
||||||
|
<field name="answer_alternatives" placeholder="color, colour, Color"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="case_sensitive"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<div class="text-muted">
|
||||||
|
<i class="fa fa-lightbulb-o"/> Use <strong>Alternative Answers</strong> for synonyms or spelling variations (comma-separated).
|
||||||
|
</div>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<!-- Explanation & Media -->
|
||||||
|
<page string="Explanation & Media" name="explanation">
|
||||||
|
<group>
|
||||||
|
<group string="Question Image">
|
||||||
|
<field name="image" widget="image" class="oe_avatar" options="{'size': [200, 200]}" nolabel="1"/>
|
||||||
|
</group>
|
||||||
|
<group string="Explanation">
|
||||||
|
<field name="explanation" nolabel="1" placeholder="Why is this the correct answer? (Shown after answering)"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Quiz Attempt Views -->
|
||||||
|
<record id="view_genius_quiz_attempt_tree" model="ir.ui.view">
|
||||||
|
<field name="name">genius.quiz.attempt.tree</field>
|
||||||
|
<field name="model">genius.quiz.attempt</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree string="Quiz Attempts" create="false" edit="false">
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="quiz_id"/>
|
||||||
|
<field name="started_at"/>
|
||||||
|
<field name="score"/>
|
||||||
|
<field name="is_passed"/>
|
||||||
|
<field name="state"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_genius_quiz_attempt_form" model="ir.ui.view">
|
||||||
|
<field name="name">genius.quiz.attempt.form</field>
|
||||||
|
<field name="model">genius.quiz.attempt</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Quiz Attempt">
|
||||||
|
<header>
|
||||||
|
<button name="action_submit" string="Submit Quiz" type="object" state="in_progress" class="oe_highlight" attrs="{'invisible': [('state', '!=', 'in_progress')]}"/>
|
||||||
|
<field name="state" widget="statusbar" statusbar_visible="in_progress,submitted"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="quiz_id" readonly="1"/>
|
||||||
|
<field name="user_id" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="started_at" readonly="1"/>
|
||||||
|
<field name="score" attrs="{'invisible': [('state', '!=', 'submitted')]}"/>
|
||||||
|
<field name="is_passed" attrs="{'invisible': [('state', '!=', 'submitted')]}"/>
|
||||||
|
<field name="show_correct_answers" invisible="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- Success Messages -->
|
||||||
|
<div class="alert alert-success" role="alert" attrs="{'invisible': ['|', '|', ('state', '!=', 'submitted'), ('is_passed', '=', False), ('success_message', '!=', False)]}">
|
||||||
|
<strong>Congratulations!</strong> You passed this quiz.
|
||||||
|
</div>
|
||||||
|
<field name="success_message" widget="html" class="alert alert-success" attrs="{'invisible': ['|', '|', ('state', '!=', 'submitted'), ('is_passed', '=', False), ('success_message', '=', False)]}"/>
|
||||||
|
|
||||||
|
<!-- Failure Messages -->
|
||||||
|
<div class="alert alert-danger" role="alert" attrs="{'invisible': ['|', '|', ('state', '!=', 'submitted'), ('is_passed', '=', True), ('fail_message', '!=', False)]}">
|
||||||
|
<strong>Try Again!</strong> Only <field name="points_earned"/> points earned.
|
||||||
|
</div>
|
||||||
|
<field name="fail_message" widget="html" class="alert alert-danger" attrs="{'invisible': ['|', '|', ('state', '!=', 'submitted'), ('is_passed', '=', True), ('fail_message', '=', False)]}"/>
|
||||||
|
|
||||||
|
<notebook>
|
||||||
|
<page string="Questions">
|
||||||
|
<field name="response_ids">
|
||||||
|
<tree editable="bottom" create="0" delete="0">
|
||||||
|
<field name="question_id" readonly="1"/>
|
||||||
|
<field name="text_answer" attrs="{'readonly': [('parent.state', '=', 'submitted')]}"/>
|
||||||
|
<!-- Assume we need a way to select answers. Standard Odoo tree generic is hard for M2M selection.
|
||||||
|
Ideally this should be a form view or web client.
|
||||||
|
For now, just showing structure. -->
|
||||||
|
<!-- Domain ensures we only see answers for THIS question -->
|
||||||
|
<field name="selected_answer_ids" widget="many2many_tags"
|
||||||
|
domain="[('question_id', '=', question_id)]"
|
||||||
|
attrs="{'readonly': [('parent.state', '=', 'submitted')]}"/>
|
||||||
|
<field name="is_correct" readonly="1" attrs="{'invisible': [('parent.show_correct_answers', '=', False)]}"/>
|
||||||
|
<field name="explanation" widget="html" optional="hide" readonly="1" attrs="{'invisible': [('parent.show_correct_answers', '=', False)]}"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_genius_quiz_attempt" model="ir.actions.act_window">
|
||||||
|
<field name="name">My Attempts</field>
|
||||||
|
<field name="res_model">genius.quiz.attempt</field>
|
||||||
|
<field name="view_mode">tree,form</field>
|
||||||
|
<field name="domain">[('user_id', '=', uid)]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem id="menu_genius_quiz_attempt"
|
||||||
|
name="My Quizzes"
|
||||||
|
parent="menu_training"
|
||||||
|
action="action_genius_quiz_attempt"
|
||||||
|
sequence="20"
|
||||||
|
groups="tour_genius.group_genius_user"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- TREE VIEW -->
|
||||||
|
<record id="view_genius_reminder_tree" model="ir.ui.view">
|
||||||
|
<field name="name">genius.reminder.tree</field>
|
||||||
|
<field name="model">genius.reminder</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree string="Reminders" decoration-muted="state == 'cancelled'" decoration-danger="state == 'failed'" decoration-info="state == 'sent'">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="reminder_type"/>
|
||||||
|
<field name="scheduled_date"/>
|
||||||
|
<field name="state"/>
|
||||||
|
<field name="sent_date"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- FORM VIEW -->
|
||||||
|
<record id="view_genius_reminder_form" model="ir.ui.view">
|
||||||
|
<field name="name">genius.reminder.form</field>
|
||||||
|
<field name="model">genius.reminder</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Reminder">
|
||||||
|
<header>
|
||||||
|
<button name="action_send" string="Send Now" type="object" class="oe_highlight" states="pending"/>
|
||||||
|
<button name="action_cancel" string="Cancel" type="object" states="pending"/>
|
||||||
|
<field name="state" widget="statusbar" statusbar_visible="pending,sent,cancelled"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="reminder_type"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="scheduled_date"/>
|
||||||
|
<field name="sent_date" readonly="1"/>
|
||||||
|
<field name="is_recurring"/>
|
||||||
|
<field name="recurrence_interval" attrs="{'invisible': [('is_recurring', '=', False)]}"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Content">
|
||||||
|
<field name="message_body" widget="html" readonly="1"/>
|
||||||
|
<field name="custom_message" attrs="{'invisible': [('reminder_type', '!=', 'custom')]}"/>
|
||||||
|
</page>
|
||||||
|
<page string="Context">
|
||||||
|
<group>
|
||||||
|
<field name="topic_id"/>
|
||||||
|
<field name="plan_id"/>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
<div class="oe_chatter">
|
||||||
|
<field name="message_follower_ids"/>
|
||||||
|
<field name="message_ids"/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ACTION -->
|
||||||
|
<record id="action_genius_reminder" model="ir.actions.act_window">
|
||||||
|
<field name="name">Reminders</field>
|
||||||
|
<field name="res_model">genius.reminder</field>
|
||||||
|
<field name="view_mode">tree,form</field>
|
||||||
|
<field name="context">{'search_default_state': 'pending'}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- MENU (Instructor Only) -->
|
||||||
|
<menuitem id="menu_genius_reminder"
|
||||||
|
name="Reminders"
|
||||||
|
parent="menu_analytics"
|
||||||
|
action="action_genius_reminder"
|
||||||
|
sequence="50"
|
||||||
|
groups="tour_genius.group_genius_instructor"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- STEP TREE VIEW -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<record id="view_genius_topic_step_tree" model="ir.ui.view">
|
||||||
|
<field name="name">genius.topic.step.tree</field>
|
||||||
|
<field name="model">genius.topic.step</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree string="Tour Steps" default_order="topic_id, sequence">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="topic_id"/>
|
||||||
|
<field name="title"/>
|
||||||
|
<field name="css_selector"/>
|
||||||
|
<field name="step_type"/>
|
||||||
|
<field name="position"/>
|
||||||
|
<field name="active" widget="boolean_toggle"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- STEP FORM VIEW -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<record id="view_genius_topic_step_form" model="ir.ui.view">
|
||||||
|
<field name="name">genius.topic.step.form</field>
|
||||||
|
<field name="model">genius.topic.step</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Tour Step">
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_title">
|
||||||
|
<label for="title"/>
|
||||||
|
<h1>
|
||||||
|
<field name="title" placeholder="Step Title"/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<group string="Configuration">
|
||||||
|
<field name="topic_id"/>
|
||||||
|
<field name="sequence"/>
|
||||||
|
<field name="step_type"/>
|
||||||
|
<field name="position"/>
|
||||||
|
<field name="active"/>
|
||||||
|
</group>
|
||||||
|
<group string="Selector">
|
||||||
|
<field name="css_selector" widget="char"/>
|
||||||
|
<field name="extra_trigger" widget="char"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group attrs="{'invisible': [('step_type', '!=', 'input')]}">
|
||||||
|
<field name="input_value" placeholder="Value to type..."/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<notebook>
|
||||||
|
<page string="Instructions" name="instructions">
|
||||||
|
<field name="instruction" placeholder="Detailed instructions for this step..."/>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- STEP SEARCH VIEW -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<record id="view_genius_topic_step_search" model="ir.ui.view">
|
||||||
|
<field name="name">genius.topic.step.search</field>
|
||||||
|
<field name="model">genius.topic.step</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Steps">
|
||||||
|
<field name="title"/>
|
||||||
|
<field name="topic_id"/>
|
||||||
|
<field name="css_selector"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Active" name="active" domain="[('active', '=', True)]"/>
|
||||||
|
<filter string="Archived" name="archived" domain="[('active', '=', False)]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Click Steps" name="click" domain="[('step_type', '=', 'click')]"/>
|
||||||
|
<filter string="Input Steps" name="input" domain="[('step_type', '=', 'input')]"/>
|
||||||
|
<separator/>
|
||||||
|
<group expand="0" string="Group By">
|
||||||
|
<filter string="Tour" name="group_topic" context="{'group_by': 'topic_id'}"/>
|
||||||
|
<filter string="Step Type" name="group_type" context="{'group_by': 'step_type'}"/>
|
||||||
|
<filter string="Position" name="group_position" context="{'group_by': 'position'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- STEP ACTION -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<record id="action_genius_topic_step" model="ir.actions.act_window">
|
||||||
|
<field name="name">Tour Steps</field>
|
||||||
|
<field name="res_model">genius.topic.step</field>
|
||||||
|
<field name="view_mode">tree,form</field>
|
||||||
|
<field name="search_view_id" ref="view_genius_topic_step_search"/>
|
||||||
|
<field name="context">{'search_default_active': 1}</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
No steps found
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Steps are usually created from the Tour form or via the Recorder tool.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- STEP MENU (Under Configuration) -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<menuitem id="menu_genius_topic_step"
|
||||||
|
name="Tour Steps"
|
||||||
|
parent="menu_configuration"
|
||||||
|
action="action_genius_topic_step"
|
||||||
|
sequence="20"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- Topic/Tour Views -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<!-- Topic Tree View -->
|
||||||
|
<record id="view_genius_topic_tree" model="ir.ui.view">
|
||||||
|
<field name="name">genius.topic.tree</field>
|
||||||
|
<field name="model">genius.topic</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree string="Tours">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="icon"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="module_id"/>
|
||||||
|
<field name="step_count"/>
|
||||||
|
<field name="consumed_count"/>
|
||||||
|
<field name="state" decoration-info="state == 'draft'" decoration-success="state == 'published'"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Embedded Step Tree View (for Smart Button) -->
|
||||||
|
<record id="view_genius_topic_step_tree_embedded" model="ir.ui.view">
|
||||||
|
<field name="name">genius.topic.step.tree.embedded</field>
|
||||||
|
<field name="model">genius.topic.step</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree string="Steps" editable="bottom">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="title"/>
|
||||||
|
<field name="step_type"/>
|
||||||
|
<field name="css_selector" string="Trigger (Selector)"/>
|
||||||
|
<field name="position"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Topic Form View -->
|
||||||
|
<record id="view_genius_topic_form" model="ir.ui.view">
|
||||||
|
<field name="name">genius.topic.form</field>
|
||||||
|
<field name="model">genius.topic</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Tour">
|
||||||
|
<header>
|
||||||
|
<!-- Main Actions -->
|
||||||
|
<button name="action_publish" type="object"
|
||||||
|
string="Publish" class="btn btn-primary"
|
||||||
|
attrs="{'invisible': [('state', '=', 'published')]}"
|
||||||
|
groups="tour_genius.group_genius_instructor"/>
|
||||||
|
<button name="action_start_recording" type="object"
|
||||||
|
string="Record Steps" class="btn btn-primary" icon="fa-video-camera"
|
||||||
|
groups="tour_genius.group_genius_admin"/>
|
||||||
|
|
||||||
|
<!-- Secondary Actions -->
|
||||||
|
<button name="action_test_tour" type="object"
|
||||||
|
string="Test Tour" class="btn btn-secondary" icon="fa-flask"
|
||||||
|
groups="tour_genius.group_genius_instructor"/>
|
||||||
|
<button name="action_open_topic" type="object"
|
||||||
|
string="Run Tour" class="btn btn-secondary" icon="fa-play"
|
||||||
|
attrs="{'invisible': [('state', '!=', 'published')]}"/>
|
||||||
|
<button name="action_set_draft" type="object"
|
||||||
|
string="Set to Draft" class="btn btn-secondary"
|
||||||
|
attrs="{'invisible': [('state', '=', 'draft')]}"
|
||||||
|
groups="tour_genius.group_genius_instructor"/>
|
||||||
|
|
||||||
|
<field name="state" widget="statusbar" statusbar_visible="draft,published"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_button_box" name="button_box">
|
||||||
|
<button name="action_view_steps" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-list-ol">
|
||||||
|
<field name="step_count" widget="statinfo" string="Steps"/>
|
||||||
|
</button>
|
||||||
|
<button class="oe_stat_button" icon="fa-users" type="object" name="action_view_completions">
|
||||||
|
<field name="consumed_count" widget="statinfo" string="Completions"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<field name="image" widget="image" class="oe_avatar"/>
|
||||||
|
<div class="oe_title">
|
||||||
|
<label for="name"/>
|
||||||
|
<h1>
|
||||||
|
<field name="name" placeholder="Tour Title"/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<field name="module_xml_id" invisible="1"/>
|
||||||
|
<group string="Target">
|
||||||
|
<field name="plan_id"/>
|
||||||
|
<field name="module_id" options="{'no_create': True}"/>
|
||||||
|
<field name="starting_url" widget="url" placeholder="/web#action=..."/>
|
||||||
|
</group>
|
||||||
|
<group string="Classification">
|
||||||
|
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/>
|
||||||
|
<field name="quiz_id" context="{'default_name': name + ' Quiz'}"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Tour Steps" name="steps">
|
||||||
|
<div class="alert alert-info genius-tip-box" role="alert">
|
||||||
|
<i class="fa fa-lightbulb-o mr-2"/> <strong>Tip:</strong> Use the <em>"Record Steps"</em> button above to visually capture steps.
|
||||||
|
</div>
|
||||||
|
<field name="step_ids">
|
||||||
|
<tree editable="bottom">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="title"/>
|
||||||
|
<field name="instruction"/>
|
||||||
|
<field name="css_selector"/>
|
||||||
|
<field name="step_type"/>
|
||||||
|
<field name="input_value" optional="hide" attrs="{'invisible': [('step_type', '!=', 'input')]}"/>
|
||||||
|
<field name="position"/>
|
||||||
|
<field name="extra_trigger" optional="hide"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
<page string="Resources" name="resources">
|
||||||
|
<group>
|
||||||
|
<field name="video_url" widget="url" placeholder="https://www.youtube.com/..."/>
|
||||||
|
<field name="document"/>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Topic Search View -->
|
||||||
|
<record id="view_genius_topic_search" model="ir.ui.view">
|
||||||
|
<field name="name">genius.topic.search</field>
|
||||||
|
<field name="model">genius.topic</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Tours">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="module_id"/>
|
||||||
|
<filter name="my_completed" string="Completed"
|
||||||
|
domain="[('consumed_user_ids', 'in', [uid])]"/>
|
||||||
|
<filter name="has_steps" string="With Steps"
|
||||||
|
domain="[('step_count', '>', 0)]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_draft" string="Draft" domain="[('state', '=', 'draft')]"/>
|
||||||
|
<filter name="filter_published" string="Published" domain="[('state', '=', 'published')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="archived" string="Archived" domain="[('active', '=', False)]"/>
|
||||||
|
<group expand="0" string="Group By">
|
||||||
|
<filter string="Module" name="group_by_module" context="{'group_by': 'module_id'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Topic Action -->
|
||||||
|
<record id="action_genius_topic" model="ir.actions.act_window">
|
||||||
|
<field name="name">Tours</field>
|
||||||
|
<field name="res_model">genius.topic</field>
|
||||||
|
<field name="view_mode">tree,form</field>
|
||||||
|
<field name="search_view_id" ref="view_genius_topic_search"/>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Create your first tour!
|
||||||
|
</p>
|
||||||
|
<p>Tours guide users through Odoo screens step by step.</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Menu Item -->
|
||||||
|
<menuitem id="menu_training_topics"
|
||||||
|
name="Tours"
|
||||||
|
parent="menu_training"
|
||||||
|
action="action_genius_topic"
|
||||||
|
sequence="20"
|
||||||
|
groups="tour_genius.group_genius_instructor"/>
|
||||||
|
</odoo>
|
||||||
Loading…
Reference in New Issue