diff --git a/odex25_base/system_dashboard_classic/__init__.py b/odex25_base/system_dashboard_classic/__init__.py
index 3cbd8bc15..5305644df 100644
--- a/odex25_base/system_dashboard_classic/__init__.py
+++ b/odex25_base/system_dashboard_classic/__init__.py
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
-#from . import controllers
-from . import models
\ No newline at end of file
+from . import models
\ No newline at end of file
diff --git a/odex25_base/system_dashboard_classic/__manifest__.py b/odex25_base/system_dashboard_classic/__manifest__.py
index d9b04a3c7..479899910 100644
--- a/odex25_base/system_dashboard_classic/__manifest__.py
+++ b/odex25_base/system_dashboard_classic/__manifest__.py
@@ -1,34 +1,77 @@
# -*- coding: utf-8 -*-
{
- 'name': "System Board",
-
- 'summary': """
- This module allows you to configure dashboard for different states or stages for specific model.""",
-
+ 'name': "System Dashboard",
+ 'summary': "Configurable dashboard for employee self-service and manager approvals",
'description': """
- This module allows you to configure dashboard for different users
- 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.""",
+System Dashboard Classic
+========================
+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",
- # 'website': "http://www.yourcompany.com",
-
- 'category': 'Uncategorized',
- 'version': '0.1',
- 'application':True,
-
- # any module necessary for this one to work correctly
- 'depends': ['base'],
-
- # always loaded
+ 'category': 'Human Resources/Dashboard',
+ 'version': '14.0.1.0.0',
+ 'license': 'LGPL-3',
+ 'application': True,
+
+ # Required Dependencies
+ 'depends': [
+ #'hr_base', MUST BE FIRST! exp_hr_payroll uses its groups but doesn't depend on it
+ 'base', # Core Odoo
+ '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': [
'security/ir.model.access.csv',
'security/security.xml',
'views/system_dashboard.xml',
'views/config.xml',
+ 'views/dashboard_settings.xml',
],
+
+ # QWeb templates
'qweb': [
- "static/src/xml/*.xml",
+ 'static/src/xml/*.xml',
],
+
+ # Assets are loaded via views/system_dashboard.xml
+ 'installable': True,
+ 'auto_install': False,
}
diff --git a/odex25_base/system_dashboard_classic/controllers/__init__.py b/odex25_base/system_dashboard_classic/controllers/__init__.py
deleted file mode 100644
index 12bcb5b8f..000000000
--- a/odex25_base/system_dashboard_classic/controllers/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# -*- coding: utf-8 -*-
-
-#from . import controllers
\ No newline at end of file
diff --git a/odex25_base/system_dashboard_classic/controllers/controllers.py b/odex25_base/system_dashboard_classic/controllers/controllers.py
deleted file mode 100644
index 4b353396c..000000000
--- a/odex25_base/system_dashboard_classic/controllers/controllers.py
+++ /dev/null
@@ -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})
-
-
\ No newline at end of file
diff --git a/odex25_base/system_dashboard_classic/data/dashboard_data.xml b/odex25_base/system_dashboard_classic/data/dashboard_data.xml
deleted file mode 100644
index d08d528a7..000000000
--- a/odex25_base/system_dashboard_classic/data/dashboard_data.xml
+++ /dev/null
@@ -1,55 +0,0 @@
-
-
-
-
-
-
diff --git a/odex25_base/system_dashboard_classic/demo/demo.xml b/odex25_base/system_dashboard_classic/demo/demo.xml
index 457e0aef2..1edc1ea44 100644
--- a/odex25_base/system_dashboard_classic/demo/demo.xml
+++ b/odex25_base/system_dashboard_classic/demo/demo.xml
@@ -1,30 +1,5 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
\ No newline at end of file
diff --git a/odex25_base/system_dashboard_classic/i18n/ar_001.po b/odex25_base/system_dashboard_classic/i18n/ar_001.po
index 2d1cf0d73..90214a7b5 100644
--- a/odex25_base/system_dashboard_classic/i18n/ar_001.po
+++ b/odex25_base/system_dashboard_classic/i18n/ar_001.po
@@ -14,7 +14,8 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\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"
#. module: system_dashboard_classic
@@ -61,8 +62,8 @@ msgstr "الحضور والانصراف"
#: model:ir.actions.act_window,name:system_dashboard_classic.base_dashboard_action
#: model:ir.model,name:system_dashboard_classic.model_base_dashbord
#: model:ir.ui.menu,name:system_dashboard_classic.base_dashboard
-msgid "Base Dashboard"
-msgstr "لوحة المعلومات الاساسية"
+msgid "Dashboard Builder"
+msgstr "تصميم الداشبورد"
#. module: system_dashboard_classic
#: model:ir.model.fields,field_description:system_dashboard_classic.field_base_dashbord_line__board_id
@@ -81,8 +82,8 @@ msgstr "اسم النموذج"
#. module: system_dashboard_classic
#: model:ir.ui.menu,name:system_dashboard_classic.base_dashboard_root
-msgid "Configrutions"
-msgstr "الاعدادات"
+msgid "Configuration"
+msgstr "الإعدادات"
#. module: system_dashboard_classic
#: 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
#, python-format
msgid "Salary Slips"
-msgstr "قسيمة الراتب"
+msgstr "قسائم الراتب"
#. module: system_dashboard_classic
#: model:ir.ui.menu,name:system_dashboard_classic.menu_self_service_service
-msgid "Self Service Screen"
+msgid "Self Service"
msgstr "الخدمة الذاتية"
#. module: system_dashboard_classic
@@ -246,6 +247,7 @@ msgstr "بدون تأثير مالي؟"
#. 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_line__sequence
msgid "sequence"
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.self_service_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
-msgid "System Dashboard"
-msgstr "لوحة الخدمة الذاتية"
+msgid "Dashboard"
+msgstr "لوحة المعلومات"
#. module: system_dashboard_classic
#: code:addons/system_dashboard_classic/models/config.py:0
@@ -403,11 +401,6 @@ msgstr ""
msgid "records to "
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
#: model:ir.model,name:system_dashboard_classic.model_stage_stage
msgid "stage.stage"
@@ -437,4 +430,147 @@ msgstr "حذف المراحل"
#. module: system_dashboard_classic
#: model:ir.model.fields,field_description:system_dashboard_classic.field_base_dashbord__search_field
msgid "Search field"
-msgstr "حقل البحث"
\ No newline at end of file
+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 "خدمات الداشبورد"
diff --git a/odex25_base/system_dashboard_classic/models/__init__.py b/odex25_base/system_dashboard_classic/models/__init__.py
index e47a205d9..d795b7333 100644
--- a/odex25_base/system_dashboard_classic/models/__init__.py
+++ b/odex25_base/system_dashboard_classic/models/__init__.py
@@ -1,2 +1,3 @@
from . import config
-from . import models
\ No newline at end of file
+from . import models
+from . import res_users
\ No newline at end of file
diff --git a/odex25_base/system_dashboard_classic/models/config.py b/odex25_base/system_dashboard_classic/models/config.py
index c557f3da5..1d24f164d 100644
--- a/odex25_base/system_dashboard_classic/models/config.py
+++ b/odex25_base/system_dashboard_classic/models/config.py
@@ -3,7 +3,7 @@ from odoo.exceptions import ValidationError
class BaseDashboard(models.Model):
_name = 'base.dashbord'
- _description = 'Base Dashboard'
+ _description = 'Dashboard Builder'
_order = 'sequence'
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'
'
+ 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'
'
+
card_image = fields.Binary(
string='Card Image',
)
@@ -431,3 +464,208 @@ class StageStage(models.Model):
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)
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'),
+ }
diff --git a/odex25_base/system_dashboard_classic/models/models.py b/odex25_base/system_dashboard_classic/models/models.py
index b4ebdc607..6ae3a517c 100644
--- a/odex25_base/system_dashboard_classic/models/models.py
+++ b/odex25_base/system_dashboard_classic/models/models.py
@@ -1,11 +1,10 @@
from odoo import models, fields, api, _
-from odoo.exceptions import ValidationError , AccessError
-from odoo.tools.safe_eval import safe_eval
+from odoo.exceptions import AccessError, UserError
import ast
from datetime import datetime, date
from dateutil.relativedelta import relativedelta, SA, SU, MO
-import calendar
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
values = {'user': [], 'timesheet': [], 'leaves': [], 'payroll': [], 'attendance': [], 'employee': [],
- 'cards': []}
+ 'cards': [], 'attendance_hours': [], 'chart_types': {}, 'card_orders': {}}
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_id = self.env['res.users'].sudo().search_read(
@@ -43,46 +90,116 @@ class SystemDashboard(models.Model):
employee_object = self.env['hr.employee'].sudo().search(
[('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 = {}
leaves_data = {}
payroll_data = {}
timesheet_data = {}
+ attendance_hours_data = {}
###################################################
# 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')])
if is_hr_attendance_module and is_hr_attendance_module.state == 'installed':
- attendance = self.env['attendance.attendance'].sudo().search(
- [('employee_id', '=', employee_object.id), ('action_date', '=', t_date)])
- is_attendance = True
- if not attendance:
- is_attendance = False
- if attendance and attendance[-1].action == 'sign_out':
- is_attendance = False
+ # ATTENDANCE LOGIC IMPROVEMENT:
+ # Fetch the absolute latest record regardless of date (e.g. yesterday, mobile app entry)
+ # This ensures we show the REAL status.
+ last_attendance = self.env['attendance.attendance'].sudo().search(
+ [('employee_id', '=', employee_object.id)], limit=1, order="name desc")
+
+ 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)
- if attendance:
- time_object = fields.Datetime.from_string(attendance[-1].name)
+ time_in_timezone = False
+
+ 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)
+
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
###############################################
# compute leaves taken and remaing leaves
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(
[('employee_id', '=', employee_object.id), ('holiday_status_id.leave_type', '=', 'annual'),
- ('type', '=', 'add'), ('check_allocation_view', '=', 'balance')], limit=1)
- taken = leaves.leaves_taken
- remaining_leaves = leaves.remaining_leaves
+ ('type', '=', 'add'), ('check_allocation_view', '=', 'balance')])
+
+ # 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,
'remaining_leaves': remaining_leaves
-
})
###################################################
@@ -91,17 +208,47 @@ class SystemDashboard(models.Model):
last_day = date(date.today().year, 12, 31)
is_payslip_module = self.env['ir.module.module'].sudo().search([('name', '=', 'exp_hr_payroll')])
- if is_payslip_module and is_payslip_module.state == 'installed':
- payslip = self.env['hr.payslip'].sudo().search_count(
+ is_payslip_installed = bool(is_payslip_module and is_payslip_module.state == 'installed')
+
+ # 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)])
- 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
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
days_off_name = []
days_special_name = []
@@ -161,25 +308,47 @@ class SystemDashboard(models.Model):
star_of_week = SA
# calcultion of all working hours and return done working hours and remaining
- 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
- domain = [('employee_id', '=', employee_object.id), '&', (
- 'date', '>=', (date.today() + relativedelta(weeks=-1, days=1, weekday=star_of_week)).strftime('%Y-%m-%d')),
- ('date', '<=', (date.today() + relativedelta(weekday=6)).strftime('%Y-%m-%d')), ]
- timesheet = self.env['account.analytic.line'].sudo().search(domain)
- day_name_list = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', ]
- done_hours = 0
- for sheet in timesheet:
- day = datetime.strptime(str(sheet.date), '%Y-%m-%d').weekday()
- day_name = day_name_list[day]
- if day_name not in days_off_name:
- done_hours = done_hours + sheet.unit_amount
+ # ATTENDANCE/TIMESHEET LOGIC IMPROVEMENT:
+ # Replaced manual calculation with standard Odoo calendar method
+ # This ensures Public Holidays (Global Leaves) are correctly handled
+
+ # Determine start/end of week logic (kept from original code to maintain week start preference)
+ # star_of_week is determined above (SU, SA, MO)
+
+ start_date = date.today() + relativedelta(weeks=-1, days=1, weekday=star_of_week)
+ end_date = start_date + relativedelta(days=6) # Ensure strictly 7 days week range
+ # Note: start_date is calculated based on star_of_week logic (e.g. last Saturday)
+
+ total_wroking_hours = 0
+ if employee_object.resource_calendar_id:
+ # Use standard Odoo method
+ 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)
- timesheet_data.update({'taken': don_hours,
- 'timesheet_remaining': total_wroking_hours - don_hours
+ domain = [('employee_id', '=', employee_object.id),
+ ('date', '>=', start_date),
+ ('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,
user): # call method to return if user is in one of the groups in current line
# static vars for the card
- # TODO: find a way to fix translation,
- # the filed is name is translateable,
- # but its not loaded when change lang,
- # so we handel it by searching on translation object.
- 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
- value = "text"
- card_name = model.name if model.name else model.model_id.name
- # if self.env.user.lang == 'en_US':
- # card_name = model.name if model.name else model.model_id.name
- # else:
- # card_name = value
- mod['name'] = card_name
- mod['name_arabic'] = card_name
- mod['name_english'] = card_name
+ # FIX: Fetch explicit translations for bilingual support
+ # model.name returns current user lang, so we force context
+ card_name_ar = model.with_context(lang='ar_001').name or model.with_context(lang='ar_SY').name or model.name
+ card_name_en = model.with_context(lang='en_US').name or model.name
+
+ # Fallback if model name is empty (use model description)
+ if not card_name_ar:
+ card_name_ar = model.model_id.with_context(lang='ar_001').name or model.model_id.name
+ if not card_name_en:
+ card_name_en = model.model_id.with_context(lang='en_US').name or model.model_id.name
+
+ mod['name'] = card_name_ar if self.env.user.lang in ['ar_001', 'ar_SY'] else card_name_en
+ mod['name_arabic'] = card_name_ar
+ mod['name_english'] = card_name_en
mod['model'] = model.model_name
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
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:
# service_action_domain.append(('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({
'type': 'selfs',
'name': card_name,
@@ -383,6 +565,8 @@ class SystemDashboard(models.Model):
'model': model.model_name,
'state_count': self.env[model.model_name].search_count(service_action_domain),
'image': model.card_image,
+ 'icon_type': model_icon_type,
+ 'icon_name': model_icon_name,
'form_view': model.form_view_id.id,
'list_view': model.list_view_id.id,
'js_domain': service_action_domain,
@@ -399,74 +583,291 @@ class SystemDashboard(models.Model):
values['payroll'].append(payroll_data)
values['attendance'].append(attendance_date)
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
+
+ # 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
+ 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
- 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
- attendance_status = ctx.get('check', False)
t_date = date.today()
user = self.env.user
- 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'}
- vals_out = {'employee_id': employee_object.id,'action': 'sign_out','action_type': 'manual'}
+ employee_object = self.env['hr.employee'].sudo().search([('user_id', '=', user.id)], limit=1)
+
+ if not employee_object:
+ 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']
- 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(
- [('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)
+
+ # 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:
time_object = fields.Datetime.from_string(last_attendance.name)
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:
- is_attendance = True
- # 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)
+ time_in_timezone = False
attendance_response = {
'is_attendance': is_attendance,
- 'time': time_in_timezone if last_attendance else False
+ 'time': time_in_timezone
}
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
diff --git a/odex25_base/system_dashboard_classic/models/res_users.py b/odex25_base/system_dashboard_classic/models/res_users.py
new file mode 100644
index 000000000..7541717da
--- /dev/null
+++ b/odex25_base/system_dashboard_classic/models/res_users.py
@@ -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'
+ )
diff --git a/odex25_base/system_dashboard_classic/security/secuirty.xml b/odex25_base/system_dashboard_classic/security/secuirty.xml
deleted file mode 100644
index 83db7be4f..000000000
--- a/odex25_base/system_dashboard_classic/security/secuirty.xml
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
-
-
-
- System Dashboard
- User access level for System Board module
- 20
-
-
-
-
- System Dashboard Manager
-
-
-
-
- System Dashboard Configurations
-
-
-
-
-
\ No newline at end of file
diff --git a/odex25_base/system_dashboard_classic/static/description/icon.png b/odex25_base/system_dashboard_classic/static/description/icon.png
index 75990761e..52fb3c4cd 100644
Binary files a/odex25_base/system_dashboard_classic/static/description/icon.png and b/odex25_base/system_dashboard_classic/static/description/icon.png differ
diff --git a/odex25_base/system_dashboard_classic/static/src/icons/login.svg b/odex25_base/system_dashboard_classic/static/src/icons/login.svg
new file mode 100644
index 000000000..1446fc8f9
--- /dev/null
+++ b/odex25_base/system_dashboard_classic/static/src/icons/login.svg
@@ -0,0 +1,15 @@
+
+
+
diff --git a/odex25_base/system_dashboard_classic/static/src/img/Attendence.png b/odex25_base/system_dashboard_classic/static/src/img/Attendence.png
deleted file mode 100644
index bb1e4bf85..000000000
Binary files a/odex25_base/system_dashboard_classic/static/src/img/Attendence.png and /dev/null differ
diff --git a/odex25_base/system_dashboard_classic/static/src/img/icon.png b/odex25_base/system_dashboard_classic/static/src/img/icon.png
deleted file mode 100644
index 689ebc71e..000000000
Binary files a/odex25_base/system_dashboard_classic/static/src/img/icon.png and /dev/null differ
diff --git a/odex25_base/system_dashboard_classic/static/src/js/genius_enhancements.js b/odex25_base/system_dashboard_classic/static/src/js/genius_enhancements.js
new file mode 100644
index 000000000..7aa72535f
--- /dev/null
+++ b/odex25_base/system_dashboard_classic/static/src/js/genius_enhancements.js
@@ -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 = '
' +
+ '
' +
+ '' +
+ '' +
+ '×' +
+ '
';
+
+ 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 };
+});
diff --git a/odex25_base/system_dashboard_classic/static/src/js/pluscharts.js b/odex25_base/system_dashboard_classic/static/src/js/pluscharts.js
index 0e11776d6..e75d80038 100644
--- a/odex25_base/system_dashboard_classic/static/src/js/pluscharts.js
+++ b/odex25_base/system_dashboard_classic/static/src/js/pluscharts.js
@@ -1224,7 +1224,7 @@ __webpack_require__.r(__webpack_exports__);
var initTooltip = (bindedElement, data, i) => {
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;
});
positionTooltip(bindedElement);
diff --git a/odex25_base/system_dashboard_classic/static/src/js/system_dashboard.js b/odex25_base/system_dashboard_classic/static/src/js/system_dashboard.js
deleted file mode 100644
index 3c9e68cd5..000000000
--- a/odex25_base/system_dashboard_classic/static/src/js/system_dashboard.js
+++ /dev/null
@@ -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': 'مشاهدة التفاصيل ',
- 'en': 'Show Details '
- }
-}];
-
-/*
- 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('' + data.name + '').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('
' + data.lines[index].count_state_click + '
');
- $(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('
' + data.lines[index].count_state_follow + '
');
- $(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 = '';
- $('#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 = '';
- $('#check_button').html(checkin_button).removeClass('checkout-btn').addClass('checkin-btn');
- $('.last-checkin-section').html(check);
- $('.attendance-img-section').html(image);
- }
-}
diff --git a/odex25_base/system_dashboard_classic/static/src/js/system_dashboard_self_service.js b/odex25_base/system_dashboard_classic/static/src/js/system_dashboard_self_service.js
index 4e691c555..787473eaf 100644
--- a/odex25_base/system_dashboard_classic/static/src/js/system_dashboard_self_service.js
+++ b/odex25_base/system_dashboard_classic/static/src/js/system_dashboard_self_service.js
@@ -16,6 +16,176 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
var functions = [];
window.click_actions = [];
+ /**
+ * Convert hex color to RGB components
+ * @param {string} hex - Color in hex format (#RRGGBB)
+ * @returns {object|null} RGB components {r, g, b}
+ */
+ function hexToRgbComponents(hex) {
+ if (!hex || typeof hex !== 'string') return null;
+ var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+ return result ? {
+ r: parseInt(result[1], 16),
+ g: parseInt(result[2], 16),
+ b: parseInt(result[3], 16)
+ } : null;
+ }
+
+ /**
+ * Lighten or darken a hex color
+ * @param {string} hex - Color in hex format
+ * @param {number} percent - Positive = lighter, Negative = darker
+ * @returns {string} Adjusted color in hex format
+ */
+ function adjustColor(hex, percent) {
+ if (!hex || typeof hex !== 'string') return hex;
+ // Remove # if present
+ hex = hex.replace(/^\s*#|\s*$/g, '');
+ // Convert 3-char hex to 6-char
+ if (hex.length === 3) {
+ hex = hex.replace(/(.)/g, '$1$1');
+ }
+ var r = parseInt(hex.substr(0, 2), 16);
+ var g = parseInt(hex.substr(2, 2), 16);
+ var b = parseInt(hex.substr(4, 2), 16);
+
+ r = Math.round(Math.min(255, Math.max(0, r + (r * percent / 100))));
+ g = Math.round(Math.min(255, Math.max(0, g + (g * percent / 100))));
+ b = Math.round(Math.min(255, Math.max(0, b + (b * percent / 100))));
+
+ return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
+ }
+
+ /**
+ * Animate a number from 0 to target value with smooth easing
+ * @param {jQuery|Element} element - The element to animate
+ * @param {number} targetValue - Final number to display
+ * @param {number} duration - Animation duration in ms (default 1000)
+ * @param {string} suffix - Optional suffix like 'days', 'hours' (default '')
+ * @param {number} decimals - Decimal places to show (default 0)
+ */
+ function animateCounter(element, targetValue, duration, suffix, decimals) {
+ duration = duration || 1000;
+ suffix = suffix || '';
+ decimals = decimals || 0;
+
+ var $el = $(element);
+ if (!$el.length || isNaN(targetValue)) return;
+
+ var startTime = null;
+ var startValue = 0;
+
+ // Easing function - easeOutQuad for smooth deceleration
+ function easeOutQuad(t) {
+ return t * (2 - t);
+ }
+
+ function animate(currentTime) {
+ if (!startTime) startTime = currentTime;
+ var elapsed = currentTime - startTime;
+ var progress = Math.min(elapsed / duration, 1);
+ var easedProgress = easeOutQuad(progress);
+ var currentValue = startValue + (targetValue - startValue) * easedProgress;
+
+ // Format the number
+ var displayValue = decimals > 0 ? currentValue.toFixed(decimals) : Math.round(currentValue);
+ $el.text(displayValue + (suffix ? ' ' + suffix : ''));
+
+ if (progress < 1) {
+ requestAnimationFrame(animate);
+ }
+ }
+
+ requestAnimationFrame(animate);
+ }
+
+ /**
+ * Show celebration modal/banner for birthday or work anniversary
+ * @param {string} type - 'birthday' or 'anniversary'
+ * @param {number} years - Years of service (for anniversary)
+ */
+ function showCelebration(type, years) {
+ var isRtl = $('body').hasClass('o_rtl');
+ var title, message, emoji, badgeClass;
+
+ if (type === 'birthday') {
+ emoji = '🎂';
+ badgeClass = 'birthday-mode';
+ title = isRtl ? 'عيد ميلاد سعيد!' : 'Happy Birthday!';
+ // Gendered pronouns: لك (male) / لكِ (female)
+ var genderInfo = window.dashboardGenderInfo || {pronoun_you: 'ك'};
+ var forYou = 'ل' + genderInfo.pronoun_you; // لك or لكِ
+ message = isRtl ? ('نتمنى ' + forYou + ' عاماً جديداً مليئاً بالنجاح والسعادة ✨') : 'Wishing you a wonderful year ahead! ✨';
+ } else if (type === 'anniversary') {
+ emoji = '⭐';
+ badgeClass = 'anniversary-mode';
+ title = isRtl ? 'شكراً لعطائك!' : 'Thank You!';
+ message = isRtl
+ ? 'نقدر إخلاصك وتفانيك في العمل'
+ : 'We appreciate your dedication';
+ } else {
+ return;
+ }
+
+ // Add celebration mode class to entire dashboard (persistent)
+ $('.dashboard-container').addClass('celebration-mode ' + badgeClass);
+
+ // Add golden glow ring around employee photo
+ $('.img-box').addClass('celebration-glow');
+
+ // Add appreciation ribbon at bottom of header for anniversary
+ if (type === 'anniversary' && $('.appreciation-ribbon').length === 0) {
+ var ribbonMessage = isRtl
+ ? 'بمناسبة إتمامك عامًا جديدًا معنا، نثمّن عطائك ونتطلع للمزيد ⭐'
+ : '⭐ Celebrating another year with us. We value your contribution and look forward to more!';
+ var $ribbon = $('
' +
+ '' + ribbonMessage + '' +
+ '
');
+ setTimeout(function() {
+ $('.dashboard-header').append($ribbon);
+ }, 1000);
+ }
+
+ // Trigger initial confetti burst
+ if (window.confetti) {
+ setTimeout(function() {
+ confetti({
+ particleCount: 150,
+ spread: 100,
+ origin: { y: 0.3, x: 0.5 }
+ });
+ }, 500);
+
+ // Subtle continuous confetti every 30 seconds (non-intrusive)
+ setInterval(function() {
+ if ($('.dashboard-container').hasClass('celebration-mode')) {
+ confetti({
+ particleCount: 30,
+ spread: 60,
+ origin: { y: 0.2, x: Math.random() },
+ colors: type === 'birthday' ? ['#ff69b4', '#ff1493', '#ffd700'] : ['#4facfe', '#00f2fe', '#ffd700']
+ });
+ }
+ }, 30000);
+ }
+ }
+
+ /**
+ * Get time-based greeting message
+ * @returns {string} Greeting in Arabic or English based on RTL
+ */
+ function getGreeting() {
+ var hour = new Date().getHours();
+ var isRtl = $('body').hasClass('o_rtl');
+ if (hour >= 6 && hour < 12) {
+ return isRtl ? 'صباح الخير' : 'Good Morning';
+ } else if (hour >= 12 && hour < 18) {
+ return isRtl ? 'مساء الخير' : 'Good Afternoon';
+ } else {
+ return isRtl ? 'مرحباً' : 'Hello';
+ }
+ }
+
var SystemDashboardView = AbstractAction.extend({
template: 'system_dashboard_classic.self_service_dashboard',
init: function(parent, context) {
@@ -23,38 +193,145 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
var data = [];
var self = this;
window.selfOb = this;
+ // Store interval ID for cleanup on destroy
+ this.pollingIntervalId = null;
if (context.tag == 'system_dashboard_classic.dashboard_self_services') {
self._rpc({
model: 'system_dashboard_classic.dashboard',
method: 'get_data',
}, []).then(function(result) {
window.resultX = result;
+
+ // IMMEDIATE CSS VARIABLE APPLICATION - Apply colors before any rendering
+ // This prevents the "flash of green/default" issue by setting CSS vars synchronously
+ if (result.chart_colors) {
+ var root = document.documentElement;
+ var primary = result.chart_colors.primary || '#0891b2';
+ var secondary = result.chart_colors.secondary || '#1e293b';
+ var warning = result.chart_colors.warning || '#f59e0b';
+ var success = result.chart_colors.success || '#10b981';
+
+ // Apply primary color and variants
+ root.style.setProperty('--dash-primary', primary);
+ root.style.setProperty('--dash-primary-light', adjustColor(primary, 30));
+ root.style.setProperty('--dash-primary-dark', adjustColor(primary, -20));
+
+ // Apply secondary color
+ root.style.setProperty('--dash-secondary', secondary);
+ root.style.setProperty('--dash-secondary-light', adjustColor(secondary, 20));
+
+ // Apply status colors
+ root.style.setProperty('--dash-success', success);
+ root.style.setProperty('--dash-warning', warning);
+
+ // Generate RGB values for rgba() usage
+ var rgb = hexToRgbComponents(primary);
+ if (rgb) {
+ root.style.setProperty('--dash-primary-rgb', rgb.r + ', ' + rgb.g + ', ' + rgb.b);
+ }
+
+ // Cache colors in localStorage for instant loading on next page load
+ // The inline script in system_dashboard.xml reads this BEFORE CSS loads
+ try {
+ localStorage.setItem('dashboard_colors', JSON.stringify({
+ primary: primary,
+ primaryLight: adjustColor(primary, 30),
+ primaryDark: adjustColor(primary, -20),
+ secondary: secondary,
+ warning: warning,
+ success: success
+ }));
+ } catch(e) {}
+ }
+
+ // ==========================================
+ // CELEBRATION TRIGGER: Birthday & Anniversary
+ // ==========================================
+ if (result.celebration) {
+ // Show birthday celebration first (if applicable)
+ if (result.celebration.is_birthday) {
+ showCelebration('birthday');
+ }
+ // Show anniversary celebration (with delay if both apply)
+ if (result.celebration.is_anniversary && result.celebration.anniversary_years > 0) {
+ var delay = result.celebration.is_birthday ? 2000 : 0;
+ setTimeout(function() {
+ showCelebration('anniversary', result.celebration.anniversary_years);
+ }, delay);
+ }
+ }
+
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) {
+ var employeeId = result.employee[0][0].id;
+ // Make ONLY photo clickable to HR record
$(".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]);
+ .addClass('clickable-profile')
+ .off('click').on('click', function() {
+ self.do_action({
+ type: 'ir.actions.act_window',
+ res_model: 'hr.employee',
+ res_id: employeeId,
+ views: [[false, 'form']],
+ target: 'current'
+ });
+ });
+ // Employee name with greeting above (use celebration greeting if active)
+ var greetingText = getGreeting();
+
+ // Get gender info for personalized greeting
+ var genderInfo = result.gender_info || {gender: 'male', honorific: 'أستاذ'};
+ var honorific = genderInfo.honorific || 'أستاذ';
+ var genderClass = genderInfo.gender === 'female' ? 'gender-female' : 'gender-male';
+
+ // Add gender class to profile image container for styled frame
+ $(".img-box").addClass(genderClass);
+
+ // Store gender info globally for use in other messages
+ window.dashboardGenderInfo = genderInfo;
+
+ // Build greeting with honorific on same line (for Arabic/RTL)
+ var employeeName = result.employee[0][0].name;
+ var isRtl = $('body').hasClass('o_rtl');
+
+ // Format: "مساء الخير يا أستاذ" then "أحمد" on new line
+ var greetingWithHonorific = isRtl
+ ? (greetingText + ' يا ' + honorific)
+ : greetingText;
+ var greetingHtml = '' + greetingWithHonorific + '';
+
+ if (result.celebration && result.celebration.is_birthday) {
+ var birthdayText = isRtl ? 'عيد ميلاد سعيد!' : 'Happy Birthday!';
+ greetingHtml = '🎂' + birthdayText + '';
+ } else if (result.celebration && result.celebration.is_anniversary) {
+ var anniversaryText = isRtl ? 'شكراً لعطائك!' : 'Thank You!';
+ greetingHtml = '⭐' + anniversaryText + '';
}
+ $("p.fn-section").html(greetingHtml + ' ' + employeeName);
+ // Employee code with styled badge
+ // Employee code with styled badge (Fallback to ID if emp_no missing)
+ var empCode = result.employee[0][0].emp_no || result.employee[0][0].pin || result.employee[0][0].barcode || result.employee[0][0].id;
+ $("p.fn-id").html('' + empCode + '');
+
+ // Job Title (Use safe multilingual variable)
+ $("p.fn-job").html(result.job_english || 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);
+ $("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);
+ // SET MAIN CARD DATA (LEAVES - TIMESHEET - PAYSLIPS) with animated counters
+ animateCounter(".leaves_count span.value", parseFloat(result['employee'][0][0].remaining_leaves) || 0, 1200, '', 2);
+ animateCounter(".timesheet_count span.value", parseInt(result['employee'][0][0].timesheet_count) || 0, 1200);
+ animateCounter(".paylips_count span.value", parseInt(result['employee'][0][0].payslip_count) || 0, 1200);
// LEAVES DO ACTION
$("#leaves_btn").on('click', function(event) {
- console.log(this);
event.stopPropagation();
event.preventDefault();
return self.do_action({
@@ -86,7 +363,6 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
// TIMESHEET DO ACTION
$("#timesheet_btn").on('click', function(event) {
- console.log(this);
event.stopPropagation();
event.preventDefault();
return self.do_action({
@@ -114,7 +390,6 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
//PAYSLIPS DO ACTION
$("#paylips_btn").on('click', function(event) {
- console.log(this);
event.stopPropagation();
event.preventDefault();
return self.do_action({
@@ -142,7 +417,8 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
// 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);
+ $("p.fn-job").html(result.job_english || '');
+ $("p.fn-id").html(result.user[0][0].id);
}
}
@@ -158,6 +434,20 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
self.$el.find('div.card-section1').append(card);
}
});
+
+ // Apply animated counters to all service cards after they're added to DOM
+ setTimeout(function() {
+ self.$el.find('.service-card-count').each(function() {
+ var $el = $(this);
+ var targetCount = parseInt($el.attr('data-target-count')) || 0;
+ animateCounter($el, targetCount, 1200);
+ });
+
+ // Initialize drag and drop for service cards reordering
+ // Pass server-prefetched order and RPC context for cross-device persistence
+ var serverServiceOrder = result.card_orders ? result.card_orders['dashboard_card_order'] : null;
+ initServiceCardsDragDrop('div.card-section1', self._rpc.bind(self), serverServiceOrder);
+ }, 100);
// triggering click event in the main card
self.$el.find('div.card3 .box-1').on('click', function(event) {
@@ -244,6 +534,190 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
}
}, { on_reverse_breadcrumb: self.on_reverse_breadcrumb })
});
+
+ // ============================================================
+ // APPROVAL CARDS HANDLING (Merged from system_dashboard.js)
+ // This code handles cards of type 'approve' for managers
+ // ============================================================
+
+ // Function to build/rebuild approval cards
+ // IMPORTANT: Receives full result object to access card_orders for drag-drop
+ function buildApprovalCards(resultData) {
+ var cards = resultData.cards;
+ var cardOrders = resultData.card_orders;
+
+ // Clear existing cards
+ self.$el.find('div.card-section-approve').empty();
+ self.$el.find('div.card-section-track').empty();
+
+ $.each(cards, function(index, cardData) {
+ if (cardData.type == "approve") {
+ // Show approval tabs
+ $('.approval-tab-item').show();
+
+ // BUILD APPROVE CARD
+ var card = buildCardApprove(cardData, index, 'table1');
+ $(card).find('.card-header h4 span').html(_t(cardData.name));
+ // BUILD FOLLOW CARD
+ var card2 = buildCardApprove(cardData, index, 'table2');
+ $(card2).find('.card-header h4 span').html(_t(cardData.name));
+ // APPENDING CARDS
+ self.$el.find('div.card-section-approve').append(card);
+ self.$el.find('div.card-section-track').append(card2);
+ }
+ });
+
+ // Rebind click handlers for the new cards
+ self.$el.find('tr[data-target="record-button"]').off('click').on('click', function(event) {
+ event.stopPropagation();
+ event.preventDefault();
+ 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'));
+
+ if (domain === undefined || domain === '') {
+ domain = false;
+ } else {
+ domain = JSON.parse(domain);
+ }
+ if (isNaN(form_view)) form_view = false;
+ if (isNaN(list_view)) list_view = false;
+
+ 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(); } });
+ });
+
+ // Update pending count badge - Sum ACTUAL pending requests from data
+ var totalPendingCount = 0;
+ $.each(cards, function(index, cardData) {
+ if (cardData.type == 'approve' && cardData.lines && cardData.lines.length > 0) {
+ $.each(cardData.lines, function(lineIdx, line) {
+ if (line.count_state_click) {
+ totalPendingCount += parseInt(line.count_state_click) || 0;
+ }
+ });
+ }
+ });
+
+ if (totalPendingCount > 0) {
+ $('.pending-count-badge').text(totalPendingCount).show();
+ } else {
+ $('.pending-count-badge').hide();
+ }
+
+ // Initialize drag and drop for approval cards ONLY
+ // Track tab will use the SAME saved order but without its own drag-drop handlers
+ setTimeout(function() {
+ // Use cardOrders from the received resultData (MUST be fresh, not closure)
+ var serverApprovalOrder = cardOrders ? cardOrders['dashboard_approval_order'] : null;
+
+ // Enable drag-drop ONLY on approve tab
+ initDragDropSortable({
+ containerSelector: 'div.card-section-approve',
+ cardSelector: '.card2',
+ storageKey: 'dashboard_approval_order',
+ rpcContext: self._rpc.bind(self),
+ serverOrder: serverApprovalOrder,
+ linkedContainerSelector: 'div.card-section-track' // Sync DOM to track tab on drag end
+ });
+
+ // Apply same order to track tab (no drag-drop, just reorder DOM)
+ var $trackContainer = $('div.card-section-track');
+ if ($trackContainer.length > 0 && serverApprovalOrder && serverApprovalOrder.length > 0) {
+ var $trackCards = $trackContainer.find('.card2');
+ serverApprovalOrder.forEach(function(cardId) {
+ var $matchingCard = $trackCards.filter(function() {
+ var model = $(this).find('.box-1').attr('data-model') || $(this).attr('data-model');
+ return model === cardId;
+ }).first();
+ if ($matchingCard.length > 0) {
+ $trackContainer.append($matchingCard);
+ }
+ });
+ }
+ }, 150);
+ }
+
+ // Initial build of approval cards (pass full result object)
+ buildApprovalCards(result);
+
+ // ============================================================
+ // TAB CLICK - Cards are already built, no need to refresh on tab switch
+ // Polling handles data freshness. Drag-drop order is preserved.
+ // ============================================================
+
+ // ============================================================
+ // POLLING FOR NEW APPROVAL REQUESTS (Feature 7)
+ // Checks every 60 seconds for new requests and plays notification
+ // ============================================================
+ var lastApproveCount = self.$el.find('div.card-section-approve .card3').length;
+ var notificationSound = null;
+ var audioUnlocked = false;
+
+ // Simple notification sound as base64 (short beep)
+ var soundDataUri = 'data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YVoGAACBhYqFbF1fdH2AgYB4cWpvdXt/fn55dHFydnl8fHt6eHd3eHl6e3t7enl4d3d4eXp7e3t6eXh3d3h5ent7e3p5eHd3eHl6e3t7enl4d3d4eXp7e3t6eXh3d3h5ent7e3p5eHd3eHl6e3t7enl4d3d4eXp7e3t6eXh3d3h5ent7e3p5eHd3eHl6e3t7';
+
+ // Unlock audio on first user interaction (browser policy)
+ $(document).one('click keydown', function() {
+ try {
+ notificationSound = new Audio(soundDataUri);
+ notificationSound.volume = 0.5;
+ notificationSound.load();
+ audioUnlocked = true;
+ } catch (e) {
+ // Audio notification not supported
+ }
+ });
+
+ // Poll for new approval requests every 60 seconds
+ self.pollingIntervalId = setInterval(function() {
+ self._rpc({
+ model: 'system_dashboard_classic.dashboard',
+ method: 'get_data',
+ }, []).then(function(freshResult) {
+ // Count ACTUAL pending requests from fresh data
+ var newApproveCount = 0;
+ $.each(freshResult.cards, function(index, cardData) {
+ if (cardData.type == 'approve' && cardData.lines && cardData.lines.length > 0) {
+ $.each(cardData.lines, function(lineIdx, line) {
+ if (line.count_state_click) {
+ newApproveCount += parseInt(line.count_state_click) || 0;
+ }
+ });
+ }
+ });
+
+ // Check if there are NEW requests
+ if (newApproveCount > lastApproveCount) {
+ // Play notification sound
+ if (audioUnlocked && notificationSound) {
+ notificationSound.currentTime = 0;
+ notificationSound.play().catch(function(e) {
+ // Ignore errors silently
+ });
+ }
+
+ // Rebuild cards and update badge
+ buildApprovalCards(freshResult.cards);
+ }
+
+ // Update count for next comparison
+ lastApproveCount = newApproveCount;
+ }).catch(function(e) {
+ // Silently ignore polling errors
+ });
+ }, 60000); // Check every 60 seconds
}
// Chart Settings
setTimeout(function() {
@@ -257,20 +731,198 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
}
if (result.attendance !== undefined) {
setupAttendanceArea(result.attendance[0], name);
- // check button submission
- $('button#check_button').on('click', function(event) {
- self._rpc({
- model: 'system_dashboard_classic.dashboard',
- method: 'checkin_checkout',
- context: { 'check': check },
- }, []).then(function(result) {
- console.log(result);
- console.log(resultX.employee);
- $('.last-checkin-section').html('');
- $('.attendance-img-section').html('');
- $('#check_button').html('');
- setupAttendanceArea(result, resultX.employee[0][0].name);
- });
+
+ // Check if attendance is enabled from settings
+ var isAttendanceEnabled = result.enable_attendance_button !== false;
+
+ // Icon-based check-in/out click handler
+ if (!isAttendanceEnabled) {
+ // Disable icon visually but keep it visible
+ $('.attendance-img-section').addClass('disabled')
+ .attr('title', $('body').hasClass('o_rtl')
+ ? 'تسجيل الحضور معطّل من قبل المسؤول'
+ : 'Check-in/out disabled by administrator');
+ }
+
+ // Icon click handler for check-in/out
+ $('.attendance-img-section').off('click').on('click', function(event) {
+ // Check if attendance is enabled
+ if (!isAttendanceEnabled) {
+ return false;
+ }
+
+ var $attendanceIcon = $(this);
+ var isRtl = $('body').hasClass('o_rtl');
+
+ // Helper: Show error toast with OK button
+ function showErrorToast(message) {
+ $('.attendance-error-overlay').remove();
+ var $errorToast = $(
+ '
' +
+ '
' +
+ '
⚠️
' +
+ '
' + message + '
' +
+ '' +
+ '
' +
+ '
'
+ );
+ $('body').append($errorToast);
+ setTimeout(function() { $errorToast.addClass('show'); }, 50);
+
+ $errorToast.find('.error-ok-btn').on('click', function() {
+ $errorToast.removeClass('show');
+ setTimeout(function() { $errorToast.remove(); }, 300);
+ });
+ }
+
+ // Helper: Perform check-in/out RPC
+ function performCheckinCheckout(lat, lng) {
+ self._rpc({
+ model: 'system_dashboard_classic.dashboard',
+ method: 'checkin_checkout',
+ args: [lat, lng],
+ }).then(function(result) {
+ // Check for zone validation errors returned from server
+ if (result && result.error) {
+ showErrorToast(result.message);
+ return;
+ }
+
+ $('.last-checkin-section').html('');
+ $('.attendance-img-section').html('');
+ setupAttendanceArea(result, resultX.employee[0][0].name);
+
+ // === Premium Check-in/out Notification ===
+ var isCheckIn = result.is_attendance;
+ var employeeName = resultX.employee[0][0].name || '';
+ var firstName = employeeName.split(' ')[0];
+
+ var now = new Date();
+ var hours = now.getHours();
+ var minutes = now.getMinutes().toString().padStart(2, '0');
+ var ampm = hours >= 12 ? (isRtl ? 'م' : 'PM') : (isRtl ? 'ص' : 'AM');
+ hours = hours % 12 || 12;
+ var timeStr = hours + ':' + minutes + ' ' + ampm;
+
+ var title, subtitle, icon, gradientClass;
+ // Gendered pronouns: لك (male) / لكِ (female)
+ var genderInfo = window.dashboardGenderInfo || {pronoun_you: 'ك'};
+ var forYou = 'ل' + genderInfo.pronoun_you; // لك or لكِ
+ if (isCheckIn) {
+ icon = '☀️';
+ gradientClass = 'notification-checkin';
+ if (isRtl) {
+ title = 'أهلاً ' + firstName + '!';
+ subtitle = 'نتمنى ' + forYou + ' يوماً مثمراً ومليئاً بالإنجازات';
+ } else {
+ title = 'Welcome, ' + firstName + '!';
+ subtitle = 'Wishing you a productive and successful day';
+ }
+ } else {
+ icon = '🌙';
+ gradientClass = 'notification-checkout';
+ if (isRtl) {
+ title = 'مع السلامة ' + firstName + '!';
+ subtitle = 'شكراً لجهودك اليوم، نراك غداً بإذن الله';
+ } else {
+ title = 'Goodbye, ' + firstName + '!';
+ subtitle = 'Thank you for your hard work today. See you tomorrow!';
+ }
+ }
+
+ $('.attendance-notification-overlay').remove();
+ var $notification = $(
+ '
' +
+ '
' +
+ '
' +
+ '' + icon + '' +
+ '
' +
+ '
' +
+ '
' + title + '
' +
+ '
' + subtitle + '
' +
+ '
' +
+ '🕐' +
+ '' + timeStr + '' +
+ '
' +
+ '
' +
+ '
' +
+ '
'
+ );
+
+ $('body').append($notification);
+ $('.attendance-img-section').addClass('attendance-success-pulse');
+ setTimeout(function() {
+ $('.attendance-img-section').removeClass('attendance-success-pulse');
+ }, 800);
+ setTimeout(function() { $notification.addClass('show'); }, 50);
+ setTimeout(function() {
+ $notification.removeClass('show');
+ setTimeout(function() { $notification.remove(); }, 400);
+ }, 5500);
+
+ }).guardedCatch(function(error) {
+ // Handle unexpected server errors only
+ var errorMsg = isRtl ? 'حدث خطأ غير متوقع. حاول مرة أخرى' : 'An unexpected error occurred. Please try again.';
+ showErrorToast(errorMsg);
+ });
+ }
+
+ // ============================================================
+ // GEOLOCATION: Get user's current position before check-in/out
+ // ============================================================
+ if (navigator.geolocation) {
+ // Show loading indicator on attendance icon
+ $attendanceIcon.css('opacity', '0.6');
+
+ var geoOptions = {
+ enableHighAccuracy: true,
+ timeout: 10000, // 10 seconds timeout
+ maximumAge: 0 // Don't use cached position
+ };
+
+ navigator.geolocation.getCurrentPosition(
+ // Success: Got location
+ function(position) {
+ $attendanceIcon.css('opacity', '1');
+ var lat = position.coords.latitude;
+ var lng = position.coords.longitude;
+ performCheckinCheckout(lat, lng);
+ },
+ // Error: Location failed
+ function(error) {
+ $attendanceIcon.css('opacity', '1');
+ var errorMessage;
+ switch(error.code) {
+ case error.PERMISSION_DENIED:
+ errorMessage = isRtl
+ ? 'يجب السماح بالوصول إلى موقعك الجغرافي لتسجيل الحضور.\nيرجى تفعيل صلاحية الموقع في إعدادات المتصفح.'
+ : 'Location access is required for attendance.\nPlease enable location permission in browser settings.';
+ break;
+ case error.POSITION_UNAVAILABLE:
+ errorMessage = isRtl
+ ? 'تعذر تحديد موقعك الجغرافي.\nتأكد من تفعيل GPS على جهازك.'
+ : 'Unable to determine your location.\nMake sure GPS is enabled on your device.';
+ break;
+ case error.TIMEOUT:
+ errorMessage = isRtl
+ ? 'انتهت المهلة المحددة لتحديد الموقع.\nحاول مرة أخرى.'
+ : 'Location request timed out.\nPlease try again.';
+ break;
+ default:
+ errorMessage = isRtl
+ ? 'حدث خطأ في تحديد الموقع.'
+ : 'An error occurred while getting location.';
+ }
+ showErrorToast(errorMessage);
+ },
+ geoOptions
+ );
+ } else {
+ // Browser doesn't support geolocation
+ showErrorToast(isRtl
+ ? 'متصفحك لا يدعم خدمة تحديد الموقع الجغرافي.'
+ : 'Your browser does not support geolocation.');
+ }
});
} else {
$('.attendance-section-body').hide();
@@ -288,48 +940,83 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
var total_timesheet_title = "الساعات المنجزة";
var remaining_timesheet_title = "إجمالي ساعات الأسبوع ";
var taken_timesheet_title = "الساعات المتبقية ";
+
+ // Chart Tooltip Labels (Arabic)
+ var label_used = "مستنفذ";
+ var label_left = "متبقي";
+ var label_received = "مستلم";
+ var label_remaining = "متبقي";
+ var label_done = "منجز";
+ var label_worked = "ساعات عمل";
} else {
var total_leaves_title = "Total Balance ";
- var remaining_leaves_title = "Left Balance ";
- var taken_leaves_title = "Total remaining days ";
+ var remaining_leaves_title = "Days Left ";
+ var taken_leaves_title = "Days Used ";
var total_payroll_title = "Total annual slips";
- var remaining_payroll_title = "Remaining salary slips ";
- var taken_payroll_title = "Left salary slips ";
+ var remaining_payroll_title = "Slips Remaining ";
+ var taken_payroll_title = "Slips Received ";
- var total_timesheet_title = "Total hours of week";
- var remaining_timesheet_title = "Remaining hours ";
- var taken_timesheet_title = "Left hours ";
+ var total_timesheet_title = "Hours Done ";
+ var remaining_timesheet_title = "Hours Left ";
+ var taken_timesheet_title = "Hours Left ";
+
+ // Chart Tooltip Labels (English)
+ var label_used = "Used";
+ var label_left = "Left";
+ var label_received = "Received";
+ var label_remaining = "Remaining";
+ var label_done = "Done";
+ var label_worked = "Worked";
}
- 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('');
+ // LEAVE CHART: Show if module is installed (regardless of balance)
+ if (result.leaves !== undefined && result.leaves[0] && result.leaves[0].is_module_installed) {
+ // Get colors from configuration
+ var primaryColor = result.chart_colors ? result.chart_colors.primary : '#0891b2';
+ var warningColor = result.chart_colors ? result.chart_colors.warning : '#f59e0b';
+ var secondaryColor = result.chart_colors ? result.chart_colors.secondary : '#1e293b';
+
+ var total_leaves = (result.leaves[0].taken + result.leaves[0].remaining_leaves);
+ var remaining_leaves = Math.round(result.leaves[0].remaining_leaves);
+ var taken_leaves = Math.round(result.leaves[0].taken);
+
+ // Handle zero balance case gracefully
+ var remaining_leaves_percent = total_leaves > 0 ? Math.round((result.leaves[0].remaining_leaves / total_leaves) * 100) : 0;
+ var taken_leaves_percent = total_leaves > 0 ? Math.round((result.leaves[0].taken / total_leaves) * 100) : 0;
+
+ // Genius Design: Show Left (primary) and Used (warning) with matching colors
+ if ($('body').hasClass('o_rtl') === true) {
+ $('.leave-total-amount').html('' + remaining_leaves + ' ' + remaining_leaves_title);
+ $('.leave-left-amount').html('' + taken_leaves + ' ' + taken_leaves_title);
+ $('.leave-center-value').css('color', secondaryColor);
+ animateCounter('.leave-center-value', total_leaves > 0 ? Math.round(total_leaves) : 0, 1200);
+ $('.leave-center-unit').html('يوم');
+ } else {
+ $('.leave-total-amount').html('' + remaining_leaves + ' ' + remaining_leaves_title);
+ $('.leave-left-amount').html('' + taken_leaves + ' ' + taken_leaves_title);
+ $('.leave-center-value').css('color', secondaryColor);
+ animateCounter('.leave-center-value', total_leaves > 0 ? Math.round(total_leaves) : 0, 1200);
+ $('.leave-center-unit').html('days');
+ }
+ $('#chartContainer').html('');
+
+ // Draw chart (show empty state if no balance)
+ if (total_leaves > 0) {
pluscharts.draw({
drawOn: "#chartContainer",
- type: "donut",
+ type: result.chart_types.annual_leave || "donut",
dataset: {
data: [{
- label: remaining_leaves_title,
- value: ((result.leaves[0].taken / total_leaves) * 100)
+ label: taken_leaves_percent + "% " + label_used,
+ value: taken_leaves_percent
},
{
- label: taken_leaves_title,
- value: ((result.leaves[0].remaining_leaves / total_leaves) * 100)
+ label: remaining_leaves_percent + "% " + label_left,
+ value: remaining_leaves_percent
}
],
- backgroundColor: ["#003056", "#2ead97"],
+ backgroundColor: [warningColor, primaryColor],
borderColor: "#ffffff",
borderWidth: 0,
},
@@ -345,44 +1032,81 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
height: 20
},
size: {
- width: '140', //give 'container' if you want width and height of initiated container
+ width: '140',
height: '140'
}
}
});
- // display section
+ } else {
+ // No balance - show empty donut
+ pluscharts.draw({
+ drawOn: "#chartContainer",
+ type: result.chart_types.annual_leave || "donut",
+ dataset: {
+ data: [{
+ label: "0% " + label_left,
+ value: 100
+ }],
+ backgroundColor: ['#e5e7eb'],
+ borderColor: "#ffffff",
+ borderWidth: 0,
+ },
+ options: {
+ width: 15,
+ text: { display: false },
+ legends: { display: false },
+ size: { width: '140', height: '140' }
+ }
+ });
+ }
+ // display section (only if enabled in settings)
+ if (!result.stats_visibility || result.stats_visibility.show_annual_leave !== false) {
$('#leave-section').fadeIn();
}
}
- if (result.payroll !== undefined) {
- if (result.payroll[0].taken > 0 || result.payroll[0].payslip_remaining > 0) {
- // Configure Salary Slips Chart
+ // PAYROLL CHART: Show if module is installed (regardless of balance)
+ if (result.payroll !== undefined && result.payroll[0] && result.payroll[0].is_module_installed) {
+ // Get colors from configuration
+ var primaryColor = result.chart_colors ? result.chart_colors.primary : '#0891b2';
+ var warningColor = result.chart_colors ? result.chart_colors.warning : '#f59e0b';
+ var secondaryColor = result.chart_colors ? result.chart_colors.secondary : '#1e293b';
+
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));
+ var remaining_payroll = Math.round(result.payroll[0].payslip_remaining);
+ var taken_payroll = Math.round(result.payroll[0].taken);
+ var remaining_payroll_percent = Math.round((result.payroll[0].payslip_remaining / total_payroll) * 100);
+ var taken_payroll_percent = Math.round((result.payroll[0].taken / total_payroll) * 100);
+
+ // Genius Design: Show remaining (primary) and received (warning) with matching colors
if ($('body').hasClass('o_rtl') === true) {
- $('.payroll-data-percent').html('%' + taken_payroll_percent);
+ $('.payroll-total-amount').html('' + remaining_payroll + ' ' + remaining_payroll_title);
+ $('.payroll-left-amount').html('' + taken_payroll + ' ' + taken_payroll_title);
+ $('.payroll-center-value').css('color', secondaryColor);
+ animateCounter('.payroll-center-value', Math.round(total_payroll), 1200);
+ $('.payroll-center-unit').html('قسيمة');
} else {
- $('.payroll-data-percent').html(taken_payroll_percent + '%');
+ $('.payroll-total-amount').html('' + remaining_payroll + ' ' + remaining_payroll_title);
+ $('.payroll-left-amount').html('' + taken_payroll + ' ' + taken_payroll_title);
+ $('.payroll-center-value').css('color', secondaryColor);
+ animateCounter('.payroll-center-value', Math.round(total_payroll), 1200);
+ $('.payroll-center-unit').html('slips');
}
$('#chartPaylips').html('');
pluscharts.draw({
drawOn: "#chartPaylips",
- type: "donut",
+ type: result.chart_types.salary_slips || "donut",
dataset: {
data: [{
- label: remaining_payroll_title,
- value: ((result.payroll[0].payslip_remaining / total_payroll) * 100)
+ label: taken_payroll_percent + "% " + label_received,
+ value: taken_payroll_percent
},
{
- label: taken_payroll_title,
- value: ((result.payroll[0].taken / total_payroll) * 100)
+ label: remaining_payroll_percent + "% " + label_remaining,
+ value: remaining_payroll_percent
}
],
- backgroundColor: ["#003056", "#2ead97"],
+ backgroundColor: [warningColor, primaryColor],
borderColor: "#ffffff",
borderWidth: 0,
},
@@ -398,45 +1122,59 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
height: 20
},
size: {
- width: '140', //give 'container' if you want width and height of initiated container
+ width: '140',
height: '140'
}
}
});
- // display section
- $('#salary-section').fadeIn();
- }
+ // display section (only if enabled in settings)
+ if (!result.stats_visibility || result.stats_visibility.show_salary_slips !== false) {
+ $('#salary-section').fadeIn();
+ }
}
- if (result.timesheet !== undefined) {
- if (result.timesheet[0].taken > 0 || result.timesheet[0].timesheet_remaining > 0) {
- // Configure Weekly Timesheet Chart
+ // TIMESHEET CHART: Show if module is installed (regardless of balance)
+ if (result.timesheet !== undefined && result.timesheet[0] && result.timesheet[0].is_module_installed) {
+ // Get colors from configuration
+ var primaryColor = result.chart_colors ? result.chart_colors.primary : '#0891b2';
+ var warningColor = result.chart_colors ? result.chart_colors.warning : '#f59e0b';
+ var secondaryColor = result.chart_colors ? result.chart_colors.secondary : '#1e293b';
+
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);
+ var done_hours = Math.round(result.timesheet[0].taken);
+ var remaining_hours = Math.round(result.timesheet[0].timesheet_remaining);
+ var done_hours_percent = Math.round((result.timesheet[0].taken / total_timesheet) * 100);
+ var remaining_hours_percent = Math.round((result.timesheet[0].timesheet_remaining / total_timesheet) * 100);
+
+ // Genius Design: Show Remaining (primary) and Done (warning) - consistent with Leaves/Payroll
if ($('body').hasClass('o_rtl') === true) {
- $('.timesheet-data-percent').html('%' + taken_timesheet_percent);
+ $('.timesheet-total-amount').html('' + remaining_hours + ' ' + remaining_timesheet_title);
+ $('.timesheet-left-amount').html('' + done_hours + ' ' + total_timesheet_title);
+ $('.timesheet-center-value').css('color', secondaryColor);
+ animateCounter('.timesheet-center-value', Math.round(total_timesheet), 1200);
+ $('.timesheet-center-unit').html('ساعة');
} else {
- $('.timesheet-data-percent').html(taken_timesheet_percent + '%');
+ $('.timesheet-total-amount').html('' + remaining_hours + ' ' + remaining_timesheet_title);
+ $('.timesheet-left-amount').html('' + done_hours + ' ' + total_timesheet_title);
+ $('.timesheet-center-value').css('color', secondaryColor);
+ animateCounter('.timesheet-center-value', Math.round(total_timesheet), 1200);
+ $('.timesheet-center-unit').html('hrs');
}
$('#chartTimesheet').html('');
pluscharts.draw({
drawOn: "#chartTimesheet",
- type: "donut",
+ type: result.chart_types.timesheet || "donut",
dataset: {
data: [{
- label: taken_timesheet_title,
- value: ((result.timesheet[0].taken / total_timesheet) * 100)
+ label: done_hours_percent + "% " + label_done,
+ value: done_hours_percent
},
{
- label: remaining_timesheet_title,
- value: ((result.timesheet[0].timesheet_remaining / total_timesheet))
+ label: remaining_hours_percent + "% " + label_left,
+ value: remaining_hours_percent
}
],
- backgroundColor: ["#003056", "#2ead97"],
+ backgroundColor: [warningColor, primaryColor],
borderColor: "#ffffff",
borderWidth: 0,
},
@@ -452,18 +1190,100 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
height: 20
},
size: {
- width: '140', //give 'container' if you want width and height of initiated container
+ width: '140',
height: '140'
}
}
});
- // display section
- $('#timesheet-section').fadeIn();
- }
+ // display section (only if enabled in settings)
+ if (!result.stats_visibility || result.stats_visibility.show_timesheet !== false) {
+ $('#timesheet-section').fadeIn();
+ }
+ }
+
+ // ATTENDANCE HOURS CHART: Show if module is installed (regardless of balance)
+ if (result.attendance_hours !== undefined && result.attendance_hours[0] && result.attendance_hours[0].is_module_installed) {
+ // Get colors from configuration
+ var primaryColor = result.chart_colors ? result.chart_colors.primary : '#0891b2';
+ var warningColor = result.chart_colors ? result.chart_colors.warning : '#f59e0b';
+ var secondaryColor = result.chart_colors ? result.chart_colors.secondary : '#1e293b';
+
+ var total_attendance = result.attendance_hours[0].plan_hours;
+ var worked_hours = Math.round(result.attendance_hours[0].official_hours);
+ var remaining_hours = Math.round(total_attendance - result.attendance_hours[0].official_hours);
+ var worked_percent = total_attendance > 0 ? Math.round((result.attendance_hours[0].official_hours / total_attendance) * 100) : 0;
+ var remaining_percent = total_attendance > 0 ? (100 - worked_percent) : 0;
+
+ // Genius Design: Show Total Planned (primary) and Worked (warning) - Goal vs Actual
+ if ($('body').hasClass('o_rtl') === true) {
+ $('.attendance-plan-hours').html('' + Math.round(total_attendance) + ' الساعات المخططة');
+ $('.attendance-official-hours').html('' + worked_hours + ' ساعات الحضور');
+ $('.attendance-center-value').css('color', secondaryColor);
+ animateCounter('.attendance-center-value', Math.round(total_attendance), 1200);
+ $('.attendance-center-unit').html('ساعة');
+ } else {
+ $('.attendance-plan-hours').html('' + Math.round(total_attendance) + ' hrs Planned');
+ $('.attendance-official-hours').html('' + worked_hours + ' hrs Worked');
+ $('.attendance-center-value').css('color', secondaryColor);
+ animateCounter('.attendance-center-value', Math.round(total_attendance), 1200);
+ $('.attendance-center-unit').html('hrs');
+ }
+ $('#chartAttendanceHours').html('');
+ pluscharts.draw({
+ drawOn: "#chartAttendanceHours",
+ type: result.chart_types.attendance_hours || "donut",
+ dataset: {
+ data: [{
+ label: worked_percent + "% " + label_worked,
+ value: worked_percent
+ },
+ {
+ label: remaining_percent + "% " + label_remaining,
+ value: remaining_percent
+ }
+ ],
+ backgroundColor: [warningColor, primaryColor],
+ borderColor: "#ffffff",
+ borderWidth: 0,
+ },
+ options: {
+ width: 15,
+ text: {
+ display: false,
+ color: "#f6f6f6"
+ },
+ legends: {
+ display: false,
+ width: 20,
+ height: 20
+ },
+ size: {
+ width: '140',
+ height: '140'
+ }
+ }
+ });
+ // display section (only if enabled in settings)
+ if (!result.stats_visibility || result.stats_visibility.show_attendance_hours !== false) {
+ $('#attendance-hours-section').fadeIn();
+ }
}
// hide charts loader
$('.charts-over-layer').fadeOut();
+
+ // Initialize drag and drop for stats module-boxes in header
+ // Uses server-side persistence for cross-device sync
+ setTimeout(function() {
+ var serverStatsOrder = result.card_orders ? result.card_orders['dashboard_stats_order'] : null;
+ initDragDropSortable({
+ containerSelector: '.dashboard-module-charts',
+ cardSelector: '.module-box',
+ storageKey: 'dashboard_stats_order',
+ rpcContext: self._rpc.bind(self),
+ serverOrder: serverStatsOrder
+ });
+ }, 200);
}, 1000);
})
@@ -487,8 +1307,119 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
},
start: function() {
var self = this;
+ // Inject search bar after template is attached to DOM
+ setTimeout(function() {
+ self._addSearchBar();
+ }, 500);
+ // Load stats visibility settings FRESH on every start (fixes caching issue)
+ setTimeout(function() {
+ self._loadStatsVisibility();
+ }, 600);
return this._super();
},
+
+ /**
+ * Load statistics visibility settings from database and apply
+ * Called on every dashboard start to ensure settings are always fresh
+ */
+ _loadStatsVisibility: function() {
+ var self = this;
+ 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) {
+ self.$('#leave-section').addClass('genius-hidden');
+ } else {
+ self.$('#leave-section').removeClass('genius-hidden');
+ }
+
+ // Salary Slips card
+ if (!visibility.show_salary_slips) {
+ self.$('#salary-section').addClass('genius-hidden');
+ } else {
+ self.$('#salary-section').removeClass('genius-hidden');
+ }
+
+ // Weekly Timesheet card
+ if (!visibility.show_timesheet) {
+ self.$('#timesheet-section').addClass('genius-hidden');
+ } else {
+ self.$('#timesheet-section').removeClass('genius-hidden');
+ }
+
+ // Attendance Hours card
+ if (!visibility.show_attendance_hours) {
+ self.$('#attendance-hours-section').addClass('genius-hidden');
+ } else {
+ self.$('#attendance-hours-section').removeClass('genius-hidden');
+ }
+
+ // Attendance Check-in/out Section (right side panel)
+ if (!visibility.show_attendance_section) {
+ self.$('.dashboard-attendance-section').addClass('genius-hidden');
+ self.$('.dashboard-charts-section').removeClass('col-md-10').addClass('col-md-12');
+ self.$('.dashboard-user-statistics-section').addClass('attendance-hidden');
+ } else {
+ self.$('.dashboard-attendance-section').removeClass('genius-hidden');
+ self.$('.dashboard-charts-section').removeClass('col-md-12').addClass('col-md-10');
+ self.$('.dashboard-user-statistics-section').removeClass('attendance-hidden');
+ }
+ }).catch(function() {
+ // Silently handle visibility settings error
+ });
+ },
+
+ /**
+ * Add search bar to the dashboard nav buttons
+ * This is called from start() to ensure DOM is ready
+ */
+ _addSearchBar: function() {
+ var navButtons = this.$('.dashboard-nav-buttons');
+
+ if (this.$('.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 = '
' +
+ '
' +
+ '' +
+ '' +
+ '×' +
+ '
';
+
+ navButtons.append(searchHtml);
+
+ this.$('#geniusServiceSearch').on('input', function() {
+ var term = $(this).val().toLowerCase().trim();
+ $('#geniusClearSearch').toggle(term.length > 0);
+ // Filter cards
+ 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));
+ });
+ });
+
+ this.$('#geniusClearSearch').on('click', function() {
+ $('#geniusServiceSearch').val('');
+ $(this).hide();
+ $('.card3, .card2').removeClass('genius-hidden');
+ });
+ },
render: function() {
var super_render = this._super;
$(".o_control_panel").addClass("o_hidden");
@@ -496,6 +1427,24 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
},
reload: function() {
window.location.href = this.href;
+ },
+ /**
+ * Cleanup on widget destruction - prevents memory leak
+ * Clears the polling interval for new approval requests
+ */
+ destroy: function() {
+ // Clear polling interval to prevent memory leak
+ if (this.pollingIntervalId) {
+ clearInterval(this.pollingIntervalId);
+ this.pollingIntervalId = null;
+ }
+ // Call parent destroy
+ this._super.apply(this, arguments);
+ // Clear attendance clock interval if exists
+ if (window.attendanceClockInterval) {
+ clearInterval(window.attendanceClockInterval);
+ window.attendanceClockInterval = null;
+ }
}
});
@@ -528,11 +1477,32 @@ function buildCardZ(data, index) {
var h4 = document.createElement('h4');
var content = document.createElement('div');
- $(img).attr('src', 'data:image/png;base64,' + data.image);
- $(h3).html(data.state_count);
+ if (data.icon_type === 'icon' && data.icon_name) {
+ // Render FontAwesome Icon
+ var iconDict = document.createElement('div');
+ var iconElem = document.createElement('i');
+
+ $(iconDict).addClass('service-icon-wrapper');
+
+ // Add fa class and the specific icon class (e.g., fa-plane)
+ // Apply secondary color using CSS variable for dynamic theming
+ // Added 'service-icon' class for consistent sizing (64px) and hover effects
+ // CSS handling: .service-icon in genius-enhancements.scss
+ $(iconElem).addClass('fa ' + data.icon_name + ' service-icon').css('color', 'var(--dash-secondary)');
+
+ $(iconDict).append(iconElem);
+ $(content).append(iconDict);
+ } else {
+ // Render Image (Default)
+ // Add specific class for sizing if not already present in CSS
+ $(img).attr('src', 'data:image/png;base64,' + data.image).addClass('service-icon');
+ $(content).append(img);
+ }
+
+ $(h3).addClass('service-card-count').attr('data-target-count', data.state_count || 0);
$(h4).html(data.name);
$(content).addClass('col-md-12 col-sm-12 col-xs-12');
- $(content).append(img).append(h3).append(h4);
+ $(content).append(h3).append(h4);
$(box1).append(content);
//box 2
var box2_i = document.createElement('i');
@@ -552,31 +1522,414 @@ function buildCardZ(data, index) {
return card;
}
+/**
+ * Generic Drag and Drop Sortable Function
+ * Works with any card type by accepting configuration options
+ * Uses HTML5 Drag and Drop API with SERVER-SIDE persistence (+ localStorage cache)
+ *
+ * @param {Object} options Configuration options
+ * @param {string} options.containerSelector - CSS selector for the container
+ * @param {string} options.cardSelector - CSS selector for cards within container
+ * @param {string} options.storageKey - Storage key for saving order (used for both localStorage cache and server)
+ * @param {string} options.idAttribute - Attribute name to identify cards (default: data-name)
+ * @param {function} options.idFinder - Optional function to get card ID
+ * @param {Array} options.serverOrder - Optional pre-fetched order from server
+ * @param {Object} options.rpcContext - RPC context object (self._rpc compatible)
+ */
+function initDragDropSortable(options) {
+ var containerSelector = options.containerSelector;
+ var cardSelector = options.cardSelector || '.card3';
+ var storageKey = options.storageKey || 'dashboard_card_order';
+ var idFinder = options.idFinder || null;
+ var serverOrder = options.serverOrder || null;
+ var rpcContext = options.rpcContext || null;
+ var linkedContainerSelector = options.linkedContainerSelector || null; // Cross-tab sync
+
+ var $container = $(containerSelector);
+ if ($container.length === 0) return;
+
+ var $cards = $container.find(cardSelector);
+ if ($cards.length < 2) return; // Need at least 2 cards to reorder
+
+ // Helper function to get card ID
+ function getCardId($card) {
+ if (idFinder) {
+ return idFinder($card);
+ }
+ // Check for data-name in box-1 (service cards)
+ var boxName = $card.find('.box-1').attr('data-name');
+ if (boxName) return boxName;
+ // Check for id attribute (module-box stats cards)
+ var elemId = $card.attr('id');
+ if (elemId) return elemId;
+ // Fallback to data-model attribute (approval cards)
+ var model = $card.find('.box-1').attr('data-model') || $card.attr('data-model');
+ if (model) return model;
+ // Ultimate fallback: index
+ return $card.index();
+ }
+
+ // Priority: serverOrder > localStorage cache
+ var savedOrder = serverOrder;
+ if (!savedOrder) {
+ try {
+ var savedData = localStorage.getItem(storageKey);
+ if (savedData) {
+ savedOrder = JSON.parse(savedData);
+ }
+ } catch(e) {
+ // Ignore localStorage errors
+ }
+ }
+
+ // Apply saved order if exists
+ if (savedOrder && Array.isArray(savedOrder) && savedOrder.length > 0) {
+ var orderedCards = [];
+ // First add cards in saved order
+ savedOrder.forEach(function(cardId) {
+ var $matchingCard = $cards.filter(function() {
+ return getCardId($(this)) === cardId;
+ }).first();
+ if ($matchingCard.length > 0) {
+ orderedCards.push($matchingCard[0]);
+ }
+ });
+ // Then add any new cards not in saved order
+ $cards.each(function() {
+ if (orderedCards.indexOf(this) === -1) {
+ orderedCards.push(this);
+ }
+ });
+ // Reorder in DOM
+ orderedCards.forEach(function(card) {
+ $container.append(card);
+ });
+ // Refresh reference after reordering
+ $cards = $container.find(cardSelector);
+ }
+
+ // Make cards draggable
+ $cards.attr('draggable', 'true').addClass('draggable-card');
+
+ var draggedCard = null;
+
+ // Drag Start - store reference to dragged card
+ $cards.on('dragstart', function(e) {
+ draggedCard = this;
+ $(this).addClass('dragging');
+ // Required for Firefox
+ e.originalEvent.dataTransfer.effectAllowed = 'move';
+ e.originalEvent.dataTransfer.setData('text/plain', '');
+ });
+
+ // Drag End - cleanup and save order (to both localStorage AND server)
+ $cards.on('dragend', function() {
+ $(this).removeClass('dragging');
+ $container.find(cardSelector).removeClass('drag-over');
+ draggedCard = null;
+
+ // Collect new order
+ var newOrder = [];
+ $container.find(cardSelector).each(function() {
+ var cardId = getCardId($(this));
+ if (cardId) {
+ newOrder.push(cardId);
+ }
+ });
+
+ // Save to localStorage (immediate cache)
+ try {
+ localStorage.setItem(storageKey, JSON.stringify(newOrder));
+ } catch(e) {
+ // Ignore localStorage errors
+ }
+
+ // Save to server (persistent, cross-device)
+ if (rpcContext) {
+ rpcContext({
+ model: 'system_dashboard_classic.dashboard',
+ method: 'save_card_order',
+ args: [storageKey, newOrder],
+ }).then(function() {
+ // Saved successfully to server
+ }).guardedCatch(function(err) {
+ // Silently fail - localStorage still has it
+ console.log('Card order save to server failed, using local cache');
+ });
+ }
+
+ // ============================================================
+ // CROSS-TAB DOM SYNC: Reorder the linked container to match
+ // ============================================================
+ if (linkedContainerSelector) {
+ var $linkedContainer = $(linkedContainerSelector);
+ if ($linkedContainer.length > 0) {
+ var $linkedCards = $linkedContainer.find(cardSelector);
+ if ($linkedCards.length > 0) {
+ // Build ordered array based on newOrder
+ newOrder.forEach(function(cardId) {
+ var $matchingCard = $linkedCards.filter(function() {
+ return getCardId($(this)) === cardId;
+ }).first();
+ if ($matchingCard.length > 0) {
+ $linkedContainer.append($matchingCard);
+ }
+ });
+ }
+ }
+ }
+ });
+
+ // Drag Over - allow drop
+ $cards.on('dragover', function(e) {
+ e.preventDefault();
+ e.originalEvent.dataTransfer.dropEffect = 'move';
+
+ if (this !== draggedCard) {
+ $(this).addClass('drag-over');
+ }
+ });
+
+ // Drag Leave - remove highlight
+ $cards.on('dragleave', function() {
+ $(this).removeClass('drag-over');
+ });
+
+ // Drop - reorder cards
+ $cards.on('drop', function(e) {
+ e.preventDefault();
+ if (this !== draggedCard && draggedCard) {
+ var $this = $(this);
+ var $dragged = $(draggedCard);
+
+ // Get positions to determine insert before or after
+ var thisRect = this.getBoundingClientRect();
+ var draggedRect = draggedCard.getBoundingClientRect();
+
+ // If dragging from left to right, insert after
+ // If dragging from right to left, insert before
+ if (draggedRect.left < thisRect.left) {
+ $dragged.insertAfter($this);
+ } else {
+ $dragged.insertBefore($this);
+ }
+ }
+ $(this).removeClass('drag-over');
+ });
+}
+
+// Backward compatibility wrapper for service cards
+function initServiceCardsDragDrop(containerSelector, rpcContext, serverOrder) {
+ initDragDropSortable({
+ containerSelector: containerSelector,
+ cardSelector: '.card3',
+ storageKey: 'dashboard_card_order',
+ rpcContext: rpcContext,
+ serverOrder: serverOrder
+ });
+}
+
+
function setupAttendanceArea(data, name) {
window.check = data.is_attendance;
var button = '';
+
+ // Get current date info for display
+ var now = new Date();
+ var dayNames = $('body').hasClass('o_rtl')
+ ? ['الأحد', 'الإثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة', 'السبت']
+ : ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
+ var monthNames = $('body').hasClass('o_rtl')
+ ? ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر']
+ : ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+ var dayName = dayNames[now.getDay()];
+ var day = now.getDate();
+ var month = monthNames[now.getMonth()];
+ var dateDisplay = $('body').hasClass('o_rtl')
+ ? dayName + '، ' + day + ' ' + month
+ : dayName + ', ' + day + ' ' + month;
+
if ($('body').hasClass('o_rtl')) {
- var checkout_title = "زمن تسجيل اخر دخول"
- var checkin_title = ",مرحباً بك"
+ 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_title = "Last check in";
+ var checkin_title = "Last check out";
+ var checkin_button = "Check In";
var checkout_button = "Check Out";
}
+
+ // Always show live clock (date and time)
+ function updateClock() {
+ var now = new Date();
+ var hours = now.getHours().toString().padStart(2, '0');
+ var minutes = now.getMinutes().toString().padStart(2, '0');
+ var seconds = now.getSeconds().toString().padStart(2, '0');
+ var timeStr = hours + ':' + minutes + ':' + seconds;
+ // Date on one line, time on another line
+ var clockHtml = '
' + dateDisplay + '
' +
+ '
' + timeStr + '
';
+ $('.last-checkin-section').html(clockHtml);
+ }
+
+ // Initial display
+ updateClock();
+
+ // Clear any existing interval first
+ if (window.attendanceClockInterval) {
+ clearInterval(window.attendanceClockInterval);
+ }
+ // Update every second (client-side only, no server calls)
+ window.attendanceClockInterval = setInterval(updateClock, 1000);
+
if (data.is_attendance === true) {
- var check = checkout_title + " " + data.time;
- var image = '';
- $('#check_button').html(checkout_button).removeClass('checkin-btn').addClass('checkout-btn');
- $('.last-checkin-section').html(check);
- $('.attendance-img-section').html(image);
+ // User is checked in - show logout icon (arrow out) with warning color
+ var image = '';
+ $('.attendance-img-section').html(image).removeClass('state-checkin').addClass('state-checkout');
+
+ // Show last check-in info below icon (on two lines)
+ var checkinInfoHtml = '
' + checkout_title + '
' +
+ '
' + data.time + '
';
+ $('.last-checkin-info').html(checkinInfoHtml).show();
} else {
- var check = checkin_title + " " + name + ",";
- var image = '';
- $('#check_button').html(checkin_button).removeClass('checkout-btn').addClass('checkin-btn');
- $('.last-checkin-section').html(check);
- $('.attendance-img-section').html(image);
+ // User is NOT checked in - show login icon (arrow in) with success color
+ var image = '';
+ $('.attendance-img-section').html(image).removeClass('state-checkout').addClass('state-checkin');
+
+ // Show last check-out info below icon (on two lines) if time exists
+ if (data.time) {
+ var checkoutInfoHtml = '
' + checkin_title + '
' +
+ '
' + data.time + '
';
+ $('.last-checkin-info').html(checkoutInfoHtml).show();
+ } else {
+ $('.last-checkin-info').html('').hide();
+ }
}
}
+
+/*
+ ============================================================
+ APPROVAL CARD BUILDER FUNCTIONS (Merged from system_dashboard.js)
+ These functions build the approval/follow cards for managers
+ ============================================================
+*/
+
+/*
+ NAME: buildCardApprove
+ DESC: Using to create (APPROVE/FOLLOW) card base on sent data and it return DOM OBJECT
+ RETURN: DOM OBJECT
+*/
+function buildCardApprove(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');
+ // CRITICAL: Set data-model attribute for drag-drop order tracking
+ $(card).attr('data-model', data.model);
+ $(card_container).addClass('col-md-12 col-sm-12 col-xs-12 card-container');
+
+ //card header
+ //card header
+ var h4 = document.createElement('h4');
+ // Changed header background color logic implies CSS update likely needed,
+ // but here we ensure the icon inside matches size.
+ // NOTE: The user requested Primary Color for header.
+ // Usually this is set in CSS, but we can enforce it here if needed or check CSS.
+ // Assuming .card-header has background color, we might need to override it.
+ $(card_header).addClass('col-md-12 col-sm-12 col-xs-12 card-header');
+ // .css('background-color', 'var(--dash-primary)') REMOVED - Moved to SCSS
+
+ var iconOrImgHtml = '';
+ // Unify size for Approval cards too (typically smaller than self service)
+ // Let's use 30px for header icons to be clean.
+ var headerIconSize = '30px';
+
+ if (data.icon_type === 'icon' && data.icon_name) {
+ // Dynamic Icon - using SCSS class .approval-header-icon
+ iconOrImgHtml = '';
+ } else {
+ // Default Image - using SCSS class .approval-header-icon
+ iconOrImgHtml = '';
+ }
+
+ $(h4).html(iconOrImgHtml + ' ' + data.name + '').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('
' + data.lines[index].count_state_click + '
');
+ $(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('
' + data.lines[index].count_state_follow + '
');
+ $(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;
+}
diff --git a/odex25_base/system_dashboard_classic/static/src/js/utils.js b/odex25_base/system_dashboard_classic/static/src/js/utils.js
deleted file mode 100644
index 6500b4445..000000000
--- a/odex25_base/system_dashboard_classic/static/src/js/utils.js
+++ /dev/null
@@ -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 =
- '