From 6afce48eaaa8e9dcbff66f9ba45d68270b395dd0 Mon Sep 17 00:00:00 2001 From: Suraiyya Sutriya Date: Sat, 30 May 2026 16:46:31 +0530 Subject: [PATCH] feat:add scrollable audit workflow tracker with tooltip fix --- .../doctype/my_audits/my_audits.js | 639 +++++++++++------- 1 file changed, 383 insertions(+), 256 deletions(-) diff --git a/audit_management/audit_management/doctype/my_audits/my_audits.js b/audit_management/audit_management/doctype/my_audits/my_audits.js index 923576f..c1aa378 100755 --- a/audit_management/audit_management/doctype/my_audits/my_audits.js +++ b/audit_management/audit_management/doctype/my_audits/my_audits.js @@ -80,14 +80,18 @@ frappe.ui.form.on("My Audits", { } if (frm.doc.creation) { - const created_date = frappe.datetime.str_to_user(frm.doc.creation.split(" ")[0]); - + const created_date = frappe.datetime.str_to_user( + frm.doc.creation.split(" ")[0], + ); + // Calculate dynamic aging: today - creation date - const creation_date_obj = frappe.datetime.str_to_obj(frm.doc.creation); + const creation_date_obj = frappe.datetime.str_to_obj( + frm.doc.creation, + ); const today = new Date(); const time_diff = today - creation_date_obj; const dynamic_aging = Math.floor(time_diff / (1000 * 60 * 60 * 24)); - + date_html += item("Aging", `${dynamic_aging} Days`); } @@ -1142,13 +1146,13 @@ frappe.ui.form.on("My Audits", { frm .add_custom_button(__("Send"), function () { let stages = audit_table - .filter((r) => r.stage_name) + .filter((r) => r.stage_name) .map((r) => { return { name: r.stage_name, employee_name: r.employee_name || "Unassigned", status: r.status, - is_sent: !!r.status + is_sent: !!r.status, }; }); @@ -1178,7 +1182,9 @@ frappe.ui.form.on("My Audits", { - ${stages.map((s) => ` + ${stages + .map( + (s) => ` ${s.is_sent ? s.status : __("Not Sent")} - ${["Pending", "No Response"].includes(s.status) ? ` + ${ + [ + "Pending", + "No Response", + ].includes(s.status) + ? ` × - ` : ""} + ` + : "" + } - `).join("")} + `, + ) + .join("")} @@ -1220,9 +1235,11 @@ frappe.ui.form.on("My Audits", { primary_action_label: __("Submit"), primary_action: function () { let selected_stages = []; - d.$wrapper.find(".stage-checkbox:checked:not(:disabled)").each(function () { - selected_stages.push($(this).val()); - }); + d.$wrapper + .find(".stage-checkbox:checked:not(:disabled)") + .each(function () { + selected_stages.push($(this).val()); + }); if (selected_stages.length === 0) { frappe.msgprint(__("Please select at least one new stage.")); @@ -1255,48 +1272,69 @@ frappe.ui.form.on("My Audits", { d.show(); // Rollback Logic - d.$wrapper.find('.rollback-btn').on('click', function() { - let stagename = $(this).data('stage'); - // Find the row object corresponding to the stage to get its unique name - let row = audit_table.find(r => (r.stage_name || r.stagename) === stagename); - let row_name = row ? row.name : null; - - frappe.confirm(`Are you sure you want to rollback ${stagename} stage?`, () => { - frappe.call({ - method: "audit_management.audit_management.doctype.my_audits.my_audits.rollback_stage", - args: { - docname: frm.doc.name, - stagename: stagename, - row_name: row_name - }, - callback: function(r) { - if (r.message) { - frappe.show_alert({message: __("Stage rolled back successfully"), indicator: "green"}); - d.hide(); - frm.reload_doc(); - } - }, - error: function(err) { - console.error("Rollback Stage Error:", err); - frappe.msgprint(__("An error occurred while rolling back the stage.")); - } - }); - }); + d.$wrapper.find(".rollback-btn").on("click", function () { + let stagename = $(this).data("stage"); + // Find the row object corresponding to the stage to get its unique name + let row = audit_table.find( + (r) => (r.stage_name || r.stagename) === stagename, + ); + let row_name = row ? row.name : null; + + frappe.confirm( + `Are you sure you want to rollback ${stagename} stage?`, + () => { + frappe.call({ + method: + "audit_management.audit_management.doctype.my_audits.my_audits.rollback_stage", + args: { + docname: frm.doc.name, + stagename: stagename, + row_name: row_name, + }, + callback: function (r) { + if (r.message) { + frappe.show_alert({ + message: __("Stage rolled back successfully"), + indicator: "green", + }); + d.hide(); + frm.reload_doc(); + } + }, + error: function (err) { + console.error("Rollback Stage Error:", err); + frappe.msgprint( + __("An error occurred while rolling back the stage."), + ); + }, + }); + }, + ); }); // Select All logic d.$wrapper.find("#select-all-stages").on("change", function () { let checked = $(this).prop("checked"); // Only affect non-disabled checkboxes - d.$wrapper.find(".stage-checkbox:not(:disabled)").prop("checked", checked); + d.$wrapper + .find(".stage-checkbox:not(:disabled)") + .prop("checked", checked); }); // Individual checkbox logic - d.$wrapper.find(".stage-checkbox:not(:disabled)").on("change", function () { - let all_checkables = d.$wrapper.find(".stage-checkbox:not(:disabled)"); - let all_checked = all_checkables.filter(":checked").length === all_checkables.length; - d.$wrapper.find("#select-all-stages").prop("checked", all_checked); - }); + d.$wrapper + .find(".stage-checkbox:not(:disabled)") + .on("change", function () { + let all_checkables = d.$wrapper.find( + ".stage-checkbox:not(:disabled)", + ); + let all_checked = + all_checkables.filter(":checked").length === + all_checkables.length; + d.$wrapper + .find("#select-all-stages") + .prop("checked", all_checked); + }); }) .css({ "background-color": "#007bff", color: "white" }); } @@ -1359,8 +1397,12 @@ frappe.ui.form.on("My Audits", { }, error: function (err) { console.error("Submit Response Error:", err); - frappe.msgprint(__("An error occurred while submitting your response. Please check your network connection or contact IT support.")); - } + frappe.msgprint( + __( + "An error occurred while submitting your response. Please check your network connection or contact IT support.", + ), + ); + }, }); }, }); @@ -1886,7 +1928,7 @@ frappe.ui.form.on("My Audits", { // ) // .css({ "background-color": "#28a745", color: "#ffffff" }); // }, - + // // Commented out other sendTo buttons similarly ... // // I will comment out the rest for you. @@ -2321,7 +2363,6 @@ frappe.ui.form.on("My Audits", { render_interactive_tracker(frm, can_edit); }, }); - function render_interactive_tracker(frm, can_edit) { // 0. Fetch TAT Config if not already loaded if (frm.doc.query_type && !frm.tat_config_loaded) { @@ -2339,251 +2380,342 @@ function render_interactive_tracker(frm, can_edit) { return; } - // 1. Inject the CSS globally into the document head (only once) + // 1. Inject Compact & Spacing Optimized CSS Structure (With Flex-Shrink Fixed) if (!document.getElementById("custom-audit-tracker-style")) { let style = document.createElement("style"); style.id = "custom-audit-tracker-style"; style.innerHTML = ` - /* Modern tracker styling */ .modern-audit-tracker { - font-family: inherit; - padding: 4px 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + padding: 12px 14px; + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 10px; + box-shadow: 0 1px 2px rgba(0,0,0,0.01); + width: 100%; } - /* Base style for the container holding the pill and its arrow */ - .stage-pill-container { - display: inline-flex !important; - align-items: center !important; /* This ensures elements are perfectly aligned on the center line */ - justify-content: center; + .tracker-flow-container { + display: flex; + align-items: flex-start; + gap: 0px; + width: 100%; + overflow-x: auto !important; /* Forces scrollbar container visibility */ + white-space: nowrap; + padding-top: 6px; + padding-bottom: 8px; } + /* FIXED: Added flex-shrink: 0 to enforce independent scrolling without compression */ + .workflow-node { + display: flex; + flex-direction: column; + align-items: center; + width: 100px; + min-width: 100px; + flex-shrink: 0 !important; /* Stays fixed, forces layout to overflow cleanly */ + position: relative; + } + + .node-status-top { + font-size: 10px; + font-weight: 700; + margin-bottom: 5px; + height: 14px; + text-transform: capitalize; + white-space: nowrap; + } + .top-completed { color: #22c55e; } + .top-progress { color: #2563eb; } + .top-no-response { color: #f97316; } + .top-overdue { color: #ef4444; } + .top-pending { color: #64748b; } + .top-blank { color: transparent; } + .modern-pill { - position: relative; /* Required for absolute tooltip positioning */ display: inline-flex; + flex-direction: column; align-items: center; justify-content: center; - padding: 4px 14px; - border-radius: 20px; - font-size: 12px; - font-weight: 600; + width: 100px; + height: 32px; + border-radius: 16px; + font-size: 11px; + font-weight: 700; letter-spacing: 0.3px; - box-shadow: 0 1px 2px rgba(0,0,0,0.05); + box-shadow: 0 1px 3px rgba(0,0,0,0.03); transition: all 0.2s ease; - white-space: nowrap; - height: 26px; /* Explicit uniform height to prevent jumpy layout */ - // z-index: 999; + background: #ffffff; + border: 1px solid #cbd5e1; + color: #334155; + } + .pill-audit-team { + background-color: #eff6ff !important; + border: 1.5px solid #bfdbfe !important; + color: #1d4ed8 !important; } - - /* Explicit Arrow Fix to ensure it centers nicely */ - .modern-arrow { - display: inline-block; - vertical-align: middle; - margin: 0 6px; - align-self: center; /* Forces flex child to center perfectly regardless of context */ + .pill-responded { + background-color: #f0fdf4 !important; + border: 1.5px solid #22c55e !important; + color: #166534 !important; + } + .pill-progress { + background-color: #2563eb !important; + border: 1.5px solid #1e40af !important; + color: #ffffff !important; + box-shadow: 0 3px 8px rgba(37, 99, 235, 0.2); + } + .pill-no-response { + background-color: #ffedd5 !important; + border: 1.5px solid #fed7aa !important; + color: #ea580c !important; + } + .pill-overdue { + background-color: #fef2f2 !important; + border: 1.5px solid #ef4444 !important; + color: #991b1b !important; + box-shadow: 0 3px 8px rgba(239, 68, 68, 0.15); + } + .pill-future { + background-color: #f8fafc !important; + border: 1px solid #e2e8f0 !important; + color: #64748b !important; } - .sortable-item:hover .modern-pill { - transform: translateY(-1px); - box-shadow: 0 4px 6px rgba(0,0,0,0.08); - z-index: 999; - + /* FIXED: Connector line shrinks cleanly, but node boxes don't */ + .node-connector { + flex-grow: 1; + margin-top: 35px; + min-width: 24px; + height: 2px; + border-top: 2px dashed #cbd5e1; + position: relative; + flex-shrink: 1; + } + .connector-solid { + border-top-style: solid !important; + border-top-color: #22c55e !important; + } + .connector-blue-dashed { + border-top-color: #2563eb !important; + } + .connector-red-dashed { + border-top-color: #ef4444 !important; } - /* TAT Label Styling - Absolute Position to not break Flex alignment */ - .tat-label { - position: absolute; - top: -14px; - left: 50%; - transform: translateX(-50%); + .node-meta-bottom { + margin-top: 6px; + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + text-align: center; + min-height: 40px; + } + .meta-tat { font-size: 10px; - color: #777777; font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.3px; - line-height: 1; + color: #64748b; white-space: nowrap; } - - /* Status Colors (Modern Banking Palette) */ - .pill-pending { background-color: #fef2f2; border: 1px solid #fecaca; color: #b91c1c; } - .pill-responded { background-color: #f0fdf4; border: 1px solid #bbf7d0; color: #15803d; } - .pill-skipped { background-color: #faf5ff; border: 1px solid #e9d5ff; color: #6b21a8; } - .pill-no-response { background-color: #fff7ed; border: 1px solid #fdba74; color: #c2410c; } - .pill-default { background-color: #f3f4f6; border: 1px solid #e5e7eb; color: #374151; } - .pill-audit-team { background-color: #eff6ff; border: 1px solid #bfdbfe; color: #1d4ed8; } - - /* Hide the arrow on the very last stage pill dynamically */ - .stage-pill-container:last-child .modern-arrow { - display: none !important; - } - /* Hide any empty message boxes Frappe creates */ - .form-message.blue:empty { - display: none !important; - } - /* Hide default frappe close icon specifically for our tracker via modern CSS */ - .form-message:has(.modern-audit-tracker) .close-message { - display: none !important; + .meta-highlight { + font-size: 10px; + font-weight: 700; + white-space: nowrap; } - - /* --- GORGEOUS CUSTOM CSS TOOLTIP --- */ - .modern-pill[data-tooltip]::after { - content: attr(data-tooltip); - position: absolute; - top: calc(100% + 8px); /* Position below the pill */ - left: 50%; - transform: translateX(-50%) translateY(-4px); /* Animate downwards */ - background: #1e293b; /* Sleek dark slate */ - color: #f8fafc; - padding: 6px 12px; - border-radius: 6px; - font-size: 11px; + .text-blue-highlight { color: #2563eb; } + .text-orange-highlight { color: #ea580c; } + .meta-date { + font-size: 9.5px; + color: #94a3b8; font-weight: 500; - letter-spacing: 0.2px; white-space: nowrap; - opacity: 0; - visibility: hidden; - transition: all 0.2s ease-in-out; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); - z-index: 999; - pointer-events: none; - } - /* Tooltip Pointer Triangle (Flipped to point up) */ - .modern-pill[data-tooltip]::before { - content: ''; - position: absolute; - top: calc(100% + 3px); /* Position right below the pill border */ - left: 50%; - transform: translateX(-50%); - border-width: 5px; - border-style: solid; - border-color: transparent transparent #1e293b transparent; /* Points upward */ - opacity: 0; - visibility: hidden; - transition: all 0.2s ease-in-out; - z-index: 999; - pointer-events: none; - } - /* Show Tooltip on Hover */ - .modern-pill[data-tooltip]:hover::after { - opacity: 1; - visibility: visible; - transform: translateX(-50%) translateY(0); /* Float down into place */ - z-index: 999; - } - .modern-pill[data-tooltip]:hover::before { - opacity: 1; - visibility: visible; - z-index: 999; - } + .form-message.blue:empty { display: none !important; } + .form-message:has(.modern-audit-tracker) .close-message { display: none !important; } `; document.head.appendChild(style); } if (!frm.doc.audit_stages || frm.doc.audit_stages.length === 0) { - frm.set_intro(""); // Clear if no stages + frm.set_intro(""); return; } - // Modern SVG Chevron instead of --> - const arrow_svg = ``; - - // Format age helper - const fmt_age = (d) => { - if (!d) return ""; - let diff = frappe.datetime.get_diff(frappe.datetime.now_datetime(), d); - return diff <= 0 ? "Today" : diff + " days"; + const get_aging_days = (timestamp) => { + if (!timestamp) return 0; + return frappe.datetime.get_diff(frappe.datetime.now_datetime(), timestamp); }; - // 2. Build the HTML wrapper + // 2. Build HTML Structure let html = ` -
+
+
-
-
- +
+
Raised
+
AUDIT TEAM
- ${arrow_svg} +
+
Initial Stage
+
${frappe.datetime.str_to_user(frm.doc.creation.split(" ")[0])}
+
- -
+
`; - // Generate pills from the actual child table - frm.doc.audit_stages.forEach((row, index) => { - let pill_class = - row.status === "Pending" - ? "pill-pending" - : row.status === "Responded" - ? "pill-responded" - : row.status === "Skipped" - ? "pill-skipped" - : row.status === "No Response" - ? "pill-no-response" - : "pill-default"; - - // Get the best available name for the tooltip - let emp_name = - row.employee_name || row.employee || row.user_id || "Unassigned"; - - // Prepare time info (Date + Aging) - let time_info = ""; + let live_running_index = -1; + for (let i = 0; i < frm.doc.audit_stages.length; i++) { if ( - (row.status === "Pending" || row.status === "No Response") && - row.pending_time + frm.doc.audit_stages[i].status === "Pending" && + frm.doc.audit_stages[i].pending_time ) { - let d = frappe.datetime.str_to_user(row.pending_time.split(" ")[0]); - time_info = - row.status === "No Response" - ? ` | No Response Since: ${d} (${fmt_age(row.pending_time)})` - : ` | Pending: ${d} (${fmt_age(row.pending_time)})`; - } else if (row.status === "Responded" && row.response_time) { - let d = frappe.datetime.str_to_user(row.response_time.split(" ")[0]); - time_info = ` | Responded: ${d} (${fmt_age(row.response_time)} ago)`; + live_running_index = i; + break; } + } + + frm.doc.audit_stages.forEach((row, index) => { + let top_status = ""; + let pill_class = "pill-future"; + let top_label_class = "top-blank"; + let bottom_highlight = ""; + let calculated_date_view = ""; - // Determine TAT for this stage let stage_tat = frm.default_tat || 0; if (frm.tat_config && frm.tat_config[row.stage_name]) { stage_tat = frm.tat_config[row.stage_name]; } + let base_tat_label = stage_tat === 1 ? "1 Day" : `${stage_tat} Days`; + + if (row.status === "Responded") { + top_status = "Responded"; + pill_class = "pill-responded"; + top_label_class = "top-completed"; + } else if (row.status === "No Response") { + top_status = "No Response"; + pill_class = "pill-no-response"; + top_label_class = "top-no-response"; + } else if (row.status === "Pending") { + if (index === live_running_index) { + let current_elapsed = get_aging_days(row.pending_time); + let days_left = stage_tat - current_elapsed; + + if (days_left < 0) { + top_status = "Overdue"; + pill_class = "pill-overdue"; + top_label_class = "top-overdue"; + } else { + top_status = "In Progress"; + pill_class = "pill-progress"; + top_label_class = "top-progress"; + } + } else { + top_status = "Pending"; + pill_class = "pill-future"; + top_label_class = "top-pending"; + } + } + + if (row.status === "Responded" && row.response_time) { + let days_taken = + get_aging_days(row.pending_time) - get_aging_days(row.response_time); + bottom_highlight = `
${days_taken <= 1 ? "1 Day" : days_taken + " Days"}
`; + calculated_date_view = frappe.datetime.str_to_user( + row.response_time.split(" ")[0], + ); + } else if ( + (index === live_running_index || row.status === "No Response") && + row.pending_time + ) { + let current_elapsed = get_aging_days(row.pending_time); + let days_left = stage_tat - current_elapsed; + + bottom_highlight = `
${base_tat_label}
`; + if (days_left >= 0) { + let color_class = + row.status === "No Response" + ? "text-orange-highlight" + : "text-blue-highlight"; + let left_label = + days_left === 1 ? "1 Day Left" : `${days_left} Days Left`; + bottom_highlight += `
${left_label}
`; + } else { + let overdue_days_abs = Math.abs(days_left); + let overdue_label = + overdue_days_abs === 1 + ? "1 Day Overdue" + : `${overdue_days_abs} Days Overdue`; + bottom_highlight += `
${overdue_label}
`; + } + let target_due = frappe.datetime.add_days( + row.pending_time.split(" ")[0], + stage_tat, + ); + calculated_date_view = `Due ${frappe.datetime.str_to_user(target_due)}`; + } else { + bottom_highlight = `
${base_tat_label}
`; + if (row.pending_time && row.status === "Pending") { + let target_due = frappe.datetime.add_days( + row.pending_time.split(" ")[0], + stage_tat, + ); + calculated_date_view = `Due ${frappe.datetime.str_to_user(target_due)}`; + } else { + calculated_date_view = ""; + } + } + + let emp_name = + row.employee_name || row.employee || row.user_id || "Unassigned"; + + let tooltip_str = `Assignee: ${emp_name} Status: ${row.status || "Not Started"}`; + if (row.pending_time) + tooltip_str += ` Started: ${frappe.datetime.str_to_user(row.pending_time.split(" ")[0])}`; + html += ` -
-
-
TAT: ${stage_tat}d
-
- ${row.stage_name} -
+
+
${top_status || " "}
+
+ ${row.stage_name} +
+
+ ${bottom_highlight} +
${calculated_date_view}
- ${arrow_svg}
`; - }); - html += `
`; // End draggable-stages + if (index !== frm.doc.audit_stages.length - 1) { + let line_class = "connector-grey-dashed"; + if (row.status === "Responded") { + line_class = "connector-solid"; + } else if (index === live_running_index || row.status === "No Response") { + line_class = "connector-blue-dashed"; + } else if (top_status === "Overdue") { + line_class = "connector-red-dashed"; + } + html += `
`; + } + }); - // Add Settings Icon if user has permission if (can_edit) { html += ` -
- +
+
`; } - html += `
`; // End wrapper + html += `
`; - // 3. Clear ALL existing intro messages before setting the new one frm.page.wrapper.find(".form-message-container").empty(); - - // Set the intro natively via Frappe frm.set_intro(html, "blue"); - // FORCE REMOVE the Close Button via Javascript as a secondary bulletproof measure setTimeout(() => { let wrapper = frm.page.wrapper.find(".custom-interactive-tracker-wrapper"); if (wrapper.length > 0) { @@ -2591,43 +2723,38 @@ function render_interactive_tracker(frm, can_edit) { } }, 50); - // 4. Make it Draggable (if permitted) - if (can_edit) { + // 4. Sortable Engine + if (can_edit && typeof Sortable !== "undefined") { let el = document.getElementById("draggable-stages"); + new Sortable(el, { + animation: 150, + draggable: ".sortable-item", + ghostClass: "sortable-ghost", + onEnd: function (evt) { + let old_index = evt.oldIndex - 1; + let new_index = evt.newIndex - 1; + + if (old_index < 0 || new_index < 0 || old_index === new_index) return; + + let moved_item = frm.doc.audit_stages.splice(old_index, 1)[0]; + frm.doc.audit_stages.splice(new_index, 0, moved_item); + + frm.doc.audit_stages.forEach((row, i) => { + row.stage = i + 1; + row.idx = i + 1; + }); - if (typeof Sortable !== "undefined") { - new Sortable(el, { - animation: 150, - draggable: ".sortable-item", - ghostClass: "sortable-ghost", - onEnd: function (evt) { - let old_index = evt.oldIndex; - let new_index = evt.newIndex; - - if (old_index === new_index) return; - - let moved_item = frm.doc.audit_stages.splice(old_index, 1)[0]; - frm.doc.audit_stages.splice(new_index, 0, moved_item); - - frm.doc.audit_stages.forEach((row, i) => { - row.stage = i + 1; - row.idx = i + 1; + frm.dirty(); + frm.refresh_field("audit_stages"); + frm.save().then(() => { + frappe.show_alert({ + message: "Stage sequence updated", + indicator: "green", }); + }); + }, + }); - frm.dirty(); - frm.refresh_field("audit_stages"); - - frm.save().then(() => { - frappe.show_alert({ - message: "Stage order saved successfully", - indicator: "green", - }); - }); - }, - }); - } - - // Attach Settings Modal Click Event setTimeout(() => { let settings_icon = document.getElementById("edit-tracker-settings"); if (settings_icon) {