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'
' + except Exception: + record.icon_preview_html = '
' + else: + # Default Placeholder + record.icon_preview_html = '
' + 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 = - '
' + - '« chartjs-plugin-datalabels' + - '
' + - '
' + - '' + group + ' / ' + - '' + name + '' + - (desc ? '
' + desc + '
' : '') + - '
'; - } - }, true); - - // Google Analytics - /* eslint-disable */ - if (['localhost', '127.0.0.1', ''].indexOf(document.location.hostname) === -1) { - (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ - (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), - m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) - })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); - ga('create', 'UA-99068522-2', 'auto'); - ga('send', 'pageview'); - } - /* eslint-enable */ - -}(this)); \ No newline at end of file diff --git a/odex25_base/system_dashboard_classic/static/src/lib/confetti.min.js b/odex25_base/system_dashboard_classic/static/src/lib/confetti.min.js new file mode 100644 index 000000000..ca0ef9652 --- /dev/null +++ b/odex25_base/system_dashboard_classic/static/src/lib/confetti.min.js @@ -0,0 +1,8 @@ +/** + * Minified by jsDelivr using Terser v5.37.0. + * Original file: /npm/canvas-confetti@1.9.3/dist/confetti.browser.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +!function(t,e){!function t(e,a,n,r){var o=!!(e.Worker&&e.Blob&&e.Promise&&e.OffscreenCanvas&&e.OffscreenCanvasRenderingContext2D&&e.HTMLCanvasElement&&e.HTMLCanvasElement.prototype.transferControlToOffscreen&&e.URL&&e.URL.createObjectURL),i="function"==typeof Path2D&&"function"==typeof DOMMatrix,l=function(){if(!e.OffscreenCanvas)return!1;var t=new OffscreenCanvas(1,1),a=t.getContext("2d");a.fillRect(0,0,1,1);var n=t.transferToImageBitmap();try{a.createPattern(n,"no-repeat")}catch(t){return!1}return!0}();function s(){}function c(t){var n=a.exports.Promise,r=void 0!==n?n:e.Promise;return"function"==typeof r?new r(t):(t(s,s),null)}var h,f,u,d,m,g,p,b,M,v,y,w=(h=l,f=new Map,{transform:function(t){if(h)return t;if(f.has(t))return f.get(t);var e=new OffscreenCanvas(t.width,t.height);return e.getContext("2d").drawImage(t,0,0),f.set(t,e),e},clear:function(){f.clear()}}),x=(m=Math.floor(1e3/60),g={},p=0,"function"==typeof requestAnimationFrame&&"function"==typeof cancelAnimationFrame?(u=function(t){var e=Math.random();return g[e]=requestAnimationFrame((function a(n){p===n||p+m-1a { color: $color_nav; - border: 1px solid $color_5 !important; margin: 0; } } diff --git a/odex25_base/system_dashboard_classic/static/src/scss/genius-enhancements.scss b/odex25_base/system_dashboard_classic/static/src/scss/genius-enhancements.scss new file mode 100644 index 000000000..f50374a1d --- /dev/null +++ b/odex25_base/system_dashboard_classic/static/src/scss/genius-enhancements.scss @@ -0,0 +1,3466 @@ +/* ============================================================ + System Dashboard Classic - GENIUS PREMIUM REDESIGN + ============================================================ + + A complete professional redesign with: + - Premium stat cards instead of plain circles + - Enhanced employee profile with status badge + - Real-time search bar for services + - Modern check-in button + - Configurable 2-3 color palette + + ============================================================ */ + +/* === THEME COLORS (Configurable via CSS Variables) === */ +/* Default colors match Odoo settings - will be overridden by JS */ +:root { + /* Primary Brand Colors - DEFAULT CYAN (Odoo typical, will be overridden by JS) */ + --dash-primary: #0891b2; /* Cyan-600 - Main accent */ + --dash-primary-light: #22d3ee; /* Cyan-400 light */ + --dash-primary-dark: #0e7490; /* Cyan-700 dark */ + + /* Secondary Colors */ + --dash-secondary: #1e293b; /* Slate-800 - Headers/Dark */ + --dash-secondary-light: #334155; + + /* Status Colors */ + --dash-success: #10b981; /* Emerald-500 */ + --dash-warning: #f59e0b; /* Amber-500 */ + --dash-danger: #ef4444; /* Red-500 */ + + /* Neutral Colors */ + --dash-bg: #f1f5f9; /* Slate-100 */ + --dash-white: #ffffff; + --dash-border: #e2e8f0; /* Slate-200 */ + --dash-text: #475569; /* Slate-600 */ + --dash-text-light: #94a3b8; /* Slate-400 */ + + /* Shadows */ + --dash-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --dash-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --dash-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + --dash-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + + /* Transitions */ + --dash-transition: 0.2s ease-in-out; +} + +/* ============================================================ + HEADER SECTION - Premium Dark Gradient + ============================================================ */ + +.dashboard-container .dashboard-header { + background: linear-gradient(135deg, var(--dash-secondary) 0%, var(--dash-secondary-light) 100%) !important; + border-radius: 0 0 28px 28px !important; + box-shadow: var(--dash-shadow-xl) !important; + padding: 30px 20px !important; + min-height: auto !important; + position: relative !important; + overflow: hidden !important; + border-bottom: none !important; +} + +/* Subtle decorative gradient overlay - uses primary color */ +.dashboard-container .dashboard-header::before { + content: '' !important; + position: absolute !important; + top: 0 !important; + right: 0 !important; + width: 50% !important; + height: 100% !important; + background: linear-gradient(135deg, transparent 0%, rgba(var(--dash-primary-rgb, 8, 145, 178), 0.15) 100%) !important; + pointer-events: none !important; +} + +.dashboard-container .dashboard-header::after { + display: none !important; +} + +/* ============================================================ + EMPLOYEE PROFILE - Premium Card Design + ============================================================ */ + +.dashboard-user-data-section { + display: flex !important; + align-items: center !important; + padding: 0 !important; + background: transparent !important; + /* Remove the dark box - the header gradient behind will show through */ +} + +/* Profile container - VISIBLE Glassmorphism Card */ +/* Enhanced for better visibility on dark header */ +.profile-container { + background: rgba(255, 255, 255, 0.15) !important; + -webkit-backdrop-filter: blur(20px) saturate(180%) !important; + border-radius: 20px 0px 0px 20px !important; + padding: 10px 5px !important; + border: 1px solid rgba(255, 255, 255, 0.08) !important; + margin: 10px !important; + text-align: center !important; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.2) !important; + position: relative !important; +} + +/* Subtle shine effect on top */ +.profile-container::before { + content: '' !important; + position: absolute !important; + top: 0 !important; + left: 10% !important; + right: 10% !important; + height: 1px !important; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent) !important; + pointer-events: none !important; +} + +.profile-container .pp-image-section { + position: relative !important; + display: inline-block !important; + margin-bottom: 0px !important; +} + +/* Online status indicator removed by user request */ +/* .profile-container .pp-image-section::after { ... } */ + +.profile-container .pp-image-section::before { + display: none !important; +} + +.profile-container .pp-image-section .img-box { + border: 4px solid rgba(255, 255, 255, 0.2) !important; + height: 130px !important; + width: 130px !important; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3) !important; + transition: all var(--dash-transition) !important; +} + +.profile-container .pp-image-section .img-box:hover { + transform: scale(1.05) !important; + border-color: var(--dash-primary) !important; +} + +.profile-container .info-section { + padding: 0 !important; + margin-top: 0 !important; +} + +.profile-container .info-section::before { + display: none !important; +} + +.profile-container .info-section p { + margin: 0 !important; + color: rgba(255, 255, 255, 0.9) !important; +} + +.profile-container .info-section p.fn-section { + font-size: 18px !important; + font-weight: 700 !important; + color: #fff !important; + margin-bottom: 4px !important; + letter-spacing: 0.3px !important; + text-shadow: none !important; + -webkit-text-fill-color: unset !important; + background: none !important; + animation: none !important; +} + +.profile-container .info-section p.fn-job { + font-size: 12px !important; + font-weight: 500 !important; + text-transform: uppercase !important; + letter-spacing: 1px !important; + margin-bottom: 2px !important; + -webkit-text-fill-color: unset !important; + text-shadow: none !important; +} + +/* Employee ID - Styled Badge */ +.profile-container .info-section p.fn-id { + font-size: 14px !important; + color: rgba(255, 255, 255, 0.9) !important; + background: rgba(255, 255, 255, 0.15) !important; + padding: 0px 12px !important; + border-radius: 20px !important; + margin-top: 0px !important; + display: inline-block !important; + visibility: visible !important; + opacity: 1 !important; +} + +/* Hide fn-id only when it contains "/" or is empty - via class added by JS */ +p.fn-id.genius-hidden, +.fn-id.genius-hidden, +.profile-container .info-section p.fn-id.genius-hidden { + display: none !important; +} + +.emp-code-badge { + color: #fff !important; + font-weight: 600 !important; + letter-spacing: 0.5px !important; +} + +/* Dynamic Greeting Text */ +.greeting-text { + font-weight: 400 !important; + font-size: 14px !important; + opacity: 0.9; +} + +/* Pending Requests Counter Badge - Uses Warning Color from Settings */ +.pending-count-badge { + background: var(--dash-warning, #f59e0b) !important; + color: white !important; + border-radius: 50% !important; + padding: 2px 8px !important; + font-size: 11px !important; + margin-inline-start: 8px !important; + font-weight: bold !important; + min-width: 20px !important; + text-align: center !important; + display: inline-block !important; + animation: pulse-badge 2s ease-in-out infinite !important; +} + +@keyframes pulse-badge { + 0%, 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4); + } + 50% { + transform: scale(1.1); + box-shadow: 0 0 0 8px rgba(245, 158, 11, 0); + } +} + +/* Attendance Title - Uses Secondary Color from Settings */ +.attendance-title { + color: var(--dash-secondary, #1e293b) !important; + font-weight: 600 !important; + font-size: 16px !important; + margin-bottom: 8px !important; +} + +/* Attendance Date & Time - Uses Primary Color from Settings */ +/* Date and time displayed on single line */ +.last-checkin-section { + text-align: center !important; + margin-bottom: 5px !important; +} + +.attendance-date { + color: var(--dash-primary, #0891b2) !important; + font-size: 12px !important; + font-weight: 500 !important; +} + +.attendance-time { + color: var(--dash-primary, #0891b2) !important; + font-size: 18px !important; + font-weight: 600 !important; + font-family: 'SF Mono', 'Roboto Mono', 'Consolas', monospace !important; + letter-spacing: 1px !important; + display: inline !important; +} + +/* Last Check-in/out Info - Displayed below icon on single line */ +.last-checkin-info { + text-align: center !important; + margin-top: 4px !important; +} + +.last-checkin-info .checkin-label { + color: var(--dash-secondary, #64748b) !important; + font-size: 10px !important; + font-weight: 500 !important; + text-transform: uppercase !important; + letter-spacing: 0.5px !important; +} + +.last-checkin-info .checkin-time { + color: var(--dash-warning, #f59e0b) !important; + font-size: 11px !important; + font-weight: 600 !important; + font-family: 'SF Mono', 'Roboto Mono', monospace !important; + display: inline !important; + margin-left: 5px !important; +} + +/* Clickable Profile Elements */ +.clickable-profile { + cursor: pointer !important; + transition: all 0.3s ease !important; +} + +.clickable-profile:hover { + opacity: 0.85 !important; +} + +.pp-image-section .img-box.clickable-profile:hover { + transform: scale(1.08) !important; + border-color: var(--dash-primary) !important; + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4) !important; +} + +p.fn-section.clickable-profile:hover { + text-decoration: underline !important; + text-underline-offset: 3px !important; +} + +/* ============================================================ + STATISTICS CARDS - Modern Premium Design + ============================================================ */ + +.dashboard-user-statistics-section { + padding: 0 15px !important; + border-radius: 5px !important; + /* No overflow-x here - only in mobile media query */ +} + +.dashboard-charts-section { + height: auto !important; + padding: 0 !important; +} + +.dashboard-module-charts { + display: flex !important; + flex-wrap: nowrap !important; + gap: 12px !important; + padding: 10px 0 !important; + justify-content: stretch !important; + align-items: stretch !important; + width: 100% !important; +} + +/* Statistics cards - flex grow to fill space equally */ +.module-box { + flex: 1 1 0 !important; /* grow, shrink, basis=0 for equal distribution */ + min-width: 0 !important; /* allow shrinking below content size */ + max-width: none !important; + padding: 0 !important; +} + +/* Hidden cards should not take space */ +.module-box.genius-hidden { + display: none !important; + flex: 0 0 0 !important; +} + +/* Stat Card Container */ +.module-box .module-box-container { + background: rgba(255, 255, 255, 0.95) !important; + backdrop-filter: blur(10px) !important; + -webkit-backdrop-filter: blur(10px) !important; + border-radius: 16px !important; + padding: 16px !important; + border: 1px solid rgba(255, 255, 255, 0.3) !important; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1) !important; + transition: all var(--dash-transition) !important; + position: relative !important; + overflow: hidden !important; + height: 100% !important; +} + +.module-box .module-box-container::before { + display: none !important; +} + +.module-box .module-box-container:hover { + transform: translateY(-4px) !important; + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15) !important; +} + +/* Stat Card Title - ANNUAL LEAVE, SALARY SLIPS, etc */ +.module-box .module-box-container h3 { + font-size: 13px !important; + font-weight: 700 !important; + color: var(--dash-secondary) !important; + text-transform: uppercase !important; + letter-spacing: 0.5px !important; + margin: 12px 0 0 0 !important; + text-align: center !important; + text-shadow: none !important; + opacity: 1 !important; +} + +/* Stat Values - Big Numbers */ +.module-box .module-box-container h4.leave-data-percent, +.module-box .module-box-container h4.payroll-data-percent, +.module-box .module-box-container h4.timesheet-data-percent, +.module-box .module-box-container h4.attendance-hours-percent { + font-size: 28px !important; + font-weight: 800 !important; + color: var(--dash-primary) !important; + text-align: center !important; + margin: 8px 0 !important; + text-shadow: none !important; + -webkit-text-fill-color: unset !important; + animation: none !important; + transform: none !important; +} + +/* Stat Labels - GENIUS MINIMAL DESIGN */ +/* Clean inline labels with subtle number highlighting */ +.module-box .module-box-container p { + margin: 3px 0 !important; + text-align: center !important; + line-height: 1.4 !important; +} + +.module-box .module-box-container p span { + font-size: 11px !important; + font-weight: 500 !important; + color: var(--dash-text-light) !important; + display: inline-flex !important; + align-items: center !important; + gap: 6px !important; + background: none !important; + padding: 0 !important; + border-radius: 0 !important; +} + +/* Colored dots - smaller and subtle */ +.module-box .module-box-container p span i.fa-circle { + font-size: 5px !important; + opacity: 0.9 !important; +} + +/* First row dot (Total) - Primary teal */ +.module-box .module-box-container p:first-of-type span i { + color: var(--dash-primary) !important; +} + +/* Second row dot (Remaining) - Warm amber */ +.module-box .module-box-container p:nth-of-type(2) span i { + color: var(--dash-warning) !important; +} + +/* GENIUS NUMBER HIGHLIGHTING */ +/* The actual values get bold treatment with primary color */ +.module-box .module-box-container .leave-total-amount, +.module-box .module-box-container .payroll-total-amount, +.module-box .module-box-container .timesheet-total-amount, +.module-box .module-box-container .attendance-plan-hours { + font-weight: 800 !important; + font-size: 14px !important; + color: var(--dash-primary) !important; + padding: 1px 6px !important; + background: rgba(8, 145, 178, 0.08) !important; + border-radius: 4px !important; + margin-left: 2px !important; +} + +/* Remaining amounts - Amber highlight */ +.module-box .module-box-container .leave-left-amount, +.module-box .module-box-container .payroll-left-amount, +.module-box .module-box-container .timesheet-left-amount, +.module-box .module-box-container .attendance-official-hours { + font-weight: 800 !important; + font-size: 14px !important; + color: var(--dash-warning) !important; + padding: 1px 6px !important; + background: rgba(245, 158, 11, 0.08) !important; + border-radius: 4px !important; + margin-left: 2px !important; +} + +/* Hide the D3 chart container - CSS only approach */ +.module-box #chartContainer, +.module-box #chartPaylips, +.module-box #chartTimesheet, +.module-box #chartAttendanceHours { + height: 140px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; +} + +/* Style the SVG charts */ +.module-box svg { + max-height: 140px !important; + margin: 0 auto !important; +} + +/* Chart wrapper for center text positioning */ +.chart-wrapper { + position: relative !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + height: 140px !important; +} + +/* Center text inside donut chart */ +.chart-center-text { + position: absolute !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%) !important; + text-align: center !important; + pointer-events: none !important; + z-index: 10 !important; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; +} + +.chart-center-value { + font-size: 2rem !important; + font-weight: 800 !important; + color: var(--dash-secondary, #1e293b); /* No !important - JS sets dynamically */ + line-height: 1 !important; + transition: all 0.3s ease !important; +} + +/* Hover effect on center text */ +.chart-wrapper:hover .chart-center-value { + transform: scale(1.1) !important; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important; +} + +.chart-center-unit { + font-size: 1.3rem !important; + font-weight: 600 !important; + color: var(--dash-secondary) !important; + text-transform: lowercase !important; + margin-top: 4px !important; + letter-spacing: 0.5px !important; + transition: all 0.3s ease !important; +} + +.chart-wrapper:hover .chart-center-unit { + color: var(--dash-secondary) !important; +} + +/* ============================================================ + ATTENDANCE SECTION - Modern Button Design + ============================================================ */ + +.dashboard-attendance-section { + border-left: 1px solid rgba(255, 255, 255, 0.1) !important; + display: flex !important; + align-items: center !important; +} + +.attendance-section-body { + background: rgba(255, 255, 255, 0.98) !important; + backdrop-filter: blur(10px) !important; + -webkit-backdrop-filter: blur(10px) !important; + border-radius: 20px !important; + padding: 10px 4px 4px 4px !important; + margin: 4px !important; + text-align: center !important; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08) !important; + width: 100% !important; + border: 1px solid rgba(0, 0, 0, 0.05) !important; +} + +.attendance-section-body h3 { + font-size: 13px !important; + font-weight: 600 !important; + color: var(--dash-secondary) !important; + text-transform: uppercase !important; + letter-spacing: 1px !important; + margin-bottom: 4px !important; + text-shadow: none !important; + -webkit-text-fill-color: unset !important; +} + +/* Welcome text - Make sure no extra background */ +.attendance-section-body p.last-checkin-section { + font-size: 12px !important; + color: var(--dash-text) !important; + margin-bottom: 8px !important; + font-weight: 500 !important; + background: none !important; + padding: 0 !important; +} + +/* Check-in/out Arrow Button Container - FORCED VISIBILITY */ +.attendance-section-body .attendance-img-section { + display: flex !important; + justify-content: center !important; + align-items: center !important; + margin-top: 10px !important; + background: transparent !important; + min-height: 70px !important; +} + +/* Check-in/out Arrow Button - DYNAMIC COLOR using CSS Mask */ +/* The container uses mask-image technique for reliable color matching */ +.attendance-section-body .attendance-img-section { + position: relative !important; + display: flex !important; + justify-content: center !important; + align-items: center !important; + cursor: pointer !important; +} + +/* Hide the original img completely - pseudo-element handles display */ +.attendance-section-body .attendance-img-section img, +.attendance-section-body .attendance-img-section img.img-logout, +.attendance-section-body .attendance-img-section img.img-login, +.attendance-section-body .attendance-img-section img.attendance-icon, +.attendance-section-body p.attendance-img-section img { + /* Hide img completely - CSS mask pseudo-element handles the visual */ + display: none !important; +} + +/* Create the visible icon using mask-image on a pseudo-element */ +/* Default state - Check-in (login icon with success color) */ +.attendance-section-body .attendance-img-section::before { + content: '' !important; + display: block !important; + width: 70px !important; + height: 70px !important; + /* Default: Success color for check-in */ + background-color: var(--dash-success, #10b981) !important; + /* Default: login icon */ + -webkit-mask-image: url('/system_dashboard_classic/static/src/icons/login.svg') !important; + mask-image: url('/system_dashboard_classic/static/src/icons/login.svg') !important; + -webkit-mask-size: contain !important; + mask-size: contain !important; + -webkit-mask-repeat: no-repeat !important; + mask-repeat: no-repeat !important; + -webkit-mask-position: center !important; + mask-position: center !important; + transition: all 0.3s ease !important; + pointer-events: none !important; +} + +/* Check-in state - Green success color with login icon */ +.attendance-section-body .attendance-img-section.state-checkin::before { + background-color: var(--dash-success, #10b981) !important; + -webkit-mask-image: url('/system_dashboard_classic/static/src/icons/login.svg') !important; + mask-image: url('/system_dashboard_classic/static/src/icons/login.svg') !important; +} + +/* Check-out state - Warning color with logout icon */ +.attendance-section-body .attendance-img-section.state-checkout::before { + background-color: var(--dash-warning, #f59e0b) !important; + -webkit-mask-image: url('/system_dashboard_classic/static/src/icons/logout.svg') !important; + mask-image: url('/system_dashboard_classic/static/src/icons/logout.svg') !important; +} + +/* Disabled state - only change icon color, no opacity */ +.attendance-section-body .attendance-img-section.disabled::before { + background-color: #9ca3af !important; + /* Remove opacity to prevent gray rectangle background */ +} + +.attendance-section-body .attendance-img-section.disabled { + cursor: not-allowed !important; + opacity: 0.6 !important; +} + +/* Hover effects */ +.attendance-section-body .attendance-img-section:not(.disabled):hover::before { + transform: scale(1.1) !important; +} + +.attendance-section-body .attendance-img-section.state-checkin:not(.disabled):hover::before { + background-color: var(--dash-success-dark, #059669) !important; +} + +.attendance-section-body .attendance-img-section.state-checkout:not(.disabled):hover::before { + background-color: var(--dash-warning-dark, #d97706) !important; +} + +/* Check-in/out Button - Modern Style */ + + +/* ============================================================ + DASHBOARD BODY - Clean Background + ============================================================ */ + +.dashboard-container .dashboard-body { + background: var(--dash-bg) !important; + padding: 30px !important; +} + +/* ============================================================ + SELF SERVICES HEADER - With Search Bar + ============================================================ */ + +.dashboard-nav-buttons { + display: flex !important; + justify-content: space-between !important; + align-items: center !important; + flex-wrap: wrap !important; + gap: 15px !important; + margin-bottom: 25px !important; + padding: 0 0 20px 0 !important; + position: relative !important; +} + +/* Full-width separator line under Self Services & Search */ +.dashboard-nav-buttons::after { + content: '' !important; + position: absolute !important; + bottom: 0 !important; + left: 0 !important; + right: 0 !important; + height: 2px !important; + background: linear-gradient(90deg, + var(--dash-primary-light) 0%, + var(--dash-primary) 50%, + var(--dash-primary-light) 100%) !important; + opacity: 0.6 !important; + border-radius: 2px !important; +} + +.dashboard-nav-buttons hr { + display: none !important; +} + +/* ============================================================ + TAB NAVIGATION - ELEGANT UNDERLINE DESIGN + Modern, minimal, professional - NOT button-like + ============================================================ */ + +.dashboard-nav-buttons .nav-tabs { + background: transparent !important; + border: none !important; + border-radius: 0 !important; + padding: 0 !important; + box-shadow: none !important; + display: inline-flex !important; + gap: 0 !important; + margin: 0 !important; + position: relative !important; +} + +/* Remove all ::before pseudo elements */ +.dashboard-nav-buttons .nav-tabs::before { + display: none !important; +} + +.dashboard-nav-buttons .nav-tabs li { + margin: 0 !important; + position: relative !important; +} + +/* Tab Links - Clean Text Style */ +.dashboard-nav-buttons .nav-tabs li a { + border-radius: 0 !important; + padding: 16px 32px !important; + font-weight: 600 !important; + font-size: 15px !important; + color: var(--dash-text-light, #94a3b8) !important; + background: transparent !important; + border: none !important; + transition: all 0.35s ease !important; + white-space: nowrap !important; + position: relative !important; + letter-spacing: 0.3px !important; + text-decoration: none !important; +} + +/* Animated underline indicator - subtle line always visible */ +.dashboard-nav-buttons .nav-tabs li a::after { + content: '' !important; + position: absolute !important; + bottom: 0 !important; + left: 10% !important; + width: 80% !important; + height: 2px !important; + background: rgba(0, 0, 0, 0.06) !important; + border-radius: 2px 2px 0 0 !important; + transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1) !important; +} + +/* Hover - Text color change + underline becomes primary */ +.dashboard-nav-buttons .nav-tabs li a:hover:not(.active) { + color: var(--dash-primary) !important; + background: transparent !important; + transform: none !important; +} + +.dashboard-nav-buttons .nav-tabs li a:hover:not(.active)::after { + left: 5% !important; + width: 90% !important; + background: linear-gradient(90deg, var(--dash-primary-light) 0%, rgba(var(--dash-primary-rgb, 8, 145, 178), 0.3) 100%) !important; +} + +/* Active Tab - Full underline with gradient */ +.dashboard-nav-buttons .nav-tabs li.active a, +.dashboard-nav-buttons .nav-tabs li a.active { + color: var(--dash-primary) !important; + font-weight: 700 !important; + background: transparent !important; + box-shadow: none !important; + transform: none !important; +} + +.dashboard-nav-buttons .nav-tabs li.active a::after, +.dashboard-nav-buttons .nav-tabs li a.active::after { + left: 0 !important; + width: 100% !important; + height: 3px !important; + background: linear-gradient(90deg, + var(--dash-primary) 0%, + var(--dash-primary-light) 50%, + var(--dash-primary) 100%) !important; + box-shadow: 0 2px 8px rgba(var(--dash-primary-rgb, 8, 145, 178), 0.35) !important; +} + +/* Remove shine animation from this design */ +.dashboard-nav-buttons .nav-tabs li.active a::before, +.dashboard-nav-buttons .nav-tabs li a.active::before { + display: none !important; +} + +/* Icon inside tab (if any) */ +.dashboard-nav-buttons .nav-tabs li a i { + margin-right: 10px !important; + font-size: 16px !important; + opacity: 0.7 !important; + transition: all 0.3s ease !important; +} + +.dashboard-nav-buttons .nav-tabs li a:hover i, +.dashboard-nav-buttons .nav-tabs li a.active i { + opacity: 1 !important; + color: var(--dash-primary) !important; +} + +/* Loading/Refreshing state for cards */ +.card-section-approve.refreshing, +.card-section-track.refreshing { + opacity: 0.5 !important; + pointer-events: none !important; + position: relative !important; +} + +.card-section-approve.refreshing::after, +.card-section-track.refreshing::after { + content: '' !important; + position: absolute !important; + top: 50% !important; + left: 50% !important; + width: 40px !important; + height: 40px !important; + margin: -20px 0 0 -20px !important; + border: 3px solid var(--dash-primary-light) !important; + border-top-color: var(--dash-primary) !important; + border-radius: 50% !important; + animation: tabRefreshSpin 0.8s linear infinite !important; +} + +@keyframes tabRefreshSpin { + to { transform: rotate(360deg); } +} + +/* Search Container (will be added via JS) */ +.dashboard-search-container { + display: flex !important; + align-items: center !important; + gap: 10px !important; +} + +.dashboard-search-input { + background: var(--dash-white) !important; + border: 1px solid var(--dash-border) !important; + border-radius: 10px !important; + padding: 10px 16px !important; + font-size: 14px !important; + width: 220px !important; + color: var(--dash-text) !important; + transition: all var(--dash-transition) !important; +} + +.dashboard-search-input::placeholder { + color: var(--dash-text-light) !important; +} + +.dashboard-search-input:focus { + outline: none !important; + border-color: var(--dash-primary) !important; + box-shadow: 0 0 0 3px rgba(8, 145, 178, 0.1) !important; +} + +/* ============================================================ + APPROVAL CARDS (Card2) - GENIUS PREMIUM DESIGN + Uses dynamic colors from settings via CSS variables + ============================================================ */ + +.card2 { + margin-bottom: 24px !important; + padding: 0 10px !important; +} + +.card2 .card-container { + background: var(--dash-white) !important; + border: none !important; + border-radius: 20px !important; + overflow: hidden !important; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06), + 0 1px 3px rgba(0, 0, 0, 0.04) !important; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1) !important; + position: relative !important; +} + +/* Top accent line on hover */ +.card2 .card-container::before { + content: '' !important; + position: absolute !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + height: 3px !important; + background: linear-gradient(90deg, var(--dash-primary) 0%, var(--dash-primary-light) 100%) !important; + opacity: 0 !important; + transition: opacity 0.3s ease !important; + z-index: 10 !important; +} + +.card2 .card-container:hover::before { + opacity: 1 !important; +} + +.card2 .card-container:hover { + transform: translateY(-6px) !important; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.1), + 0 4px 12px rgba(0, 0, 0, 0.06) !important; +} + + +/* Decorative glow in header */ +.card2 .card-container .card-header::before { + content: '' !important; + position: absolute !important; + top: -50% !important; + right: -20% !important; + width: 120px !important; + height: 120px !important; + background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, transparent 70%) !important; + pointer-events: none !important; +} + +.card2 .card-container .card-header::after { + display: none !important; +} + +.card2 .card-container .card-header img { + height: 36px !important; + width: 36px !important; + padding: 6px !important; + // background: rgba(255, 255, 255, 0.18) !important; + border-radius: 10px !important; + margin-right: 14px !important; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important; + filter: brightness(1.1) !important; +} + +.card2 .card-container:hover .card-header img { + transform: scale(1.12) rotate(-5deg) !important; + background: rgba(255, 255, 255, 0.25) !important; +} + +.card2 .card-container .card-header h4 { + color: white !important; + font-size: 15px !important; + font-weight: 700 !important; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.1) !important; + margin: 0 !important; + flex: 1 !important; + letter-spacing: 0.3px !important; +} + +.card2 .card-container .card-body { + height: 180px !important; + padding: 0 !important; + overflow-y: auto !important; + background: linear-gradient(180deg, #ffffff 0%, #fafafa 100%) !important; +} + +/* Custom scrollbar for card body */ +.card2 .card-container .card-body::-webkit-scrollbar { + width: 5px !important; +} + +.card2 .card-container .card-body::-webkit-scrollbar-track { + background: #f1f1f1 !important; + border-radius: 3px !important; +} + +.card2 .card-container .card-body::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, var(--dash-primary) 0%, var(--dash-primary-dark) 100%) !important; + border-radius: 3px !important; +} + +.card2 .card-container .card-body table { + margin: 0 !important; + width: 100% !important; +} + +.card2 .card-container .card-body table tr { + border-bottom: 1px solid rgba(0, 0, 0, 0.04) !important; + transition: all 0.25s ease !important; + cursor: pointer !important; +} + +.card2 .card-container .card-body table tr:last-child { + border-bottom: none !important; +} + +.card2 .card-container .card-body table tr:hover { + background: linear-gradient(90deg, rgba(var(--dash-primary-rgb, 8, 145, 178), 0.04) 0%, rgba(var(--dash-primary-rgb, 8, 145, 178), 0.08) 100%) !important; +} + +.card2 .card-container .card-body table tr td { + padding: 14px 18px !important; + font-weight: 500 !important; + color: var(--dash-secondary) !important; + font-size: 13px !important; + vertical-align: middle !important; + transition: color 0.2s ease !important; +} + +.card2 .card-container .card-body table tr:hover td { + color: var(--dash-primary-dark) !important; +} + +.card2 .card-container .card-body table tr td:last-child { + text-align: right !important; +} + +/* Count Badge - Uses PRIMARY color from settings */ +.card2 .card-container .card-body table tr td:last-child div { + background: linear-gradient(135deg, var(--dash-secondary) 0%, var(--dash-secondary-dark) 100%) !important; + height: 30px !important; + width: 30px !important; + line-height: 30px !important; + font-size: 12px !important; + font-weight: 700 !important; + color: white !important; + border-radius: 50% !important; + display: inline-block !important; + text-align: center !important; + box-shadow: 0 3px 10px rgba(var(--dash-primary-rgb, 8, 145, 178), 0.35) !important; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important; +} + +.card2 .card-container .card-body table tr:hover td:last-child div { + transform: scale(1.15) !important; + box-shadow: 0 5px 15px rgba(var(--dash-primary-rgb, 8, 145, 178), 0.45) !important; +} + +/* ============================================================ + SELF-SERVICE CARDS (Card3) - Premium Design + ============================================================ */ + +.card3 { + padding: 0 8px !important; + margin-bottom: 20px !important; +} + +.card3 .card-body { + background: linear-gradient(180deg, #ffffff 0%, #fafbfc 100%) !important; + border: 2px solid transparent !important; + border-radius: 20px !important; + overflow: hidden !important; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06), + 0 1px 4px rgba(0, 0, 0, 0.04) !important; + transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) !important; + position: relative !important; +} + +/* Morphing gradient border on hover */ +.card3 .card-body::before { + content: '' !important; + position: absolute !important; + inset: -2px !important; + background: linear-gradient(135deg, + var(--dash-primary), + var(--dash-primary-light), + var(--dash-secondary-light), + var(--dash-primary)) !important; + background-size: 200% 200% !important; + border-radius: 22px !important; + z-index: -1 !important; + opacity: 0 !important; + transition: opacity 0.4s ease !important; +} + +.card3 .card-body:hover::before { + opacity: 1 !important; + animation: cardGradientBorder 3s ease-in-out infinite !important; +} + +@keyframes cardGradientBorder { + 0%, 100% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } +} + +.card3 .card-body:hover { + transform: translateY(-8px) scale(1.02) !important; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.12), + 0 8px 16px rgba(var(--dash-primary-rgb, 8, 145, 178), 0.15) !important; +} + +/* Genius 3D Tilt Effect on Service Cards */ +.card3 .card-body { + transform-style: preserve-3d !important; + perspective: 1000px !important; +} + +.card3 .card-body:hover .box-1 { + transform: translateZ(20px) !important; +} + +.card3 .card-body:hover .box-1 img, +.card3 .card-body:hover .box-1 .service-icon { + transform: scale(1.15) translateZ(30px) !important; + filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.15)) !important; +} + +.card3 .card-body .box-1 { + height: 220px !important; + background: var(--dash-white) !important; + border: none !important; + padding: 24px 16px !important; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; +} + +.card3 .card-body .box-1::before { + display: none !important; +} + +/* Icon with glow effect */ +.card3 .card-body .box-1 img, +.card3 .card-body .box-1 .service-icon { + height: 60px !important; + width: 60px !important; /* Ensure width is also consistent */ + display: inline-block !important; /* For icon */ + font-size: 60px !important; /* For icon font size to match height */ + line-height: 60px !important; /* Center vertically */ + margin-bottom: 4px !important; + transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) !important; + filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.1)) !important; + animation: none !important; +} + +.card3 .card-body:hover .box-1 img, +.card3 .card-body:hover .box-1 .service-icon { + transform: scale(1.18) rotate(-5deg) !important; + color: var(--dash-secondary) !important; /* Ensure icon keeps color but gets effect */ + filter: drop-shadow(0 8px 20px rgba(var(--dash-primary-rgb, 8, 145, 178), 0.3)) !important; +} + +/* Count Number - Gradient text with SECONDARY/PRIMARY */ +.card3 .card-body .box-1 h3 { + font-size: 48px !important; + font-weight: 900 !important; + background: linear-gradient(135deg, var(--dash-secondary) 0%, var(--dash-primary) 100%) !important; + -webkit-background-clip: text !important; + -webkit-text-fill-color: transparent !important; + background-clip: text !important; + margin: 8px 0 !important; + text-shadow: none !important; + transition: all 0.3s ease !important; + letter-spacing: -1px !important; +} + +.card3 .card-body:hover .box-1 h3 { + transform: scale(1.08) !important; + filter: brightness(1.1) !important; +} + +/* Service Name - Uses SECONDARY color from settings */ +.card3 .card-body .box-1 h4 { + font-size: 15px !important; + font-weight: 700 !important; + color: var(--dash-secondary) !important; + padding: 8px 16px !important; + margin: 8px 0 0 0 !important; + -webkit-text-fill-color: unset !important; + text-align: center !important; + background: linear-gradient(135deg, rgba(var(--dash-secondary-rgb, 30, 41, 59), 0.04) 0%, rgba(var(--dash-secondary-rgb, 30, 41, 59), 0.08) 100%) !important; + border-radius: 8px !important; + letter-spacing: 0.3px !important; + transition: all 0.3s ease !important; + max-width: 100% !important; +} + +.card3 .card-body:hover .box-1 h4 { + background: linear-gradient(135deg, rgba(var(--dash-primary-rgb, 8, 145, 178), 0.08) 0%, rgba(var(--dash-primary-rgb, 8, 145, 178), 0.15) 100%) !important; + color: var(--dash-primary-dark) !important; +} + +.card3 .card-body .box-2 { + background: var(--dash-primary) !important; + height: 50px !important; + line-height: 50px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + gap: 8px !important; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1) !important; + cursor: pointer !important; +} + +.card3 .card-body .box-2::before { + display: none !important; +} + +.card3 .card-body .box-2 span { + font-size: 14px !important; + font-weight: 700 !important; + color: white !important; + letter-spacing: 0.5px !important; + transition: all 0.4s ease !important; +} + +/* BIGGER + ICON */ +.card3 .card-body .box-2 i { + font-size: 24px !important; + font-weight: 700 !important; + color: white !important; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1) !important; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2) !important; +} + +/* Genius hover for Add New button */ +.card3 .card-body .box-2:hover { + background: linear-gradient(135deg, var(--dash-primary) 0%, var(--dash-primary-dark) 100%) !important; + box-shadow: 0 6px 20px rgba(var(--dash-primary-rgb, 75, 155, 75), 0.4), + inset 0 1px 0 rgba(255, 255, 255, 0.2) !important; +} + +.card3 .card-body .box-2:hover span { + letter-spacing: 1.5px !important; + text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2) !important; +} + +.card3 .card-body .box-2:hover i { + transform: rotate(180deg) scale(1.2) !important; + text-shadow: 0 0 15px rgba(255, 255, 255, 0.5) !important; +} + +/* ============================================================ + CARD ENTRY ANIMATIONS + ============================================================ */ + +.card-section1 .card2, +.card-section .card2, +.card-section1 .card3, +.card-section .card3 { + animation: cardFadeIn 0.35s ease-out backwards !important; +} + +@keyframes cardFadeIn { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.card-section1 .card2:nth-child(1), +.card-section1 .card3:nth-child(1) { animation-delay: 0.04s !important; } +.card-section1 .card2:nth-child(2), +.card-section1 .card3:nth-child(2) { animation-delay: 0.08s !important; } +.card-section1 .card2:nth-child(3), +.card-section1 .card3:nth-child(3) { animation-delay: 0.12s !important; } +.card-section1 .card2:nth-child(4), +.card-section1 .card3:nth-child(4) { animation-delay: 0.16s !important; } +.card-section1 .card2:nth-child(5), +.card-section1 .card3:nth-child(5) { animation-delay: 0.20s !important; } +.card-section1 .card2:nth-child(6), +.card-section1 .card3:nth-child(6) { animation-delay: 0.24s !important; } + +/* ============================================================ + LOADING SPINNER + ============================================================ */ + +.charts-over-layer { + background: rgba(255, 255, 255, 0.95) !important; + backdrop-filter: blur(5px) !important; + border-radius: 16px !important; +} + +.lds-roller div::after { + background: var(--dash-primary) !important; +} + +/* ============================================================ + RESPONSIVE ADJUSTMENTS - COMPREHENSIVE + ============================================================ */ + +/* Large tablets and small desktops (992px and below) */ +@media (max-width: 992px) { + .module-box { + min-width: 140px !important; + padding: 15px 12px !important; + } + + .dashboard-search-input, + .genius-search-input { + width: 200px !important; + } + + .card3 .card-body .box-1 { + height: 180px !important; + padding: 20px 12px !important; + } + + .card3 .card-body .box-1 h3 { + font-size: 32px !important; + } + + .attendance-section-body .attendance-img-section::before { + width: 60px !important; + height: 60px !important; + } + + .attendance-section-body .attendance-img-section img { + width: 60px !important; + height: 60px !important; + } +} + +/* Tablets (768px and below) */ +@media (max-width: 768px) { + .dashboard-container .dashboard-header { + padding: 20px 15px !important; + border-radius: 0 0 20px 20px !important; + } + + .dashboard-nav-buttons { + flex-direction: column !important; + align-items: stretch !important; + gap: 12px !important; + } + + .dashboard-nav-buttons::after { + display: none !important; + } + + .dashboard-search-container, + .genius-search-container { + width: 100% !important; + } + + .dashboard-search-input, + .genius-search-input { + width: 100% !important; + } + + .profile-container { + padding: 16px 14px !important; + margin: 8px !important; + border-radius: 16px !important; + } + + .profile-container .pp-image-section img { + height: 70px !important; + width: 70px !important; + } + + .profile-container .info-section h2 { + font-size: 16px !important; + } + + .module-box { + min-width: 120px !important; + padding: 12px 10px !important; + } + + .module-box h3 { + font-size: 11px !important; + } + + .module-box h4 { + font-size: 20px !important; + } + + .card2 .card-container { + min-width: auto !important; + } + + .card3 .card-body .box-1 { + height: 160px !important; + padding: 16px 10px !important; + } + + .card3 .card-body .box-1 img { + height: 45px !important; + } + + .card3 .card-body .box-1 h3 { + font-size: 28px !important; + } + + .card3 .card-body .box-1 h4 { + font-size: 12px !important; + } + + .card3 .card-body .box-2 { + height: 45px !important; + line-height: 45px !important; + } + + .card3 .card-body .box-2 span { + font-size: 12px !important; + } + + .card3 .card-body .box-2 i { + font-size: 20px !important; + } + + .attendance-section-body .attendance-img-section::before { + width: 55px !important; + height: 55px !important; + } + + .attendance-section-body .attendance-img-section img { + width: 55px !important; + height: 55px !important; + } +} + +/* Mobile devices (576px and below) */ +@media (max-width: 576px) { + /* ============================================ + HEADER LAYOUT - BLOCK DISPLAY FOR STACKING + Key fix: Use display:block to force normal + document flow instead of flex + ============================================ */ + .dashboard-container .dashboard-header { + display: block !important; /* KEY: Block forces stacking */ + padding: 10px !important; + border-radius: 0 0 16px 16px !important; + height: auto !important; + min-height: auto !important; + overflow: visible !important; + padding-bottom: 60px !important; /* MASSIVE padding to fix overflow */ + position: relative !important; + } + + /* Ensure gradient overlay covers extended height */ + .dashboard-container .dashboard-header::before { + height: 100% !important; + min-height: 100% !important; + bottom: 0 !important; + } + + /* ============================================ + PROFILE SECTION - Full width block + ============================================ */ + .dashboard-user-data-section { + display: block !important; + width: 100% !important; + max-width: 100% !important; + height: auto !important; + padding: 10px !important; + float: none !important; + } + + /* Single Row Tabs for Mobile */ + .dashboard-nav-buttons .nav-tabs { + flex-wrap: nowrap !important; /* Force single row */ + overflow-x: auto !important; /* scroll if needed */ + justify-content: flex-start !important; + width: 100% !important; + padding: 5px 0 !important; + } + + .dashboard-nav-buttons .nav-tabs li .nav-link { + padding: 6px 10px !important; /* Smaller padding */ + font-size: 11px !important; /* Smaller text */ + white-space: nowrap !important; + } + + /* ... rest of profile section styles ... */ + + + .profile-container { + padding: 15px !important; + margin: 0 auto 10px auto !important; + border-radius: 16px !important; + width: 100% !important; + // max-width: 250px !important; + } + + .profile-container .pp-image-section img, + .profile-container .pp-image-section .img-box { + height: 80px !important; + width: 80px !important; + } + + .profile-container .info-section h2 { + font-size: 16px !important; + } + + .profile-container .info-section p { + font-size: 11px !important; + } + + /* ============================================ + STATISTICS SECTION - CSS Grid Layout (2 columns) + Key fix: Replace horizontal scroll with grid + ============================================ */ + .dashboard-user-statistics-section { + display: block !important; + width: 100% !important; + max-width: 100% !important; + height: auto !important; + padding: 28px 10px 40px 10px !important; + overflow: visible !important; /* No more horizontal scroll */ + float: none !important; + } + + .dashboard-charts-section { + display: block !important; + width: 100% !important; + height: auto !important; + padding: 0 !important; + } + + .dashboard-module-charts { + display: grid !important; + grid-template-columns: repeat(2, 1fr) !important; /* 2 columns */ + gap: 10px !important; + width: 100% !important; + padding: 5px !important; + } + + /* Override Bootstrap col classes for grid children */ + .dashboard-module-charts .module-box, + .dashboard-module-charts .module-box.col-12, + .dashboard-module-charts .module-box.col-sm-6 { + width: 100% !important; + max-width: 100% !important; + min-width: unset !important; + flex: unset !important; + padding: 0 !important; + } + + .module-box .module-box-container { + padding: 12px 10px !important; + border-radius: 12px !important; + height: 100% !important; + min-height: 120px !important; + } + + .module-box h3 { + font-size: 9px !important; + letter-spacing: 0.5px !important; + margin: 6px 0 0 0 !important; + } + + .module-box h4 { + font-size: 20px !important; + margin: 6px 0 !important; + } + + .module-box p { + font-size: 9px !important; + margin: 4px 0 !important; + } + + .module-box p span { + font-size: 9px !important; + gap: 4px !important; + } + + .module-box #chartContainer, + .module-box #chartPaylips, + .module-box #chartTimesheet { + height: 55px !important; + } + + .module-box svg { + // max-height: 55px !important; + } + + /* ============================================ + ATTENDANCE SECTION - Full width below cards + ============================================ */ + .dashboard-attendance-section { + width: 100% !important; + max-width: 100% !important; + min-width: unset !important; + flex: unset !important; + padding: 10px 0 !important; + border-left: none !important; + border-top: 1px solid rgba(255,255,255,0.1) !important; + margin-top: 10px !important; + } + + .attendance-section-body { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + padding: 15px !important; + margin: 0 5px !important; + border-radius: 12px !important; + gap: 12px !important; + } + + .attendance-section-body h3 { + font-size: 12px !important; + margin-bottom: 8px !important; + text-align: center !important; + } + + .attendance-section-body p.last-checkin-section { + font-size: 11px !important; + margin-bottom: 8px !important; + text-align: center !important; + } + + .attendance-img-section { + margin-bottom: 8px !important; + } + + .attendance-details-section { + text-align: center !important; + width: 100% !important; + } + + + + /* ============================================ + BODY SECTION + ============================================ */ + .dashboard-container .dashboard-body { + padding: 10px !important; + } + + .card3 { + padding: 0 4px !important; + margin-bottom: 15px !important; + } + + .card3 .card-body { + border-radius: 12px !important; + } + + .card3 .card-body .box-1 { + height: 140px !important; + padding: 12px 8px !important; + } + + .card3 .card-body .box-1 img { + height: 40px !important; + margin-bottom: 8px !important; + } + + .card3 .card-body .box-1 h3 { + font-size: 24px !important; + margin: 4px 0 !important; + } + + .card3 .card-body .box-1 h4 { + font-size: 11px !important; + } + + .card3 .card-body .box-2 { + height: 40px !important; + line-height: 40px !important; + gap: 6px !important; + } + + .card3 .card-body .box-2 span { + font-size: 11px !important; + } + + .card3 .card-body .box-2 i { + font-size: 18px !important; + } + + .attendance-section-body .attendance-img-section::before { + width: 50px !important; + height: 50px !important; + } + + .attendance-section-body .attendance-img-section img { + width: 50px !important; + height: 50px !important; + } + + .dashboard-nav-buttons { + flex-direction: column !important; + gap: 15px !important; + padding-bottom: 15px !important; + align-items: center !important; + } + + /* Single Row Tabs for Mobile */ + .dashboard-nav-buttons .nav-tabs { + flex-wrap: nowrap !important; /* Force single row */ + overflow-x: auto !important; /* scroll */ + justify-content: flex-start !important; /* Start align for scroll */ + width: 100% !important; + padding: 5px 0 !important; + display: flex !important; + -webkit-overflow-scrolling: touch !important; + } + + .dashboard-nav-buttons .nav-tabs li { + flex: 0 0 auto !important; + display: block !important; + } + + /* CRITICAL: Keep approval tabs hidden until JS shows them */ + .dashboard-nav-buttons .nav-tabs li.approval-tab-item[style*="display:none"], + .dashboard-nav-buttons .nav-tabs li.approval-tab-item[style*="display: none"] { + display: none !important; + } + + .dashboard-nav-buttons .nav-tabs li .nav-link { + padding: 8px 14px !important; + font-size: 12px !important; + } + + /* Fix Search Bar Alignment */ + .genius-search-container { + width: 100% !important; + margin: 0 !important; + justify-content: center !important; + position: relative !important; + } + + .genius-search-wrapper { + // width: 100% !important; + max-width: 100% !important; + position: relative !important; + } + + /* Center search input text */ + .genius-search-input { + width: 100% !important; + text-align: center !important; + padding-left: 40px !important; + padding-right: 40px !important; + box-sizing: border-box !important; + } + + /* Position search icon inside input - RTL SAFE */ + .genius-search-icon { + position: absolute !important; + right: 40px !important; /* Moved DEEP inside */ + left: auto !important; + top: 50% !important; + transform: translateY(-50%) !important; + pointer-events: none !important; + z-index: 1000 !important; /* Very high z-index */ + color: var(--dash-primary) !important; + } +} + +/* Extra small devices (400px and below) */ +@media (max-width: 400px) { + /* Fix Overlap Issue: Add margin to attendance section */ + .dashboard-attendance-section { + margin: 20px auto 0 auto !important; /* Centering with auto margins */ + padding-top: 10px !important; + border-top: 1px solid rgba(0,0,0,0.05) !important; + width: 90% !important; /* Contain width */ + max-width: 350px !important; + } + + .attendance-section-body { + width: 100% !important; + margin: 0 !important; + box-shadow: 0 4px 15px rgba(0,0,0,0.05) !important; + } + + .profile-container .pp-image-section img, + .profile-container .pp-image-section .img-box { + height: 60px !important; + width: 60px !important; + } + + .profile-container .info-section h2 { + font-size: 14px !important; + } + + .profile-container .info-section p { + font-size: 10px !important; + } + + /* Single column cards on very small screens */ + .dashboard-module-charts { + grid-template-columns: 1fr !important; /* Single column */ + gap: 8px !important; + } + + .module-box .module-box-container { + min-height: 100px !important; + } + + .module-box h4 { + font-size: 18px !important; + } + + .module-box h3 { + font-size: 8px !important; + } + + .card3 .card-body .box-1 h3 { + font-size: 20px !important; + } + + .attendance-section-body .attendance-img-section::before { + width: 45px !important; + height: 45px !important; + } + + .attendance-section-body .attendance-img-section img { + width: 45px !important; + height: 45px !important; + } +} + +/* ============================================================ + HIDDEN CLASS FOR SEARCH FILTER + ============================================================ */ + +.card-hidden, +.genius-hidden { + display: none !important; +} + +/* ============================================================ + GENIUS SEARCH BAR - Premium Design + ============================================================ */ + +.genius-search-container { + display: flex !important; + align-items: center !important; + margin-left: auto !important; +} + +.genius-search-wrapper { + position: relative !important; + display: flex !important; + align-items: center !important; +} + +.genius-search-icon { + position: absolute !important; + left: 14px !important; + color: var(--dash-text-light) !important; + font-size: 14px !important; + pointer-events: none !important; + z-index: 1 !important; +} + +.genius-search-input { + background: var(--dash-white) !important; + border: 1px solid var(--dash-border) !important; + border-radius: 25px !important; + padding: 10px 40px 10px 38px !important; + font-size: 14px !important; + width: 300px !important; + color: var(--dash-text) !important; + transition: all var(--dash-transition) !important; + box-shadow: var(--dash-shadow-sm) !important; +} + +.genius-search-input::placeholder { + color: var(--dash-text-light) !important; +} + +.genius-search-input:focus { + outline: none !important; + border-color: var(--dash-primary) !important; + box-shadow: 0 0 0 3px rgba(8, 145, 178, 0.15) !important; +} + +.genius-search-clear { + position: absolute !important; + right: 14px !important; + color: var(--dash-text-light) !important; + font-size: 18px !important; + cursor: pointer !important; + line-height: 1 !important; + transition: color var(--dash-transition) !important; +} + +.genius-search-clear:hover { + color: var(--dash-danger) !important; +} + +/* ============================================================ + SELF SERVICES - Title Styling (Not Button) + ============================================================ */ + +/* Style Self Services tab as a prominent title when it's the only tab */ +.dashboard-nav-buttons .nav-tabs li.genius-single-tab { + background: none !important; + box-shadow: none !important; + border: none !important; +} + +.dashboard-nav-buttons .nav-tabs li.genius-single-tab a, +.dashboard-nav-buttons .nav-tabs li.genius-single-tab a.genius-title-styled { + background: transparent !important; + color: var(--dash-secondary) !important; + font-size: 18px !important; + font-weight: 700 !important; + padding: 8px 4px 12px 4px !important; + box-shadow: none !important; + border: none !important; + border-bottom: 3px solid var(--dash-primary) !important; + cursor: default !important; + text-transform: none !important; + letter-spacing: 0.3px !important; + border-radius: 0 !important; +} + +.dashboard-nav-buttons .nav-tabs li.genius-single-tab a:hover, +.dashboard-nav-buttons .nav-tabs li.genius-single-tab a.genius-title-styled:hover { + background: transparent !important; + color: var(--dash-secondary) !important; + transform: none !important; +} + +/* When there's only one tab, make the nav-tabs transparent */ +.dashboard-nav-buttons .nav-tabs:has(.genius-single-tab) { + background: transparent !important; + border: none !important; + box-shadow: none !important; + padding: 0 !important; +} + +/* ============================================================ + CSS-ONLY SINGLE TAB DETECTION + When nav-tabs has only one li, style it as a title, not a button + ============================================================ */ + +/* CSS fallback for single tab - using :only-child selector */ +.dashboard-nav-buttons .nav-tabs li:only-child { + background: none !important; + box-shadow: none !important; + border: none !important; +} + +.dashboard-nav-buttons .nav-tabs li:only-child a, +.dashboard-nav-buttons .nav-tabs li:only-child a.nav-link, +.dashboard-nav-buttons .nav-tabs li:only-child a.active { + background: transparent !important; + color: var(--dash-secondary) !important; + font-size: 20px !important; + font-weight: 700 !important; + padding: 8px 0 12px 0 !important; + box-shadow: none !important; + border: none !important; + border-bottom: 3px solid var(--dash-primary) !important; + cursor: default !important; + text-transform: none !important; + letter-spacing: -0.3px !important; + border-radius: 0 !important; +} + +.dashboard-nav-buttons .nav-tabs li:only-child a:hover, +.dashboard-nav-buttons .nav-tabs li:only-child a.active:hover { + background: transparent !important; + color: var(--dash-secondary) !important; + transform: none !important; +} + +/* When nav-tabs has only one child, make parent transparent */ +.dashboard-nav-buttons .nav-tabs:has(li:only-child) { + background: transparent !important; + border: none !important; + box-shadow: none !important; + padding: 0 !important; + display: block !important; +} + +/* ============================================================ + SERVICE CARDS (Card3) - GENIUS SUBTLE ENHANCEMENTS + ============================================================ */ + +/* Card3 number styling - subtle genius effect */ +.card3 .card-body .box-1 h3 { + position: relative !important; + color: var(--dash-primary) !important; +} + +/* Card3 title - elegant styling */ +.card3 .card-body .box-2 h4 { + color: var(--dash-secondary) !important; + font-weight: 600 !important; + letter-spacing: -0.2px !important; + transition: color var(--dash-transition) !important; +} + +.card3 .card-body:hover .box-2 h4 { + color: var(--dash-primary) !important; +} + +/* Card3 icon subtle glow on hover */ +.card3 .card-body .box-2 i { + color: var(--dash-text-light) !important; + transition: all var(--dash-transition) !important; +} + +.card3 .card-body:hover .box-2 i { + color: var(--dash-primary) !important; + text-shadow: 0 0 8px rgba(8, 145, 178, 0.3) !important; +} + +/* ============================================================ + GREEN DOT - FINAL POSITION FIX + ============================================================ */ + +/* Ensure green dot is in correct position */ +.profile-container .pp-image-section { + position: relative !important; + display: inline-block !important; +} + + + +/* ============================================================ + RTL SUPPORT FOR NEW ELEMENTS + ============================================================ */ + +.o_rtl { + .genius-search-container { + margin-left: 0 !important; + margin-right: auto !important; + } + + .genius-search-icon { + left: auto !important; + right: 14px !important; + } + + .genius-search-input { + padding: 10px 38px 10px 40px !important; + text-align: right !important; + } + + .genius-search-clear { + right: auto !important; + left: 14px !important; + } +} + +/* ============================================================ + CRITICAL OVERRIDES - MUST LOAD LAST + These rules override conflicting styles from core.scss + ============================================================ */ + + + +/* 2. FIX: Remove ALL colored theme backgrounds from stat boxes */ +.module-box-container, +.module-box .module-box-container, +.module-box-container.theme-1, +.module-box-container.theme-2, +.module-box-container.theme-3, +.module-box .module-box-container.theme-1, +.module-box .module-box-container.theme-2, +.module-box .module-box-container.theme-3 { + background: var(--dash-white, #ffffff) !important; + background-color: var(--dash-white, #ffffff) !important; +} + +/* Also remove colored backgrounds from module-icon inside themed containers */ +.module-box-container.theme-1 .module-icon, +.module-box-container.theme-2 .module-icon, +.module-box-container.theme-3 .module-icon { + background: var(--dash-primary) !important; +} + +/* 3. FIX: COMPLETE OVERRIDE - Remove ALL borders from nav-tabs (core.scss adds them) */ + + +/* 4. FIX: Correct green dot position - removed by user request */ +.profile-container .pp-image-section { + position: relative !important; + display: inline-block !important; +} + + + +/* 5. FIX: Round corners on attendance/statistics white box */ +.attendance-section-body, +.dashboard-attendance-section .attendance-section-body, +.module-box-container, +.module-box .module-box-container { + border-radius: 10px !important; +} + +/* 6. FIX: Ensure animated border doesn't appear */ +.dashboard-nav-buttons .nav-tabs::before, +.dashboard-nav-buttons .nav-tabs::after, +.nav-tabs::before, +.nav-tabs::after { + display: none !important; + content: none !important; +} + +/* 7. FIX: Remove any focus/active border styling that might cause green border */ +.dashboard-nav-buttons .nav-tabs li a:focus, +.dashboard-nav-buttons .nav-tabs li a:active, +.nav-tabs li a:focus, +.nav-tabs li a:active { + outline: none !important; + border: none !important; + box-shadow: none !important; +} + +/* 8. FIX: Remove any animated transitions on initial load */ +.dashboard-container *, +.system-dashboard * { + animation-delay: 0s !important; +} + +/* Disable card entrance animations that cause "reload" glitch */ +@keyframes none-animation { + from { opacity: 1; transform: none; } + to { opacity: 1; transform: none; } +} + +.card-section .card, +.card-section1 .card-line, +.dashboard-module-charts .module-box { + animation: none !important; + opacity: 1 !important; + transform: none !important; +} + +/* ============================================================ + CHART TOOLTIP - Premium Styling + ============================================================ */ + +/* Override the ugly black tooltip */ +.pc-tooltip { + background: var(--dash-secondary) !important; + color: white !important; + font-size: 12px !important; + font-weight: 500 !important; + padding: 8px 14px !important; + border-radius: 8px !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important; + border: 1px solid rgba(255, 255, 255, 0.1) !important; + backdrop-filter: blur(8px) !important; + -webkit-backdrop-filter: blur(8px) !important; + line-height: 1.4 !important; + max-width: 200px !important; + text-align: center !important; +} + +/* ============================================================ + CHART HALF-CIRCLES - Subtle Enhancements + ============================================================ */ + +/* Chart container subtle glow */ +#chartContainer, +#chartPaylips, +#chartTimesheet { + position: relative !important; +} + +#chartContainer svg, +#chartPaylips svg, +#chartTimesheet svg { + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.08)) !important; +} + +/* Chart paths - subtle gradient effect */ +.module-box svg path { + transition: opacity var(--dash-transition) !important; +} + +.module-box:hover svg path { + opacity: 0.9 !important; +} + + +/* ============================================================ + ADD NEW BUTTON - Enhanced Visibility and Hover + ============================================================ */ + +/* Ensure + icon is always visible */ +.card3 .card-body .box-2 i.mdi-plus, +.card3 .card-body .box-2 i[class*="plus"] { + color: white !important; + font-size: 20px !important; + font-weight: bold !important; + opacity: 1 !important; + visibility: visible !important; +} + +/* Add New button - base state */ +.card3 .card-body .box-2 { + background: var(--dash-primary) !important; + transition: all 0.25s ease !important; + cursor: pointer !important; + position: relative !important; + overflow: hidden !important; +} + +/* Add subtle shine overlay */ +.card3 .card-body .box-2::after { + content: '' !important; + position: absolute !important; + top: 0 !important; + left: -100% !important; + width: 100% !important; + height: 100% !important; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent) !important; + transition: left 0.5s ease !important; +} + +/* Special hover for Add New button area */ +.card3 .card-body .box-2:hover { + background: var(--dash-primary-dark) !important; + transform: translateY(-2px) !important; + box-shadow: 0 6px 20px rgba(var(--dash-primary-rgb, 8, 145, 178), 0.4) !important; +} + +.card3 .card-body .box-2:hover::after { + left: 100% !important; +} + +.card3 .card-body .box-2:hover i { + transform: rotate(90deg) scale(1.1) !important; +} + +.card3 .card-body .box-2:hover span { + letter-spacing: 1px !important; +} + +/* ============================================================ + CHECK-IN BUTTON - Ensure Theme Variable Usage + ============================================================ */ + +/* Re-enforce theme variable on check-in button */ + + +/* Check-in/out button styles now defined earlier in file */ +/* Removed duplicate rule that conflicted with white bg approach */ + +/* ============================================================ + THEME VARIABLES - Complete Element Binding + ============================================================ */ + +/* Card2 header gradient - use secondary */ +.card2 .card-container .card-header { + background: linear-gradient(135deg, var(--dash-primary) 0%, var(--dash-primary-dark) 100%) !important; +} + +/* Card2 hover accent */ +.card2 .card-container:hover { + border-color: var(--dash-primary-light) !important; +} + +/* Action buttons in cards */ +.card2 .card-body table tr td:last-child div, +.card2 .card-body .btn-action { + background: var(--dash-primary) !important; +} + +/* Search input focus */ +.genius-search-input:focus, +.dashboard-search-input:focus { + border-color: var(--dash-primary) !important; + box-shadow: 0 0 0 3px rgba(var(--dash-primary-rgb, 8, 145, 178), 0.15) !important; +} + +/* Single tab underline */ +.dashboard-nav-buttons .nav-tabs li:only-child a { + border-bottom-color: var(--dash-primary) !important; +} + +/* Scrollbar thumb */ +.card2 .card-container .card-body::-webkit-scrollbar-thumb { + background: var(--dash-primary) !important; +} + +/* Status indicators */ +.attendance-section-body .attendance-status.online, +.status-online { + color: var(--dash-success) !important; + background: rgba(var(--dash-success-rgb, 16, 185, 129), 0.1) !important; +} + +.attendance-section-body .attendance-status.offline, +.status-offline { + color: var(--dash-danger, #ef4444) !important; +} + +/* ============================================================ + CRITICAL: ATTENDANCE SECTION OVERRIDES + ============================================================ */ + +/* Ensure all text inside attendance has no extra backgrounds */ +.attendance-section-body p, +.attendance-section-body span, +.attendance-section-body .last-checkin-section { + background: none !important; + background-color: transparent !important; +} + + + +/* ============================================================ + CRITICAL: PROFILE SECTION VISIBILITY + ============================================================ */ + +/* Ensure profile text is visible on glassmorphism background */ +.profile-container .info-section .fn-section, +.profile-container .info-section .fn-job, +.profile-container .info-section .fn-id { + color: white !important; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) !important; +} + +.profile-container .info-section .fn-section { + font-size: 16px !important; + font-weight: 700 !important; +} + +.profile-container .info-section .fn-job { + font-size: 11px !important; + font-weight: 500 !important; + opacity: 0.85 !important; + text-transform: uppercase !important; + letter-spacing: 0.5px !important; +} + + +/* ============================================================ + ENHANCED CELEBRATION MODE - Persistent Birthday & Anniversary + ============================================================ */ + +/* Dashboard-wide celebration mode class */ +.dashboard-container.celebration-mode { + position: relative; +} + +/* Festive header gradient animation for birthday */ +.dashboard-container.birthday-mode .dashboard-header { + background: linear-gradient(135deg, #667eea 0%, #f093fb 50%, #f5576c 100%) !important; + background-size: 200% 200% !important; + animation: festiveGradient 5s ease infinite !important; +} + +/* Festive header gradient animation for anniversary */ +.dashboard-container.anniversary-mode .dashboard-header { + background: linear-gradient(135deg, #4facfe 0%, #00f2fe 50%, #667eea 100%) !important; + background-size: 200% 200% !important; + animation: festiveGradient 5s ease infinite !important; +} + +@keyframes festiveGradient { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +/* Golden glow ring around employee photo */ +.img-box.celebration-glow { + animation: photoGlow 2s ease-in-out infinite; + box-shadow: 0 0 0 4px rgba(255, 215, 0, 0.6), + 0 0 20px rgba(255, 215, 0, 0.4), + 0 0 40px rgba(255, 215, 0, 0.2) !important; +} + +.birthday-mode .img-box.celebration-glow { + box-shadow: 0 0 0 4px rgba(255, 105, 180, 0.7), + 0 0 20px rgba(255, 105, 180, 0.5), + 0 0 40px rgba(255, 105, 180, 0.3) !important; +} + +.anniversary-mode .img-box.celebration-glow { + box-shadow: 0 0 0 4px rgba(79, 172, 254, 0.7), + 0 0 20px rgba(79, 172, 254, 0.5), + 0 0 40px rgba(79, 172, 254, 0.3) !important; +} + +@keyframes photoGlow { + 0%, 100% { + transform: scale(1); + filter: brightness(1); + } + 50% { + transform: scale(1.02); + filter: brightness(1.1); + } +} + +/* Celebration badge near employee name */ +.celebration-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + margin-bottom: 8px; + animation: badgePop 0.5s ease-out, badgeGlow 2s ease-in-out infinite; +} + +.celebration-badge.birthday { + background: linear-gradient(135deg, #f093fb, #f5576c); + color: white; + box-shadow: 0 4px 15px rgba(245, 87, 108, 0.4); +} + +.celebration-badge.anniversary { + background: linear-gradient(135deg, #4facfe, #00f2fe); + color: white; + box-shadow: 0 4px 15px rgba(79, 172, 254, 0.4); +} + +.badge-emoji { + font-size: 16px; + animation: bounce 1s ease infinite; +} + +.badge-text { + letter-spacing: 0.3px; +} + +@keyframes badgePop { + 0% { + transform: scale(0); + opacity: 0; + } + 50% { + transform: scale(1.2); + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +@keyframes badgeGlow { + 0%, 100% { + box-shadow: 0 4px 15px rgba(245, 87, 108, 0.4); + } + 50% { + box-shadow: 0 4px 25px rgba(245, 87, 108, 0.6); + } +} + +/* Floating celebration message */ +.celebration-floating-msg { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + margin-top: 12px; + border-radius: 10px; + font-size: 13px; + font-weight: 500; + animation: floatIn 0.6s ease-out, floatBounce 3s ease-in-out infinite; + position: relative; + overflow: hidden; +} + +.celebration-floating-msg::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent); + animation: shimmer 3s ease-in-out infinite; +} + +.celebration-floating-msg.birthday { + background: linear-gradient(135deg, rgba(240, 147, 251, 0.15), rgba(245, 87, 108, 0.15)); + border: 1px solid rgba(245, 87, 108, 0.3); + color: #fff; +} + +.celebration-floating-msg.anniversary { + background: linear-gradient(135deg, rgba(79, 172, 254, 0.15), rgba(0, 242, 254, 0.15)); + border: 1px solid rgba(79, 172, 254, 0.3); + color: #fff; +} + +.floating-emoji { + font-size: 20px; + animation: bounce 1.5s ease infinite; +} + +.floating-text { + flex: 1; +} + +@keyframes floatIn { + 0% { + opacity: 0; + transform: translateY(20px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes floatBounce { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-3px); + } +} + +@keyframes shimmer { + 0% { + left: -100%; + } + 100% { + left: 100%; + } +} + +@keyframes bounce { + 0%, 20%, 50%, 80%, 100% { + transform: translateY(0); + } + 40% { + transform: translateY(-10px); + } + 60% { + transform: translateY(-5px); + } +} + + +/* ============================================================ + ANIMATED COUNTERS - Smooth number transitions + ============================================================ */ + +/* Ensure counter elements have proper number styling */ +.chart-center-value, +.leave-center-value, +.leave-total-amount span, +.leave-left-amount span, +.timesheet-center-value, +.attendance-center-value { + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum"; +} + +/* Counter value pulse on load */ +.counter-animated { + animation: counterPop 0.3s ease-out; +} + +@keyframes counterPop { + 0% { + transform: scale(0.8); + opacity: 0; + } + 50% { + transform: scale(1.05); + } + 100% { + transform: scale(1); + opacity: 1; + } +} + + +/* ============================================================ + ENHANCED CELEBRATION DECORATIONS - More Festive Effects + ============================================================ */ + +/* Shimmer effect on greeting TEXT only (not emoji) - NO visible background */ +.celebration-mode .greeting-text { + font-weight: 700 !important; + font-size: 14px !important; +} + +/* Emoji stays normal */ +.celebration-mode .greeting-emoji { + font-size: 16px; + margin-inline-end: 4px; +} + +/* Shimmer effect on text portion only */ +.celebration-mode .greeting-shimmer { + background: linear-gradient(90deg, #ffd700, #ff69b4, #ffd700); + background-size: 200% auto; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + animation: textShimmer 2s linear infinite; +} + +.birthday-mode .greeting-shimmer { + background: linear-gradient(90deg, #f093fb, #f5576c, #ffd700, #f093fb); + background-size: 300% auto; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.anniversary-mode .greeting-shimmer, +.anniversary-mode .anniversary-shimmer { + background: linear-gradient(90deg, #ffd700, #ff8c00, #ffa500, #ffd700); + background-size: 300% auto; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +@keyframes textShimmer { + 0% { background-position: 0% center; } + 100% { background-position: 200% center; } +} + +/* Appreciation ribbon for anniversary - positioned at bottom of header */ +.appreciation-ribbon { + position: absolute; + bottom: 8px; + left: 50%; + transform: translateX(-50%); + background: linear-gradient(135deg, rgba(255, 215, 0, 0.95), rgba(255, 140, 0, 0.95)); + color: #333; + padding: 8px 8px; + border-radius: 25px; + font-size: 12px; + font-weight: 600; + box-shadow: 0 4px 15px rgba(255, 140, 0, 0.4); + animation: ribbonSlideIn 0.6s ease-out, ribbonGlow 2s ease-in-out infinite; + z-index: 100; + white-space: nowrap; + max-width: 90%; + overflow: hidden; + text-overflow: ellipsis; +} + +.appreciation-ribbon .ribbon-text { + display: inline-block; +} + +@keyframes ribbonSlideIn { + 0% { + opacity: 0; + transform: translateX(-50%) translateY(20px); + } + 100% { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +@keyframes ribbonGlow { + 0%, 100% { + box-shadow: 0 4px 15px rgba(255, 140, 0, 0.4); + } + 50% { + box-shadow: 0 4px 25px rgba(255, 140, 0, 0.6); + } +} + +/* Festive glow on main stat cards */ +.celebration-mode .card2 { + position: relative; + overflow: visible; +} + +.celebration-mode .card2::after { + content: ''; + position: absolute; + top: -2px; + left: -2px; + right: -2px; + bottom: -2px; + border-radius: inherit; + background: linear-gradient(45deg, rgba(255,215,0,0.3), transparent, rgba(255,105,180,0.3)); + z-index: -1; + animation: cardGlow 3s ease-in-out infinite; + pointer-events: none; +} + +.birthday-mode .card2::after { + background: linear-gradient(45deg, rgba(240,147,251,0.4), transparent, rgba(245,87,108,0.4)); +} + +.anniversary-mode .card2::after { + background: linear-gradient(45deg, rgba(79,172,254,0.4), transparent, rgba(0,242,254,0.4)); +} + +@keyframes cardGlow { + 0%, 100% { opacity: 0.5; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.02); } +} + +/* Celebration stars decoration on corners (using pseudo-elements, no layout shift) */ +.celebration-mode .dashboard-user-data-section::before { + content: '✨'; + position: absolute; + top: 25px; + right: 25px; + font-size: 20px; + animation: starTwinkle 1.5s ease-in-out infinite; + pointer-events: none; + z-index: 10; +} + +.celebration-mode .dashboard-user-data-section::after { + content: '⭐'; + position: absolute; + bottom: 25px; + left: 25px; + font-size: 16px; + animation: starTwinkle 2s ease-in-out infinite 0.5s; + pointer-events: none; + z-index: 10; +} + +.o_rtl .celebration-mode .dashboard-user-data-section::before { + right: auto; + left: 25px; +} + +.o_rtl .celebration-mode .dashboard-user-data-section::after { + left: auto; + right: 25px; +} + +@keyframes starTwinkle { + 0%, 100% { opacity: 0.6; transform: scale(1) rotate(0deg); } + 50% { opacity: 1; transform: scale(1.2) rotate(10deg); } +} + +/* Festive employee code badge */ +.celebration-mode .emp-code-badge { + background: linear-gradient(135deg, #ffd700, #ffb347) !important; + color: #333 !important; + box-shadow: 0 2px 10px rgba(255, 215, 0, 0.5) !important; + animation: badgeShine 2s ease-in-out infinite; +} + +@keyframes badgeShine { + 0%, 100% { box-shadow: 0 2px 10px rgba(255, 215, 0, 0.5); } + 50% { box-shadow: 0 2px 20px rgba(255, 215, 0, 0.8); } +} + +/* Celebration effect on nav-buttons separator line */ +.celebration-mode .dashboard-nav-buttons::after { + height: 4px !important; /* Slightly thicker to show effect */ + opacity: 1 !important; + /* Stronger gradient with more stops for visible movement */ + background: linear-gradient(90deg, + #ffd700, #ff69b4, #00f2fe, #ffd700, #ff69b4 + ) !important; + background-size: 200% 100% !important; /* Smaller size = faster visual movement */ + animation: rainbowBorder 1.5s linear infinite !important; /* Faster animation */ + box-shadow: 0 1px 4px rgba(0,0,0,0.1); +} + +.birthday-mode .dashboard-nav-buttons::after { + background: linear-gradient(90deg, + #f093fb, #f5576c, #ffd700, #00f2fe, #f093fb + ) !important; + background-size: 200% 100% !important; +} + +.anniversary-mode .dashboard-nav-buttons::after { + /* High contrast gold/orange/white for shine */ + background: linear-gradient(90deg, + #ffd700, #ff8c00, #ffffff, #ff8c00, #ffd700 + ) !important; + background-size: 200% 100% !important; +} + +@keyframes rainbowBorder { + 0% { background-position: 0% 0; } + 100% { background-position: 200% 0; } +} + +/* Floating emoji decorations on charts */ +.celebration-mode .chart-section::before { + content: '🎈'; + position: absolute; + top: 5px; + right: 5px; + font-size: 18px; + animation: floatEmoji 3s ease-in-out infinite; + pointer-events: none; + opacity: 0.8; +} + +.birthday-mode .chart-section::before { + content: '🎂'; +} + +.anniversary-mode .chart-section::before { + content: '🎉'; +} + +@keyframes floatEmoji { + 0%, 100% { transform: translateY(0) rotate(-5deg); } + 50% { transform: translateY(-8px) rotate(5deg); } +} + +/* Make sure decorations don't affect layout */ +.celebration-mode .dashboard-user-data-section, +.celebration-mode .chart-section { + position: relative; +} + +/* ═══════════════════════════════════════════════════════════ + PREMIUM CHECK-IN/OUT NOTIFICATION - GENIUS DESIGN + Centered glass-morphism modal with warm professional messaging + ═══════════════════════════════════════════════════════════ */ + +/* Full-screen overlay with backdrop blur */ +.attendance-notification-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(15, 23, 42, 0.2); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: 99999; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + + &.show { + opacity: 1; + visibility: visible; + + .attendance-notification { + transform: scale(1) translateY(0); + opacity: 1; + } + } +} + +/* Premium notification card */ +.attendance-notification { + background: linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(248,250,252,0.98) 100%); + border-radius: 24px; + padding: 40px 50px; + text-align: center; + box-shadow: + 0 25px 80px rgba(0, 0, 0, 0.25), + 0 10px 30px rgba(0, 0, 0, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.8); + transform: scale(0.9) translateY(20px); + opacity: 0; + transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); + max-width: 420px; + width: 90vw; + position: relative; + overflow: hidden; + + /* Decorative gradient line at top */ + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 5px; + border-radius: 24px 24px 0 0; + } + + /* Check-in gradient (warm gold/orange) */ + &.notification-checkin::before { + background: linear-gradient(90deg, #f59e0b, #f97316, #fbbf24); + } + + /* Check-out gradient (cool blue/teal) */ + &.notification-checkout::before { + background: linear-gradient(90deg, #0891b2, #06b6d4, #22d3ee); + } +} + +/* Icon wrapper with glow effect */ +.notification-icon-wrapper { + width: 90px; + height: 90px; + margin: 0 auto 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + animation: iconBounce 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); + + .notification-icon { + font-size: 56px; + line-height: 1; + filter: drop-shadow(0 4px 12px rgba(0,0,0,0.1)); + } +} + +/* Check-in icon background */ +.notification-checkin .notification-icon-wrapper { + background: linear-gradient(135deg, rgba(251, 191, 36, 0.2) 0%, rgba(245, 158, 11, 0.1) 100%); + box-shadow: 0 0 40px rgba(245, 158, 11, 0.3); +} + +/* Check-out icon background */ +.notification-checkout .notification-icon-wrapper { + background: linear-gradient(135deg, rgba(6, 182, 212, 0.2) 0%, rgba(8, 145, 178, 0.1) 100%); + box-shadow: 0 0 40px rgba(6, 182, 212, 0.3); +} + +@keyframes iconBounce { + 0% { transform: scale(0) rotate(-10deg); } + 60% { transform: scale(1.2) rotate(5deg); } + 100% { transform: scale(1) rotate(0); } +} + +/* Content styles */ +.notification-content { + .notification-title { + font-size: 28px; + font-weight: 700; + color: #1e293b; + margin: 0 0 10px; + letter-spacing: -0.5px; + animation: fadeSlideUp 0.5s 0.2s ease both; + } + + .notification-subtitle { + font-size: 16px; + font-weight: 400; + color: #64748b; + margin: 0 0 20px; + line-height: 1.6; + animation: fadeSlideUp 0.5s 0.3s ease both; + } + + .notification-time { + display: inline-flex; + align-items: center; + gap: 8px; + background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%); + padding: 10px 20px; + border-radius: 50px; + animation: fadeSlideUp 0.5s 0.4s ease both; + + .time-icon { + font-size: 16px; + } + + .time-value { + font-size: 15px; + font-weight: 600; + color: #334155; + letter-spacing: 0.5px; + } + } +} + +@keyframes fadeSlideUp { + from { + opacity: 0; + transform: translateY(15px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Subtle Icon Pulse Animation on Check-in/out */ +.attendance-success-pulse { + animation: attendancePulse 0.8s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes attendancePulse { + 0% { transform: scale(1); } + 30% { transform: scale(1.2); } + 60% { transform: scale(0.95); } + 100% { transform: scale(1); } +} + +/* Mobile Responsive */ +@media (max-width: 576px) { + .attendance-notification { + padding: 30px 25px; + border-radius: 20px; + + &::before { + height: 4px; + } + } + + .notification-icon-wrapper { + width: 70px; + height: 70px; + margin-bottom: 18px; + + .notification-icon { + font-size: 42px; + } + } + + .notification-content { + .notification-title { + font-size: 22px; + } + + .notification-subtitle { + font-size: 14px; + margin-bottom: 16px; + } + + .notification-time { + padding: 8px 16px; + + .time-value { + font-size: 14px; + } + } + } +} + +/* RTL Support */ +[dir="rtl"] .attendance-notification, +body.o_rtl .attendance-notification { + .notification-content { + .notification-subtitle { + direction: rtl; + } + } +} + +/* ═══════════════════════════════════════════════════════════ + ATTENDANCE ERROR TOAST - Zone/Location Errors + ═══════════════════════════════════════════════════════════ */ + +.attendance-error-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(15, 23, 42, 0.3); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: 99999; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + &.show { + opacity: 1; + visibility: visible; + + .attendance-error-toast { + transform: scale(1) translateY(0); + opacity: 1; + } + } +} + +.attendance-error-toast { + background: #ffffff; + border-radius: 16px; + padding: 30px 35px; + text-align: center; + box-shadow: + 0 20px 60px rgba(0, 0, 0, 0.2), + 0 8px 20px rgba(0, 0, 0, 0.1); + transform: scale(0.9) translateY(20px); + opacity: 0; + transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); + max-width: 380px; + width: 90vw; + border-top: 4px solid #f59e0b; + + .error-icon { + font-size: 48px; + margin-bottom: 16px; + animation: errorShake 0.5s ease; + } + + .error-message { + font-size: 15px; + font-weight: 500; + color: #334155; + line-height: 1.7; + margin-bottom: 24px; + white-space: pre-line; + } + + .error-ok-btn { + background: var(--dash-secondary) !important; + color: #ffffff; + border: none; + padding: 12px 40px; + border-radius: 8px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + transform: translateY(-2px); + filter: brightness(1.1); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + } + + &:active { + transform: translateY(0); + } + } +} + +@keyframes errorShake { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-8px); } + 40% { transform: translateX(8px); } + 60% { transform: translateX(-4px); } + 80% { transform: translateX(4px); } +} + +/* RTL Support for Error Toast */ +[dir="rtl"] .attendance-error-toast, +body.o_rtl .attendance-error-toast { + .error-message { + direction: rtl; + } +} + +/* Mobile Responsive */ +@media (max-width: 576px) { + .attendance-error-toast { + padding: 24px 20px; + + .error-icon { + font-size: 40px; + } + + .error-message { + font-size: 14px; + } + + .error-ok-btn { + padding: 10px 30px; + font-size: 14px; + } + } +} + +/* ═══════════════════════════════════════════════════════════ + DRAG AND DROP SERVICE CARDS REORDERING + ═══════════════════════════════════════════════════════════ */ + +.card3.draggable-card, +.card2.draggable-card { + cursor: grab; + transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; + position: relative; + + &:active { + cursor: grabbing; + } + + /* Dragging state - reduce opacity */ + &.dragging { + opacity: 0.5; + transform: scale(0.95); + z-index: 1000; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); + } + + /* Drop target highlight */ + &.drag-over { + transform: scale(1.03); + + .card-body, + .card-container { + border: 2px dashed var(--dash-primary, #0891b2) !important; + background: rgba(8, 145, 178, 0.05); + } + } +} + +/* Subtle drag handle hint on hover */ +.card3.draggable-card .card-body::after { + content: '⋮⋮'; + position: absolute; + top: 6px; + left: 6px; + font-size: 10px; + color: rgba(0, 0, 0, 0.15); + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; +} + +.card3.draggable-card:hover .card-body::after { + opacity: 1; +} + +/* RTL Support for drag handle */ +[dir="rtl"] .card3.draggable-card .card-body::after, +body.o_rtl .card3.draggable-card .card-body::after { + left: auto; + right: 6px; +} + +/* Disable drag on mobile - touch devices use different gestures */ +@media (max-width: 768px) { + .card3.draggable-card, + .module-box.draggable-card { + cursor: default; + + .card-body::after, + .module-box-container::after { + display: none; + } + } +} + +/* ═══════════════════════════════════════════════════════════ + DRAG AND DROP FOR STATS MODULE-BOXES (HEADER CARDS) + ═══════════════════════════════════════════════════════════ */ + +.module-box.draggable-card { + cursor: grab; + transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; + position: relative; + + &:active { + cursor: grabbing; + } + + /* Dragging state */ + &.dragging { + opacity: 0.5; + transform: scale(0.95); + z-index: 1000; + + .module-box-container { + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); + } + } + + /* Drop target highlight */ + &.drag-over { + transform: scale(1.02); + + .module-box-container { + border: 2px dashed var(--dash-primary, #0891b2) !important; + background: rgba(8, 145, 178, 0.08); + } + } +} + +/* Subtle drag handle hint on hover for stats cards */ +.module-box.draggable-card .module-box-container::after { + content: '⋮⋮'; + position: absolute; + top: 4px; + left: 4px; + font-size: 10px; + color: rgba(255, 255, 255, 0.4); + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; + z-index: 10; +} + +.module-box.draggable-card:hover .module-box-container::after { + opacity: 1; +} + +/* RTL Support for stats module-box drag handle */ +[dir="rtl"] .module-box.draggable-card .module-box-container::after, +body.o_rtl .module-box.draggable-card .module-box-container::after { + left: auto; + right: 4px; +} + +/* Service Icon Uniformity and Hover Effects */ +.service-icon { + /* Base styles setup in JS (height/width/font-size), + but we ensure the container allows transition here */ + display: inline-block; + vertical-align: middle; + /* Ensure icon is centered */ + text-align: center; +} + +/* Apply hover effect to the parent card so it triggers on card hover */ +.card-section1 .card3:hover .service-icon { + transform: scale(1.15); /* 15% zoom on hover */ +} + +/* Also support direct usage if needed */ +.service-icon:hover { + transform: scale(1.15); +} + +/* Approval Card Header Styling - MOVED FROM JS */ +.card2 .card-container .card-header { + background-color: var(--dash-primary) !important; +} +// .card-header h4, .card-header i.fa { +// color: white !important; +// } + +/* Missing Wrapper Class from JS Refactor */ +.service-icon-wrapper { + text-align: center; + margin-bottom: 10px; +} + +/* Approval Card Header Icon Styling */ +.approval-header-icon { + margin: 0 8px; /* Replaces margin-left: 8px; margin-right: 8px; */ + vertical-align: middle; +} + +img.approval-header-icon { + height: 30px; + width: 30px; + object-fit: contain; +} + +i.approval-header-icon { + font-size: 30px; + color: var(--dash-secondary); +} + +/* FORCE SUPER SPECIFIC OVERRIDE for Approval Card Header */ +/* User reported conflicts, so we use max specificity */ +.o_content .dashboard-container .card2 .card-container .card-header { + background-color: var(--dash-primary, #0891b2) !important; /* Fallback to Cyan */ + color: #ffffff !important; +} + +.o_content .dashboard-container .card2 .card-container .card-header h4, +.o_content .dashboard-container .card2 .card-container .card-header i { + color: #ffffff !important; +} + +/* ============================================== + DASHBOARD CONFIG PREVIEW STYLES + ============================================== */ + +/* Base container for preview */ +.dashboard-icon-preview-box { + display: flex; + align-items: center; + justify-content: center; + background: #f1f1f1; + border: 1px solid #ddd; + border-radius: 4px; + overflow: hidden; + color: #666; + margin: 0; /* Align nicely */ +} + +.dashboard-icon-preview-box img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* --- LIST VIEW (TREE) CONTEXT --- */ +/*.o_list_view .dashboard-icon-preview-box, */ +/* Standard Odoo list uses o_data_cell classes, but we are inside a widget=html */ +.o_list_view .dashboard-icon-preview-box { + width: 40px; + height: 40px; +} + +.o_list_view .dashboard-icon-preview-box i { + font-size: 18px; /* Normal icon size for list */ +} + +/* --- FORM VIEW CONTEXT --- */ +/* Standard Odoo form avatar section */ +.o_form_view .oe_avatar .dashboard-icon-preview-box, +/* Fallback if oe_avatar class is missing or structure differs */ +.o_form_view .dashboard-icon-preview-box { + width: 90px; + height: 90px; + background: #f9f9f9; +} + +.o_form_view .dashboard-icon-preview-box i { + font-size: 3em; /* roughly fa-3x ~ fa-4x equivalent */ +} + +/* Special Case for Error/Default */ +.dashboard-icon-preview-box.error-mode { + color: #ef4444; /* Red for error */ + background: #fee2e2; +} + +/* ============================================================ + GENDER-AWARE PROFILE FRAME STYLING + ============================================================ + Subtle, professional differentiation for male/female employees + ============================================================ */ + +/* Male: Professional dark/cyan accent - HIGH SPECIFICITY to override base .img-box */ +.profile-container .pp-image-section .img-box.gender-male, +.img-box.gender-male { + border: 4px solid var(--dash-secondary) !important; + box-shadow: + 0 0 0 3px rgba(30, 41, 59, 0.15), + 0 8px 20px rgba(0, 0, 0, 0.25) !important; +} + +.profile-container .pp-image-section .img-box.gender-male:hover, +.img-box.gender-male:hover { + border-color: var(--dash-primary) !important; + box-shadow: + 0 0 0 4px rgba(8, 145, 178, 0.2), + 0 10px 25px rgba(0, 0, 0, 0.3) !important; +} + +/* Female: Elegant rose gold/warm accent - HIGH SPECIFICITY to override base .img-box */ +.profile-container .pp-image-section .img-box.gender-female, +.img-box.gender-female { + border: 4px solid #be847c !important; /* Rose gold */ + box-shadow: + 0 0 0 3px rgba(190, 132, 124, 0.2), + 0 8px 20px rgba(0, 0, 0, 0.2) !important; +} + +.profile-container .pp-image-section .img-box.gender-female:hover, +.img-box.gender-female:hover { + border-color: #d4a59a !important; /* Lighter rose gold on hover */ + box-shadow: + 0 0 0 4px rgba(212, 165, 154, 0.25), + 0 10px 25px rgba(0, 0, 0, 0.25) !important; +} + +/* Transition for smooth hover effect */ +.profile-container .pp-image-section .img-box.gender-male, +.profile-container .pp-image-section .img-box.gender-female, +.img-box.gender-male, +.img-box.gender-female { + transition: border-color 0.3s ease, box-shadow 0.3s ease !important; +} diff --git a/odex25_base/system_dashboard_classic/static/src/scss/rtl-cards.scss b/odex25_base/system_dashboard_classic/static/src/scss/rtl-cards.scss index 3c345471e..d2390d5df 100644 --- a/odex25_base/system_dashboard_classic/static/src/scss/rtl-cards.scss +++ b/odex25_base/system_dashboard_classic/static/src/scss/rtl-cards.scss @@ -1,77 +1,164 @@ -$color_1: linear-gradient(270deg, rgb(14, 62, 52) 0%, rgb(0, 136, 126) 75%); +/* ============================================================ + System Dashboard Classic - RTL Styles + ============================================================ + + This file contains RTL (Right-to-Left) overrides for Arabic/Hebrew + language support. Uses .o_rtl class added by Odoo. + + IMPORTANT: This file must load AFTER all LTR styles. + ============================================================ */ .o_rtl { - .card { - .card-body { - .box-2 { - .btn-group { - position: absolute; - bottom: -50px; - left: 10px; - right: auto; - transition: all .4s; + /* === Profile Section RTL === */ + .profile-container { + .pp-info-section { + border: none; + border-right: 1px solid #9f9f9f; + padding: 10px; + } + + /* Green status dot - flip to left side */ + .pp-image-section::after { + right: auto !important; + left: 8px !important; + } + } + + /* === Module Box / Statistics RTL === */ + .module-box { + .module-box-container { + .module-body { + padding: 10px 15px 10px 10px; + + a { + background-color: transparent; + + &:hover { + text-decoration: none; + } + + h3 { + margin: 0; + transition: all .3s ease-in-out; + color: #8a8a8a; + font-size: 16px; + text-align: center; + padding-top: 5px; + } } + + h2 { + text-align: center; + font-size: 26px; + color: #06211a; + margin-top: 5px; + } + + .module-icon { + text-align: center; + height: 80px; + width: 80px; + line-height: 80px; + border-radius: 50%; + position: absolute; + top: -40px; + right: -20px; + box-shadow: 0 3px 5px -1px rgba(0, 0, 0, .2), + 0 6px 10px 0 rgba(0, 0, 0, .14), + 0 1px 18px 0 rgba(0, 0, 0, .12) !important; + + i { + font-size: 45px; + color: #ffffff; + } + } + + .module-icon.red { background: #ee451f; } + .module-icon.green { background: #14bf1f; } + .module-icon.yellow { background: #f9d700; } + } + + /* Stat labels - reverse flex direction */ + p span { + flex-direction: row-reverse !important; } } } - .card3 { - padding: 0 5px; - .card-body { - .box-1 { - h4 { - margin-bottom: 0; - font-size: 16px; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - color: $color_1; - padding-top: 10px !important; - padding-bottom: 0 !important; - } + + /* === Card2 (Approval Cards) RTL === */ + .card2 { + padding-left: inherit; + padding-right: 0; + + .card-container .card-header { + flex-direction: row-reverse !important; + + img { + margin-right: 0 !important; + margin-left: 12px !important; } - .box-2 { - &:hover { - .btn-group { - bottom: 10px; - } - } - i { + + h4 { + text-align: right !important; + } + } + + .card-container .card-body table tr td { + text-align: right; + + &:last-child { + text-align: left; + + div { float: left; } - span { - float: right; - } } } } - .card2 { - .card-container { - .card-header { - img { - margin-right: unset; - margin-left: 10px; - } - } - .card-body { - table { - tr { - td { - &:last-child { - div { - float: left; - } - } - } - } - } - } + + /* === Card3 (Service Cards) RTL === */ + .card3 .card-body .box-2 { + flex-direction: row-reverse !important; + + i { + margin-left: 0 !important; + margin-right: 8px !important; } - padding-left: inherit; } - .profile-container { - padding-right: 0; + + /* === Tab Navigation RTL === */ + .dashboard-nav-buttons { + flex-direction: row-reverse !important; + + .nav-tabs { + flex-direction: row-reverse !important; + } } - .p0 { - padding: 0; + + /* === Search Container RTL === */ + .genius-search-container { + margin-left: 0 !important; + margin-right: auto !important; + } + + .genius-search-icon { + left: auto !important; + right: 14px !important; + } + + .genius-search-input { + padding: 10px 38px 10px 40px !important; + text-align: right !important; + } + + .genius-search-clear { + right: auto !important; + left: 14px !important; + } + + /* === Attendance Section RTL === */ + .dashboard-attendance-section { + border-left: none !important; + border-right: 1px solid rgba(255, 255, 255, 0.1) !important; } } diff --git a/odex25_base/system_dashboard_classic/static/src/scss/variables.scss b/odex25_base/system_dashboard_classic/static/src/scss/variables.scss index 557cf43c0..50b3c93a3 100644 --- a/odex25_base/system_dashboard_classic/static/src/scss/variables.scss +++ b/odex25_base/system_dashboard_classic/static/src/scss/variables.scss @@ -1,17 +1,59 @@ -$bg_user_section: linear-gradient(270deg, rgb(14, 62, 52) 0%, rgb(0, 136, 126) 75%) !default; -$bg_user_statistics: #FFFFFF !default; -$bg_checkin_btn: #2ead97 !default; -$bg_checkin_btn_hover: #084e41 !default; -$bg_checkout_btn: #2ead97 !default; -$bg_checkout_btn_hover: #084e41 !default; -$divd_border_color: #2eac96 !default; -$bg_dashboard_nav: #2ead97 !default; -$bg_dashboard_nav_hover: #084e41 !default; -$color_nav: #0B2E59 !default; -$bg_card: #f4fefe !default; -$bg_card_button: linear-gradient(270deg, #0e3e34 0%, #00887e 75%) !default; -$bg_card_header: linear-gradient(270deg, #0e3e34 0%, #00887e 75%) !default; +/* ============================================================ + System Dashboard Classic - Theme Variables + ============================================================ + + MINIMAL COLOR SCHEME: + - Primary: Teal (#0d9488) + - Secondary: Dark Blue (#1e293b) + + To customize, override these CSS variables in your theme: + :root { + --theme-primary: #YOUR_COLOR; + --theme-secondary: #YOUR_COLOR; + } + ============================================================ */ +/* === THEME CONFIGURATION (Edit these to change the entire look) === */ +:root { + /* Primary Colors - Main brand color */ + --theme-primary: #0d9488; + --theme-primary-light: #14b8a6; + --theme-primary-dark: #0f766e; + + /* Secondary Colors - For headers and dark elements */ + --theme-secondary: #1e293b; + --theme-secondary-light: #334155; + + /* Neutral Colors */ + --theme-bg-light: #f8fafc; + --theme-bg-white: #ffffff; + --theme-border: #e2e8f0; + --theme-text: #475569; + --theme-text-light: #94a3b8; + + /* Shadows */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1); + + /* Transitions */ + --transition: 0.25s ease; +} + +/* === SCSS Variables (Backward Compatibility) === */ +$bg_user_section: linear-gradient(135deg, #1e293b 0%, #334155 100%) !default; +$bg_user_statistics: #FFFFFF !default; +$bg_checkin_btn: #0d9488 !default; +$bg_checkin_btn_hover: #0f766e !default; +$bg_checkout_btn: #0d9488 !default; +$bg_checkout_btn_hover: #0f766e !default; +$divd_border_color: #0d9488 !default; +$bg_dashboard_nav: #0d9488 !default; +$bg_dashboard_nav_hover: #0f766e !default; +$color_nav: #1e293b !default; +$bg_card: #f8fafc !default; +$bg_card_button: linear-gradient(135deg, #1e293b 0%, #334155 100%) !default; +$bg_card_header: linear-gradient(135deg, #1e293b 0%, #334155 100%) !default; @if variable-exists(sidebar_bg){ $bg_user_section: $sidebar_bg; diff --git a/odex25_base/system_dashboard_classic/static/src/xml/self_service_dashboard.xml b/odex25_base/system_dashboard_classic/static/src/xml/self_service_dashboard.xml index 2f78135ed..270589f2e 100644 --- a/odex25_base/system_dashboard_classic/static/src/xml/self_service_dashboard.xml +++ b/odex25_base/system_dashboard_classic/static/src/xml/self_service_dashboard.xml @@ -23,7 +23,7 @@
-
+ -
+ -
+ + +
@@ -81,12 +119,10 @@
-

Attendance

+

Attendance

- +

@@ -96,6 +132,9 @@ @@ -104,6 +143,13 @@
+ +
+
+
+
+
+
diff --git a/odex25_base/system_dashboard_classic/static/src/xml/system_dashboard.xml b/odex25_base/system_dashboard_classic/static/src/xml/system_dashboard.xml index a4083dc5b..ce5b4d70a 100644 --- a/odex25_base/system_dashboard_classic/static/src/xml/system_dashboard.xml +++ b/odex25_base/system_dashboard_classic/static/src/xml/system_dashboard.xml @@ -74,6 +74,24 @@

Weekly Timesheet

+ +
+
+

+ + 0 + +

+

+ + 0 + +

+
+

+

Monthly Attendance

+
+
@@ -84,9 +102,7 @@

Attendance

- +

diff --git a/odex25_base/system_dashboard_classic/views/config.xml b/odex25_base/system_dashboard_classic/views/config.xml index f1ba10e3a..48e4ca276 100644 --- a/odex25_base/system_dashboard_classic/views/config.xml +++ b/odex25_base/system_dashboard_classic/views/config.xml @@ -1,84 +1,198 @@ + - Base Dashboard + Dashboard Builder base.dashbord -
+ + + +
+
+ + + + + + + +
+

+ +

+ +
+ + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - -