diff --git a/one_compliance/custom/custom_field/timesheet_detail.py b/one_compliance/custom/custom_field/timesheet_detail.py index eafc0438..13f87f7e 100644 --- a/one_compliance/custom/custom_field/timesheet_detail.py +++ b/one_compliance/custom/custom_field/timesheet_detail.py @@ -31,5 +31,12 @@ def get_timesheet_detail_custom_fields(): "default": "Pending", "insert_after": "lag_notification_sent", }, + { + "fieldname": "event", + "fieldtype": "Link", + "label": "Event", + "options": "Event", + "insert_after": "approval_status", + }, ] } diff --git a/one_compliance/one_compliance/doctype/active_task_timer/active_task_timer.json b/one_compliance/one_compliance/doctype/active_task_timer/active_task_timer.json index 472592a3..7bc6b7f9 100644 --- a/one_compliance/one_compliance/doctype/active_task_timer/active_task_timer.json +++ b/one_compliance/one_compliance/doctype/active_task_timer/active_task_timer.json @@ -1,7 +1,7 @@ { "actions": [], "allow_rename": 1, - "autoname": "full_name:task", + "autoname": "prompt", "creation": "2026-04-18 11:40:00", "doctype": "DocType", "editable_grid": 1, @@ -10,6 +10,7 @@ "user", "full_name", "task", + "is_ad_hoc_event", "project", "subject", "start_time" @@ -29,6 +30,12 @@ "label": "Task", "options": "Task" }, + { + "default": "0", + "fieldname": "is_ad_hoc_event", + "fieldtype": "Check", + "label": "Is Ad-hoc Event" + }, { "fieldname": "project", "fieldtype": "Data", diff --git a/one_compliance/one_compliance/doctype/active_task_timer/active_task_timer.py b/one_compliance/one_compliance/doctype/active_task_timer/active_task_timer.py index a43725c0..2718c7d3 100644 --- a/one_compliance/one_compliance/doctype/active_task_timer/active_task_timer.py +++ b/one_compliance/one_compliance/doctype/active_task_timer/active_task_timer.py @@ -1,5 +1,11 @@ +# Copyright (c) 2026, efeone and contributors +# For license information, please see license.txt + import frappe from frappe.model.document import Document class ActiveTaskTimer(Document): - pass + def autoname(self): + full_name = self.full_name or frappe.db.get_value("User", self.user, "full_name") or self.user + task_label = self.task if self.task else ("Ad-hoc Event" if self.is_ad_hoc_event else "No Task") + self.name = f"{full_name}: {task_label}" diff --git a/one_compliance/one_compliance/page/project_management_tool/project_management_tool.js b/one_compliance/one_compliance/page/project_management_tool/project_management_tool.js index e6367316..aaafdac1 100644 --- a/one_compliance/one_compliance/page/project_management_tool/project_management_tool.js +++ b/one_compliance/one_compliance/page/project_management_tool/project_management_tool.js @@ -10,6 +10,25 @@ frappe.pages['project-management_tool'].on_page_load = function (wrapper) { page.main.addClass("frappe-card"); + page.add_event_btn = page.add_inner_button(__('Add Event'), function () { + window.start_active_event_timer(); + }); + + $(document).on('one-compliance-refresh-tools', function () { + refresh_projects(page); + }); + + $(document).on('one-compliance-timer-changed', function (e, timers) { + const has_event_timer = (timers || []).some(t => t.is_ad_hoc_event); + if (page.add_event_btn) { + if (has_event_timer) { + page.add_event_btn.hide(); + } else { + page.add_event_btn.show(); + } + } + }); + make_filters(page); // Initialize pagination page.current_page = 1; @@ -137,28 +156,31 @@ function refresh_projects(page, page_num = null) { page_length: page.page_length }, callback: (r) => { - if (r.message && r.message.length > 0) { - $(frappe.render_template("project_management_tool", { project_list: r.message })).appendTo(page.body); - - // Action to redirect to the task management tool - page.body.find(".showTask").on("click", function () { - var project_id = $(this).attr("project"); - - // Set route options before navigation - frappe.route_options = { - project: project_id - }; - // Navigate to the task management tool page - frappe.set_route('task-management-tool'); - }); - - // Attach pagination controls and page-length button logic - render_pagination_controls(page, r.message.length); - setup_page_length_buttons(page); - } else { - // If no projects are found, append a message to the page body - $('
').appendTo(page.body) - .append('

No Project found with matching filters.

'); + if (r.message) { + if (r.message.projects && r.message.projects.length > 0) { + $(frappe.render_template("project_management_tool", { project_list: r.message.projects })).appendTo(page.body); + + // Action to redirect to the task management tool + page.body.find(".showTask").on("click", function () { + var project_id = $(this).attr("project"); + + // Set route options before navigation + frappe.route_options = { + project: project_id + }; + // Navigate to the task management tool page + frappe.set_route('task-management-tool'); + }); + + // Attach pagination controls and page-length button logic + render_pagination_controls(page, r.message.projects.length); + setup_page_length_buttons(page); + } else { + // If no projects are found, append a message to the page body + $('
').appendTo(page.body) + .append('

No Project found with matching filters.

'); + } + $(document).trigger('one-compliance-timer-changed', [r.message.active_timers]); } }, freeze: true, diff --git a/one_compliance/one_compliance/page/project_management_tool/project_management_tool.py b/one_compliance/one_compliance/page/project_management_tool/project_management_tool.py index 4a151e42..b3322f76 100644 --- a/one_compliance/one_compliance/page/project_management_tool/project_management_tool.py +++ b/one_compliance/one_compliance/page/project_management_tool/project_management_tool.py @@ -60,6 +60,7 @@ def get_project( query += f" ORDER BY p.modified DESC LIMIT {int(page_length)} OFFSET {int(offset)};" project_list = frappe.db.sql(query, as_dict=1) + # Process employee assignment details for project in project_list: project['employee_names'] = [] @@ -82,4 +83,9 @@ def get_project( project['_assign'] = [] project['employee_names'] = [] - return project_list + from one_compliance.one_compliance.page.task_management_tool.task_management_tool import get_active_timer + + return { + "projects": project_list, + "active_timers": get_active_timer() + } diff --git a/one_compliance/one_compliance/page/task_management_tool/task_management_tool.html b/one_compliance/one_compliance/page/task_management_tool/task_management_tool.html index b144162b..b6f0c304 100644 --- a/one_compliance/one_compliance/page/task_management_tool/task_management_tool.html +++ b/one_compliance/one_compliance/page/task_management_tool/task_management_tool.html @@ -18,10 +18,14 @@
diff --git a/one_compliance/one_compliance/page/task_management_tool/task_management_tool.js b/one_compliance/one_compliance/page/task_management_tool/task_management_tool.js index b8f293a6..24f1ee4f 100644 --- a/one_compliance/one_compliance/page/task_management_tool/task_management_tool.js +++ b/one_compliance/one_compliance/page/task_management_tool/task_management_tool.js @@ -10,6 +10,25 @@ frappe.pages['task-management-tool'].on_page_load = function (wrapper) { page.current_page = 1; page.page_length = 20; + page.add_event_btn = page.add_inner_button(__('Add Event'), function () { + window.start_active_event_timer(); + }); + + $(document).on('one-compliance-refresh-tools', function () { + refresh_tasks(page); + }); + + $(document).on('one-compliance-timer-changed', function (e, timers) { + const has_event_timer = (timers || []).some(t => t.is_ad_hoc_event); + if (page.add_event_btn) { + if (has_event_timer) { + page.add_event_btn.hide(); + } else { + page.add_event_btn.show(); + } + } + }); + make_filters(page); if (!frappe.route_options || !frappe.route_options.project) { refresh_tasks(page, true); @@ -159,13 +178,30 @@ function refresh_tasks(page, reset_page = false) { page_length: page.page_length }, callback: (r) => { - if (r.message && r.message.tasks.length > 0) { - render_task_list(page, r.message.tasks, r.message.icons); - setup_pagination(page, r.message.total_tasks); - setup_page_length_buttons(page); - initialize_task_actions(page); - } else { - show_no_task_found(page); + if (r.message) { + let tasks = r.message.tasks || []; + const active_timers = r.message.active_timers || []; + + const event_timer = active_timers.find(t => t.is_ad_hoc_event); + if (event_timer) { + tasks.unshift({ + name: 'EVENT-' + frappe.session.user, + subject: event_timer.subject || 'Ad-hoc Event', + status: 'Working', + is_event: true, + start_time: event_timer.start_time + }); + } + + if (tasks.length > 0) { + render_task_list(page, tasks, r.message.icons); + setup_pagination(page, r.message.total_tasks); + setup_page_length_buttons(page); + initialize_task_actions(page, active_timers); + } else { + show_no_task_found(page); + } + $(document).trigger('one-compliance-timer-changed', [active_timers]); } }, freeze: true, @@ -227,7 +263,7 @@ function render_task_list(page, tasks, icons) { Handles all task-related button bindings and UI updates */ -function initialize_task_actions(page) { +function initialize_task_actions(page, active_timers = null) { const body = page.body; body.find(".paymentEntryButton").off().on("click", function () { @@ -247,6 +283,12 @@ function initialize_task_actions(page) { body.find(".timeEntryButton").hide(); + body.find(".eventDialogButton").off().on("click", function () { + const start_time = $(this).attr("start-time"); + const subject = $(this).attr("task-subject"); + window.show_add_event_dialog(start_time, subject); + }); + body.find(".startButton").off().on("click", function () { const task_name = $(this).attr("task-id"); const project_name = $(this).attr("project-id"); @@ -284,29 +326,47 @@ function initialize_task_actions(page) { }); }); - frappe.call({ - method: "one_compliance.one_compliance.page.task_management_tool.task_management_tool.get_active_timer", - callback: (r) => { - const active_timers = r.message || []; - body.find(".start-time").each(function () { - const task_name = $(this).attr("task-id"); - const project_name = $(this).attr("project-id"); - - const task_timer = active_timers.find(t => t.task === task_name); + const apply_active_timers = (timers) => { + const active_timers_list = timers || []; + body.find(".start-time").each(function () { + const task_name = $(this).attr("task-id"); + const project_name = $(this).attr("project-id"); + const is_event = task_name && task_name.startsWith('EVENT-'); - if (task_timer) { - const formatted_time = frappe.datetime.str_to_user(task_timer.start_time); - $(this).text(formatted_time); - body.find(`.startButton[task-id='${task_name}'][project-id='${project_name}']`).hide(); - body.find(`.timeEntryButton[task-id='${task_name}'][project-id='${project_name}']`).show(); + const task_timer = is_event ? active_timers_list.find(t => t.is_ad_hoc_event) : active_timers_list.find(t => t.task === task_name); + + if (task_timer) { + const formatted_time = frappe.datetime.str_to_user(task_timer.start_time); + $(this).text(formatted_time); + body.find(`.startButton[task-id='${task_name}'][project-id='${project_name}']`).hide(); + + if (is_event) { + body.find(`.eventDialogButton[task-id='${task_name}']`).show(); + body.find(`.timeEntryButton[task-id='${task_name}']`).hide(); } else { - $(this).text(""); + body.find(`.timeEntryButton[task-id='${task_name}'][project-id='${project_name}']`).show(); + } + } else { + $(this).text(""); + if (!is_event) { body.find(`.startButton[task-id='${task_name}'][project-id='${project_name}']`).show(); - body.find(`.timeEntryButton[task-id='${task_name}'][project-id='${project_name}']`).hide(); } - }); - } - }); + body.find(`.timeEntryButton[task-id='${task_name}'][project-id='${project_name}']`).hide(); + body.find(`.eventDialogButton[task-id='${task_name}']`).hide(); + } + }); + }; + + if (active_timers) { + apply_active_timers(active_timers); + } else { + frappe.call({ + method: "one_compliance.one_compliance.page.task_management_tool.task_management_tool.get_active_timer", + callback: (r) => { + apply_active_timers(r.message); + } + }); + } body.find(".timeEntryButton").off().on("click", function () { const task_name = $(this).attr("task-id"); @@ -751,7 +811,10 @@ function set_status_colors(page) { status_el.css("color", color); project_el.css("color", color); - if (["Open", "Overdue", "Working", "Pending Review", "Hold", "Pending with Authority"].includes(status)) add_check_icon(status_el[0]); + const task_id = status_el.attr("task-id"); + const is_event = task_id && task_id.startsWith('EVENT-'); + + if (!is_event && ["Open", "Overdue", "Working", "Pending Review", "Hold", "Pending with Authority"].includes(status)) add_check_icon(status_el[0]); }); function add_check_icon(element) { diff --git a/one_compliance/one_compliance/page/task_management_tool/task_management_tool.py b/one_compliance/one_compliance/page/task_management_tool/task_management_tool.py index 2d051767..e96880fb 100644 --- a/one_compliance/one_compliance/page/task_management_tool/task_management_tool.py +++ b/one_compliance/one_compliance/page/task_management_tool/task_management_tool.py @@ -126,7 +126,8 @@ def get_task(status=None, task=None, project=None, customer=None, department=Non return { "tasks": task_list, "total_tasks": total_tasks, - "icons": get_icon_hidden_status() + "icons": get_icon_hidden_status(), + "active_timers": get_active_timer() } @frappe.whitelist() @@ -282,17 +283,21 @@ def get_icon_hidden_status(): return data @frappe.whitelist() -def start_active_timer(task, project, subject, start_time): +def start_active_timer(task, project, subject, start_time, is_ad_hoc_event=0): """ Start a timer for a specific task, ensuring no overlapping timers for the same user. """ user = frappe.session.user + is_ad_hoc_event = int(is_ad_hoc_event) + if not user or user == 'Guest': frappe.throw(_("User authentication required. Please login first.")) - if not frappe.db.exists("Task", task): - frappe.throw(_("Task {0} not found").format(task)) - if not frappe.has_permission("Task", "read", task): - frappe.throw(_("No permission to access this task")) + + if not is_ad_hoc_event: + if not task or not frappe.db.exists("Task", task): + frappe.throw(_("Task {0} not found").format(task)) + if not frappe.has_permission("Task", "read", task): + frappe.throw(_("No permission to access this task")) if project and not frappe.db.exists("Project", project): frappe.throw(_("Project {0} not found").format(project)) try: @@ -302,9 +307,8 @@ def start_active_timer(task, project, subject, start_time): except Exception: frappe.throw(_("Invalid start_time format")) - val1 = frappe.db.get_value("Projects Settings", "Projects Settings", "ignore_employee_time_overlap") - val2 = frappe.db.get_value("Projects Settings", "Projects Settings", "ignore_user_time_overlap") - ignore_overlap = (int(val1 or 0) == 1) or (int(val2 or 0) == 1) + settings = frappe.db.get_value("Projects Settings", "Projects Settings", ["ignore_employee_time_overlap", "ignore_user_time_overlap"], as_dict=True) or {} + ignore_overlap = (int(settings.get("ignore_employee_time_overlap") or 0) == 1) or (int(settings.get("ignore_user_time_overlap") or 0) == 1) if not ignore_overlap: existing_timer = frappe.db.sql(""" @@ -315,16 +319,24 @@ def start_active_timer(task, project, subject, start_time): existing_timer = existing_timer[0] frappe.throw(_("Another task is already running: {0}. Please stop it before starting a new one.").format(existing_timer.subject or existing_timer.task)) - timer_name = frappe.db.get_value("Active Task Timer", {"user": user, "task": task}) + filters = {"user": user} + if is_ad_hoc_event: + filters["is_ad_hoc_event"] = 1 + else: + filters["task"] = task + + timer_name = frappe.db.get_value("Active Task Timer", filters) if timer_name: doc = frappe.get_doc("Active Task Timer", timer_name) else: doc = frappe.new_doc("Active Task Timer") doc.user = user - doc.task = task + if not is_ad_hoc_event: + doc.task = task doc.flags.ignore_permissions = True + doc.is_ad_hoc_event = is_ad_hoc_event doc.project = project doc.subject = subject @@ -338,13 +350,17 @@ def start_active_timer(task, project, subject, start_time): return all_timers @frappe.whitelist() -def stop_active_timer(task=None): +def stop_active_timer(task=None, is_ad_hoc_event=0): """ - Stop the active timer for the current user, optionally filtering by task. + Stop the active timer for the current user, optionally filtering by task or ad-hoc status. """ user = frappe.session.user + is_ad_hoc_event = int(is_ad_hoc_event) filters = {"user": user} - if task: + + if is_ad_hoc_event: + filters["is_ad_hoc_event"] = 1 + elif task: filters["task"] = task timer_names = frappe.get_all("Active Task Timer", filters=filters, pluck="name", ignore_permissions=True) @@ -367,7 +383,63 @@ def get_active_timer(): return [] timers = frappe.db.sql(""" - SELECT task, project, subject, start_time FROM `tabActive Task Timer` WHERE user = %s + SELECT task, project, subject, start_time, is_ad_hoc_event FROM `tabActive Task Timer` WHERE user = %s """, (user,), as_dict=True) - return timers \ No newline at end of file + return timers + +@frappe.whitelist() +def check_active_timer(): + """ + Check if an active timer exists for the current user, respecting overlap settings. + """ + user = frappe.session.user + settings = frappe.db.get_value("Projects Settings", "Projects Settings", ["ignore_employee_time_overlap", "ignore_user_time_overlap"], as_dict=True) or {} + ignore_overlap = (int(settings.get("ignore_employee_time_overlap") or 0) == 1) or (int(settings.get("ignore_user_time_overlap") or 0) == 1) + + if not ignore_overlap: + existing_timer = frappe.db.sql(""" + SELECT task, subject FROM `tabActive Task Timer` WHERE user = %s + """, (user,), as_dict=True) + if existing_timer: + existing_timer = existing_timer[0] + return _("Another task is already running: {0}. Please stop it before starting a new one.").format(existing_timer.subject or existing_timer.task) + return None + +@frappe.whitelist() +def create_event_from_tool(subject, event_category, start_time, company, ends_on, description=None, customer=None): + """ + Create an Event record from the management tool and finalize the associated timer. + """ + user = frappe.session.user + employee = frappe.db.get_value("Employee", {"user_id": user}, ["name", "employee_name"], as_dict=True) + + event = frappe.new_doc("Event") + event.subject = subject + event.event_category = event_category + event.description = description + event.starts_on = start_time + event.ends_on = ends_on + event.company = company + event.custom_customer = customer + event.event_type = "Private" + event.status = "Completed" + event.send_reminder = 0 + + if employee: + event.append("event_participants", { + "reference_doctype": "Employee", + "reference_docname": employee.name, + "custom_participant_name": employee.employee_name + }) + + event.insert() + + # Create timesheet entry via utility + from one_compliance.one_compliance.utils import make_time_sheet_entry + make_time_sheet_entry(event.name) + + # Stop the ad-hoc event timer + stop_active_timer(is_ad_hoc_event=1) + + return event.name \ No newline at end of file diff --git a/one_compliance/one_compliance/utils.py b/one_compliance/one_compliance/utils.py index 162b830f..a1098119 100644 --- a/one_compliance/one_compliance/utils.py +++ b/one_compliance/one_compliance/utils.py @@ -365,29 +365,48 @@ def make_time_sheet_entry(event): if participant.reference_doctype == "Employee": employee_id = participant.reference_docname if employee_id: - create_timesheet(employee_id, activity_type, from_time, to_time) + create_timesheet(employee_id, activity_type, from_time, to_time, event) @frappe.whitelist() -def create_timesheet(employee, activity_type, from_time, to_time): +def create_timesheet(employee, activity_type, from_time, to_time, event=None): from_time = get_datetime(from_time) to_time = get_datetime(to_time) employee_id = frappe.get_value("Employee", {"name": employee}, "name") - # Check if a timesheet already exists for the employee within the given date range - if frappe.db.exists("Timesheet", {"employee": employee_id, "start_date": from_time.date(), "end_date": to_time.date()}): - frappe.throw(_("Timesheet already Created")) + # Check if a draft timesheet already exists for the employee for today + existing_ts = frappe.db.get_value("Timesheet", { + "employee": employee_id, + "start_date": from_time.date(), + "docstatus": 0 + }, "name") + + if existing_ts: + timesheet = frappe.get_doc("Timesheet", existing_ts) + # Check if event already logged + if event and any(log.event == event for log in timesheet.time_logs): + return + + timesheet.append("time_logs", { + "activity_type": activity_type, + "from_time": from_time, + "to_time": to_time, + "event": event + }) + timesheet.save(ignore_permissions=True) else: timesheet = frappe.new_doc("Timesheet") timesheet.employee = employee_id timesheet.append("time_logs",{ "activity_type": activity_type, "from_time": from_time, - "to_time": to_time + "to_time": to_time, + "event": event }) timesheet.insert(ignore_permissions=True) - frappe.db.commit() + + frappe.db.commit() @frappe.whitelist() def get_employee_list_for_hod(): diff --git a/one_compliance/public/js/one_compliance.js b/one_compliance/public/js/one_compliance.js index 0031cd5e..cd1ec9f5 100644 --- a/one_compliance/public/js/one_compliance.js +++ b/one_compliance/public/js/one_compliance.js @@ -1,12 +1,12 @@ (function () { - 'use strict'; + 'use strict'; - const PREFIX = 'one-compliance-active-timer-'; + const PREFIX = 'one-compliance-active-timer-'; - // ========================= - // CSS Injection - // ========================= - function injectStyles() { + // ========================= + // CSS Injection + // ========================= + function injectStyles() { const Z_INDEX = 1040; const style = document.createElement('style'); @@ -79,225 +79,366 @@ document.documentElement.appendChild(style); } - // ========================= - // Utilities - // ========================= - - /** - * Escape HTML to prevent XSS - * @param {string} str - * @returns {string} - */ - function esc(str) { - if (!str) return ''; - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - } - - /** - * Get active timer data from localStorage (fallback/cache) - * @returns {Array} - */ - function getActiveData() { - const match = document.cookie.match(/(?:^|; )user_id=([^;]*)/); - const user = match ? decodeURIComponent(match[1]) : null; - - if (user) { - const data = localStorage.getItem(PREFIX + user); - if (data) { - try { - const parsed = JSON.parse(data); - return Array.isArray(parsed) ? parsed : [parsed]; - } catch (e) { - console.warn('Invalid timer data in localStorage', e); - } - } - } - - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key && key.startsWith(PREFIX)) { - try { - const data = JSON.parse(localStorage.getItem(key)); - return Array.isArray(data) ? data : [data]; - } catch (e) { - console.warn('Error parsing fallback timer data', e); - } - } - } - - return []; - } - - /** - * Generate HTML for timer display - * @param {Array} timers - * @returns {string} - */ - function getHTML(timers) { - if (!timers || timers.length === 0) return ''; - - const icon = - ''; - - let content = ''; - - if (timers.length <= 3) { - const lines = timers.map((d) => { - const project = d.project ? esc(d.project) + ': ' : ''; - const time = esc(d.formatted_start_time || d.start_time); - - return ( - '
' + - 'Timer is ON: ' + - project + - esc(d.subject) + - ' (' + - time + - ')' + - '
' - ); - }); - - content = - '
' + - lines.join('') + - '
'; - } else { - content = - 'Timer is ON: ' + - timers.length + - ' Tasks running'; - } - - return icon + '
' + content + '
'; - } - - // ========================= - // Render - // ========================= - - /** - * Render floating timer UI - * @returns {boolean} - */ - function render() { - if (document.getElementById('oc-timer-wrap')) return true; - if (!document.body) return false; - - const wrap = document.createElement('div'); - wrap.id = 'oc-timer-wrap'; - - const link = document.createElement('a'); - link.id = 'oc-timer-box'; - link.href = '/app/task-management-tool'; - - const timers = getActiveData(); - - if (timers.length > 0) { - link.innerHTML = getHTML(timers); - } else { - link.style.display = 'none'; - } - - wrap.appendChild(link); - document.body.appendChild(wrap); - - return true; - } - - /** - * Boot renderer safely - */ - function boot() { - if (!render()) { - setTimeout(boot, 10); - } - } - - // ========================= - // Sync with Frappe Backend - // ========================= - - /** - * Start real-time sync with backend - */ - function startSync() { - if (!window.frappe || !frappe.call || !window.jQuery) { - setTimeout(startSync, 100); - return; - } - - /** - * Update UI with timer data - * @param {Array|Object|null} data - */ - function update(data) { - const el = document.getElementById('oc-timer-box'); - if (!el) return; - - const timers = Array.isArray(data) - ? data - : data - ? [data] - : []; - - const user = - (frappe.session && frappe.session.user) || - (frappe.boot && frappe.boot.user && frappe.boot.user.name); - - if (timers.length > 0) { - if (frappe.datetime) { - timers.forEach((t) => { - if (t.start_time) { - t.formatted_start_time = - frappe.datetime.str_to_user(t.start_time); - } - }); - } - - el.innerHTML = getHTML(timers); - el.style.display = 'flex'; - - if (user) { - localStorage.setItem(PREFIX + user, JSON.stringify(timers)); - } - } else { - el.style.display = 'none'; - - if (user) { - localStorage.removeItem(PREFIX + user); - } - } - } - - // Custom event - $(document).on('one-compliance-timer-changed', function (e, data) { - update(data); - }); - - // Realtime event - if (frappe.realtime) { - frappe.realtime.on('one_compliance_timer_update', update); - } - - // Initial fetch - frappe.call({ - method: - 'one_compliance.one_compliance.page.task_management_tool.task_management_tool.get_active_timer', - callback: function (r) { - update(r.message); - }, - }); - } - - // ========================= - // Init - // ========================= - injectStyles(); - boot(); - startSync(); -})(); \ No newline at end of file + // ========================= + // Utilities + // ========================= + + /** + * Escape HTML to prevent XSS + * @param {string} str + * @returns {string} + */ + function esc(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + /** + * Get active timer data from localStorage (fallback/cache) + * @returns {Array} + */ + function getActiveData() { + const match = document.cookie.match(/(?:^|; )user_id=([^;]*)/); + const user = match ? decodeURIComponent(match[1]) : null; + + if (user) { + const data = localStorage.getItem(PREFIX + user); + if (data) { + try { + const parsed = JSON.parse(data); + return Array.isArray(parsed) ? parsed : [parsed]; + } catch (e) { + console.warn('Invalid timer data in localStorage', e); + } + } + } + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(PREFIX)) { + try { + const data = JSON.parse(localStorage.getItem(key)); + return Array.isArray(data) ? data : [data]; + } catch (e) { + console.warn('Error parsing fallback timer data', e); + } + } + } + + return []; + } + + /** + * Generate HTML for timer display + * @param {Array} timers + * @returns {string} + */ + function getHTML(timers) { + if (!timers || timers.length === 0) return ''; + + const icon = + ''; + + let content = ''; + + if (timers.length <= 3) { + const lines = timers.map((d) => { + const project = d.project ? esc(d.project) + ': ' : ''; + const time = esc(d.formatted_start_time || d.start_time); + + return ( + '
' + + 'Timer is ON: ' + + project + + esc(d.subject) + + ' (' + + time + + ')' + + '
' + ); + }); + + content = + '
' + + lines.join('') + + '
'; + } else { + content = + 'Timer is ON: ' + + timers.length + + ' Tasks running'; + } + + return icon + '
' + content + '
'; + } + + // ========================= + // Render + // ========================= + + /** + * Render floating timer UI + * @returns {boolean} + */ + function render() { + if (document.getElementById('oc-timer-wrap')) return true; + if (!document.body) return false; + + const wrap = document.createElement('div'); + wrap.id = 'oc-timer-wrap'; + + const link = document.createElement('a'); + link.id = 'oc-timer-box'; + link.href = '/app/task-management-tool'; + + const timers = getActiveData(); + + if (timers.length > 0) { + link.innerHTML = getHTML(timers); + wrap.style.display = 'block'; + } else { + link.style.display = 'none'; + wrap.style.display = 'none'; + } + + wrap.appendChild(link); + document.body.appendChild(wrap); + + return true; + } + + /** + * Boot renderer safely + */ + function boot() { + if (!render()) { + setTimeout(boot, 10); + } + } + + // ========================= + // Sync with Frappe Backend + // ========================= + + /** + * Start real-time sync with backend + */ + function startSync() { + if (!window.frappe || !frappe.call || !window.jQuery) { + setTimeout(startSync, 100); + return; + } + + /** + * Update UI with timer data + * @param {Array|Object|null} data + */ + function update(data) { + const el = document.getElementById('oc-timer-box'); + const wrap = document.getElementById('oc-timer-wrap'); + if (!el) return; + + const timers = Array.isArray(data) + ? data + : data + ? [data] + : []; + + const user = + (frappe.session && frappe.session.user) || + (frappe.boot && frappe.boot.user && frappe.boot.user.name); + + if (timers.length > 0) { + if (frappe.datetime) { + timers.forEach((t) => { + if (t.start_time) { + t.formatted_start_time = + frappe.datetime.str_to_user(t.start_time); + } + }); + } + + el.innerHTML = getHTML(timers); + el.style.display = 'flex'; + if (wrap) wrap.style.display = 'block'; + + if (user) { + localStorage.setItem(PREFIX + user, JSON.stringify(timers)); + } + } else { + el.style.display = 'none'; + if (wrap) wrap.style.display = 'none'; + + if (user) { + localStorage.removeItem(PREFIX + user); + } + } + } + + // Custom event + $(document).on('one-compliance-timer-changed', function (e, data) { + update(data); + }); + + // Realtime event + if (frappe.realtime) { + frappe.realtime.on('one_compliance_timer_update', update); + } + + // Initial fetch + frappe.call({ + method: + 'one_compliance.one_compliance.page.task_management_tool.task_management_tool.get_active_timer', + callback: function (r) { + update(r.message); + }, + }); + } + + // ========================= + // Ad-hoc Events + // ========================= + + /** + * Start a synthetic task timer for an ad-hoc event + */ + window.start_active_event_timer = function () { + frappe.call({ + method: + 'one_compliance.one_compliance.page.task_management_tool.task_management_tool.check_active_timer', + callback: function (r) { + if (r.message) { + frappe.msgprint(r.message); + } else { + const user = frappe.session.user; + const start_time = frappe.datetime.now_datetime(); + + frappe.call({ + method: + 'one_compliance.one_compliance.page.task_management_tool.task_management_tool.start_active_timer', + args: { + task: '', + project: '', + subject: 'Ad-hoc Event', + start_time: start_time, + is_ad_hoc_event: 1, + }, + callback: function (r) { + $(document).trigger('one-compliance-timer-changed', [ + r.message, + ]); + $(document).trigger('one-compliance-refresh-tools'); + }, + }); + } + }, + }); + }; + + /** + * Show dialog to finalize an ad-hoc event + * @param {string} start_time + * @param {string} subject + */ + window.show_add_event_dialog = function (start_time, subject) { + frappe.model.with_doctype('Event', function () { + const meta = frappe.get_meta('Event'); + const catField = meta.fields.find( + (f) => f.fieldname === 'event_category' + ); + const options = catField ? catField.options.split('\n') : []; + + const d = new frappe.ui.Dialog({ + title: __('Add Event'), + fields: [ + { + label: __('Subject'), + fieldname: 'subject', + fieldtype: 'Data', + reqd: 1, + default: subject || 'Ad-hoc Event', + }, + { + label: __('Starts On'), + fieldname: 'start_time', + fieldtype: 'Datetime', + read_only: 1, + default: start_time, + }, + { + label: __('Company'), + fieldname: 'company', + fieldtype: 'Link', + options: 'Company', + reqd: 1, + default: frappe.defaults.get_user_default('company'), + }, + { + label: __('Client'), + fieldname: 'customer', + fieldtype: 'Link', + options: 'Customer', + }, + { + label: __('Event Category'), + fieldname: 'event_category', + fieldtype: 'Select', + options: options, + reqd: 1, + }, + { + label: __('Description'), + fieldname: 'description', + fieldtype: 'Small Text', + }, + { + label: __('Ends On'), + fieldname: 'ends_on', + fieldtype: 'Datetime', + reqd: 1, + default: frappe.datetime.now_datetime(), + }, + ], + primary_action_label: __('Add Timesheet'), + primary_action(values) { + if (values.ends_on <= values.start_time) { + frappe.msgprint(__('Ends On must be after Starts On')); + return; + } + d.disable_primary_action(); + frappe.call({ + method: + 'one_compliance.one_compliance.page.task_management_tool.task_management_tool.create_event_from_tool', + args: values, + callback: function (r) { + if (r.message) { + frappe.show_alert({ + message: __('Event and Timesheet created successfully'), + indicator: 'green', + }); + d.hide(); + $(document).trigger('one-compliance-timer-changed', [[]]); + $(document).trigger('one-compliance-refresh-tools'); + } + }, + always: function () { + d.enable_primary_action(); + }, + }); + }, + }); + d.show(); + }); + }; + + // ========================= + // Init + // ========================= + injectStyles(); + boot(); + startSync(); +})();