jey_theme/jey_theme/public/js/jey_theme.js

2422 lines
94 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ==========================================================================
Jey Theme — Multi-theme system + Desktop Icons
========================================================================== */
/* --- Theme + full-width initialisation (runs immediately, before paint) --- */
(function () {
var DEFAULT_THEME = "chrome";
var saved = localStorage.getItem("jey-theme");
if (!saved) {
saved = DEFAULT_THEME;
localStorage.setItem("jey-theme", saved);
}
document.documentElement.setAttribute("data-jey-theme", saved);
// Default full-width on fresh install (user never toggled it)
if (localStorage.getItem("container_fullwidth") === null) {
localStorage.setItem("container_fullwidth", "true");
}
// Inject saved column widths immediately to avoid flash.
// Also store grid keys so we can set data-jey-grid attributes early.
var resizeStyle = document.createElement("style");
resizeStyle.id = "jey-col-resize-styles";
var rules = [];
var earlyGridKeys = [];
var earlyListDoctypes = [];
for (var i = 0; i < localStorage.length; i++) {
var k = localStorage.key(i);
if (k.indexOf("jey-col-widths:") === 0) {
var gridKey = k.substring(15);
earlyGridKeys.push(gridKey);
try {
var widths = JSON.parse(localStorage.getItem(k));
Object.keys(widths).forEach(function (fn) {
rules.push(
'[data-jey-grid="' + gridKey + '"] .grid-static-col[data-fieldname="' + fn + '"]' +
" { flex: 0 0 " + widths[fn] + "px !important; width: " + widths[fn] + "px !important; max-width: none !important; }"
);
});
} catch (e) {}
} else if (k.indexOf("jey-list-col-widths:") === 0) {
var listDoctype = k.substring(20);
earlyListDoctypes.push(listDoctype);
try {
var lwidths = JSON.parse(localStorage.getItem(k));
Object.keys(lwidths).forEach(function (fn) {
var esc = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
rules.push(
'.list-view[data-jey-list="' + listDoctype + '"] .list-row-col.' + esc +
" { flex: 0 0 " + lwidths[fn] + "px !important; width: " + lwidths[fn] +
"px !important; max-width: none !important; min-width: 0 !important; }"
);
});
} catch (e) {}
}
}
resizeStyle.textContent = rules.join("\n");
document.head.appendChild(resizeStyle);
// Set data-jey-grid on grids as soon as they appear so CSS rules apply immediately
if (earlyGridKeys.length || earlyListDoctypes.length) {
var earlyObs = new MutationObserver(function () {
earlyGridKeys.forEach(function (key) {
// key = "Sales Invoice:items" → fieldname = "items"
var parts = key.split(":");
if (parts.length < 2) return;
var tableField = parts[1];
var control = document.querySelector('.frappe-control[data-fieldname="' + tableField + '"]');
if (control) {
var grid = control.querySelector(".form-grid");
if (grid && !grid.dataset.jeyGrid) {
grid.dataset.jeyGrid = key;
}
}
});
if (earlyListDoctypes.length) {
// Stamp data-jey-list on any .list-view whose route matches a saved doctype
var route = (window.frappe && frappe.get_route) ? frappe.get_route() : null;
var routeDoctype = (route && route[0] === "List") ? route[1] : null;
if (routeDoctype && earlyListDoctypes.indexOf(routeDoctype) !== -1) {
document.querySelectorAll(".list-view").forEach(function (lv) {
if (lv.dataset.jeyList !== routeDoctype) {
lv.dataset.jeyList = routeDoctype;
}
});
}
}
});
earlyObs.observe(document.body, { childList: true, subtree: true });
}
})();
(function () {
"use strict";
/* ==================================================================
Per-theme icon sets
Each theme can define its own SVGs. Falls back to "chrome" if a
key is missing, then to FALLBACK.
================================================================== */
var SETTINGS_PATHS = '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.32 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>';
var HR_PATHS = '<path d="M16 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line class="plus-v" x1="20" y1="8" x2="20" y2="14"/><line class="plus-h" x1="23" y1="11" x2="17" y2="11"/>';
var THEME_ICONS = {
/* ============================================================
CHROME — detailed line-art icons from the reference HTML
============================================================ */
chrome: {
buying: { cls: "ico-buying", paths: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 002 1.61h9.72a2 2 0 002-1.61L23 6H6"/>' },
selling: { cls: "ico-selling", paths: '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline class="arrow-head" points="16 7 22 7 22 13"/>' },
stock: { cls: "ico-stock", paths: '<path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>' },
manufacturing: { cls: "ico-mfg", paths: '<g class="gear1"><circle cx="9" cy="12" r="3"/><path d="M9 9V4.5"/><path d="M6.5 10.5L3 8"/><path d="M6.5 13.5L3 16"/><path d="M9 15v4.5"/><path d="M11.5 10.5L15 8"/><path d="M11.5 13.5L15 16"/></g><g class="gear2"><circle cx="17" cy="8" r="2.5"/><path d="M17 5.5V3"/><path d="M19.2 7L21 5.5"/><path d="M19.2 9L21 10.5"/><path d="M14.8 7L13 5.5"/><path d="M14.8 9L13 10.5"/></g>' },
projects: { cls: "ico-proj", paths: '<rect class="col1" x="3" y="3" width="5" height="18" rx="1"/><rect class="col2" x="10" y="3" width="5" height="12" rx="1"/><rect class="col3" x="17" y="3" width="5" height="15" rx="1"/>' },
quality: { cls: "ico-quality", paths: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><polyline points="9 12 11 14 15 10"/>' },
assets: { cls: "ico-assets", paths: '<rect x="3" y="5" width="18" height="15" rx="2"/><line x1="3" y1="10" x2="21" y2="10"/><circle class="lock-dot" cx="12" cy="15" r="2"/><line x1="12" y1="13" x2="12" y2="10"/><line x1="7" y1="5" x2="7" y2="2"/><line x1="17" y1="5" x2="17" y2="2"/>' },
subcontracting: { cls: "ico-sub", paths: '<g class="person-l"><circle cx="8" cy="7" r="3"/><path d="M7 11H4a2 2 0 00-2 2v1"/><path d="M11 18l-3-3 2-2 3 3"/></g><g class="person-r"><circle cx="16" cy="7" r="3"/><path d="M17 11h3a2 2 0 012 2v1"/><path d="M13 18l3-3-2-2-3 3"/></g>' },
organization: { cls: "ico-org", paths: '<rect x="4" y="2" width="16" height="20" rx="1"/><line x1="9" y1="6" x2="9" y2="6.01"/><line x1="15" y1="6" x2="15" y2="6.01"/><line x1="9" y1="10" x2="9" y2="10.01"/><line x1="15" y1="10" x2="15" y2="10.01"/><line x1="9" y1="14" x2="9" y2="14.01"/><line x1="15" y1="14" x2="15" y2="14.01"/><path d="M10 22v-4h4v4"/>' },
accounting: { cls: "ico-acc", paths: '<rect x="4" y="2" width="16" height="20" rx="2"/><rect x="7" y="5" width="10" height="4" rx="1"/><line class="acc-btn" x1="7" y1="13" x2="7" y2="13.01" stroke-width="2.5"/><line x1="12" y1="13" x2="12" y2="13.01" stroke-width="2.5"/><line class="acc-btn" x1="17" y1="13" x2="17" y2="13.01" stroke-width="2.5" style="animation-delay:0.4s"/><line x1="7" y1="17" x2="7" y2="17.01" stroke-width="2.5"/><line class="acc-btn" x1="12" y1="17" x2="12" y2="17.01" stroke-width="2.5" style="animation-delay:0.8s"/><line x1="17" y1="17" x2="17" y2="17.01" stroke-width="2.5"/>' },
framework: { cls: "ico-framework", paths: '<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>' },
"erpnext settings": { cls: "ico-settings", paths: SETTINGS_PATHS },
settings: { cls: "ico-settings", paths: SETTINGS_PATHS },
"accounts setup": { cls: "ico-settings", paths: SETTINGS_PATHS },
"account setup": { cls: "ico-settings", paths: SETTINGS_PATHS },
hr: { cls: "ico-hr", paths: HR_PATHS },
"hr module": { cls: "ico-hr", paths: HR_PATHS },
"frappe hr": { cls: "ico-hr", paths: HR_PATHS },
invoicing: { paths: '<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/>' },
payments: { paths: '<rect x="1" y="4" width="22" height="16" rx="2"/><line x1="1" y1="10" x2="23" y2="10"/><line x1="5" y1="15" x2="9" y2="15"/><line x1="13" y1="15" x2="15" y2="15"/>' },
"financial reports": { paths: '<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="10" y2="9"/>' },
taxes: { paths: '<path d="M4 2h16a2 2 0 012 2v16a2 2 0 01-2 2H4a2 2 0 01-2-2V4a2 2 0 012-2z"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="13" y2="16"/><line x1="6" y1="8" x2="6" y2="8.01" stroke-width="2"/><line x1="6" y1="12" x2="6" y2="12.01" stroke-width="2"/><line x1="6" y1="16" x2="6" y2="16.01" stroke-width="2"/>' },
"taxes az": { paths: '<path d="M4 2h16a2 2 0 012 2v16a2 2 0 01-2 2H4a2 2 0 01-2-2V4a2 2 0 012-2z"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="13" y2="16"/><line x1="6" y1="8" x2="6" y2="8.01" stroke-width="2"/><line x1="6" y1="12" x2="6" y2="12.01" stroke-width="2"/><line x1="6" y1="16" x2="6" y2="16.01" stroke-width="2"/>' },
banking: { paths: '<path d="M3 21h18"/><path d="M3 10h18"/><path d="M12 3l9 7H3l9-7z"/><line x1="6" y1="10" x2="6" y2="21"/><line x1="10" y1="10" x2="10" y2="21"/><line x1="14" y1="10" x2="14" y2="21"/><line x1="18" y1="10" x2="18" y2="21"/>' },
budget: { paths: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/>' },
"share management": { paths: '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>' },
subscription: { paths: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/><polyline points="7 10 10 13 17 7"/>' },
crm: { paths: '<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/>' },
support: { paths: '<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>' },
/* --- Framework sub-modules --- */
automation: { paths: '<polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/>' },
build: { paths: '<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/>' },
data: { paths: '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>' },
email: { paths: '<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22 6 12 13 2 6"/>' },
integrations: { paths: '<circle cx="12" cy="12" r="3"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="5" r="2"/><circle cx="19" cy="19" r="2"/><circle cx="5" cy="19" r="2"/><line x1="14.5" y1="9.5" x2="17.5" y2="6.5"/><line x1="9.5" y1="9.5" x2="6.5" y2="6.5"/><line x1="14.5" y1="14.5" x2="17.5" y2="17.5"/><line x1="9.5" y1="14.5" x2="6.5" y2="17.5"/>' },
printing: { paths: '<polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 01-2-2v-5a2 2 0 012-2h16a2 2 0 012 2v5a2 2 0 01-2 2h-2"/><rect x="6" y="14" width="12" height="8"/>' },
system: { paths: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/><line x1="6" y1="8" x2="10" y2="8"/><line x1="6" y1="11" x2="14" y2="11"/>' },
users: { paths: '<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/>' },
website: { paths: '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>' },
/* --- HRMS sub-modules --- */
attendance: { paths: '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><polyline points="9 16 11 18 15 14"/>' },
"shift & attendance": { paths: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>' },
"shift &amp; attendance": { paths: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>' },
leaves: { paths: '<path d="M6 3v18"/><path d="M6 8c6-2 10 2 16 0"/><path d="M6 15c6-2 10 2 16 0"/>' },
payroll: { paths: '<rect x="2" y="4" width="20" height="16" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/><line x1="12" y1="4" x2="12" y2="20"/><line x1="6" y1="7" x2="6" y2="7.01" stroke-width="2"/><line x1="6" y1="14" x2="9" y2="14"/><line x1="6" y1="17" x2="8" y2="17"/><line x1="15" y1="14" x2="18" y2="14"/><line x1="15" y1="17" x2="19" y2="17"/>' },
people: { paths: '<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/>' },
recruitment: { paths: '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><path d="M11 8v6"/><path d="M8 11h6"/>' },
performance: { paths: '<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>' },
expenses: { paths: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/>' },
tenure: { paths: '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="14" x2="8" y2="14.01" stroke-width="2"/><line x1="12" y1="14" x2="12" y2="14.01" stroke-width="2"/><line x1="16" y1="14" x2="16" y2="14.01" stroke-width="2"/><line x1="8" y1="18" x2="8" y2="18.01" stroke-width="2"/>' },
"tax & benefits": { paths: '<path d="M4 2h16a2 2 0 012 2v16a2 2 0 01-2 2H4a2 2 0 01-2-2V4a2 2 0 012-2z"/><path d="M16 8l-8 8"/><circle cx="9" cy="9" r="1.5"/><circle cx="15" cy="15" r="1.5"/>' },
"tax &amp; benefits": { paths: '<path d="M4 2h16a2 2 0 012 2v16a2 2 0 01-2 2H4a2 2 0 01-2-2V4a2 2 0 012-2z"/><path d="M16 8l-8 8"/><circle cx="9" cy="9" r="1.5"/><circle cx="15" cy="15" r="1.5"/>' },
_fallback: '<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/>'
},
/* ============================================================
MODERN — minimal geometric icons, visually distinct
============================================================ */
modern: {
buying: { paths: '<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 01-8 0"/>' },
selling: { paths: '<line x1="12" y1="20" x2="12" y2="10"/><polyline points="18 14 12 20 6 14"/><line x1="4" y1="4" x2="20" y2="4"/>' },
stock: { paths: '<rect x="2" y="2" width="8" height="8" rx="1"/><rect x="14" y="2" width="8" height="8" rx="1"/><rect x="2" y="14" width="8" height="8" rx="1"/><rect x="14" y="14" width="8" height="8" rx="1"/>' },
manufacturing: { paths: '<circle cx="12" cy="12" r="3"/><path d="M12 1v4"/><path d="M12 19v4"/><path d="M4.22 4.22l2.83 2.83"/><path d="M16.95 16.95l2.83 2.83"/><path d="M1 12h4"/><path d="M19 12h4"/><path d="M4.22 19.78l2.83-2.83"/><path d="M16.95 7.05l2.83-2.83"/>' },
projects: { paths: '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>' },
quality: { paths: '<path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>' },
assets: { paths: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><line x1="12" y1="8" x2="12" y2="3"/>' },
subcontracting: { paths: '<circle cx="9" cy="7" r="4"/><circle cx="15" cy="7" r="4"/><path d="M3 21v-2a4 4 0 014-4h2"/><path d="M15 15h2a4 4 0 014 4v2"/>' },
organization: { paths: '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="12" y1="3" x2="12" y2="21"/>' },
accounting: { paths: '<rect x="4" y="4" width="16" height="16" rx="2"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="12" y1="4" x2="12" y2="20"/><line x1="8" y1="8" x2="8" y2="8.01" stroke-width="2.5"/><line x1="16" y1="16" x2="16" y2="16.01" stroke-width="2.5"/>' },
framework: { paths: '<polygon points="12 2 22 8.5 22 15.5 12 22 2 15.5 2 8.5 12 2"/><line x1="12" y1="22" x2="12" y2="15.5"/><polyline points="22 8.5 12 15.5 2 8.5"/>' },
"erpnext settings": { paths: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>' },
settings: { paths: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>' },
"accounts setup": { paths: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>' },
"account setup": { paths: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>' },
hr: { paths: '<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/>' },
"hr module": { paths: '<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/>' },
"frappe hr": { paths: '<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/>' },
invoicing: { paths: '<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
payments: { paths: '<rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/>' },
"financial reports": { paths: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' },
taxes: { paths: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/>' },
banking: { paths: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 8h.01"/><path d="M10 8h.01"/><path d="M14 8h.01"/><line x1="2" y1="12" x2="22" y2="12"/>' },
budget: { paths: '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>' },
"share management": { paths: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>' },
subscription: { paths: '<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22 6 12 13 2 6"/>' },
crm: { paths: '<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/>' },
support: { paths: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>' },
_fallback: '<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>'
}
};
/* ==================================================================
Helpers
================================================================== */
function currentTheme() {
return document.documentElement.getAttribute("data-jey-theme") || "chrome";
}
function getIconDef(key) {
var theme = currentTheme();
var icons = THEME_ICONS[theme] || THEME_ICONS.chrome;
var icon = key && icons[key];
if (icon) return icon;
// fallback to chrome if modern doesn't have the key
icon = key && THEME_ICONS.chrome[key];
if (icon) return icon;
return icons._fallback || THEME_ICONS.chrome._fallback;
}
/** Returns true only if key has an explicit entry (not _fallback). */
function hasIconDef(key) {
if (!key) return false;
var theme = currentTheme();
var icons = THEME_ICONS[theme] || THEME_ICONS.chrome;
if (icons[key] && key !== "_fallback") return true;
if (THEME_ICONS.chrome[key] && key !== "_fallback") return true;
return false;
}
function makeSvg(icon) {
var paths = typeof icon === "string" ? icon : icon.paths;
var cls = (typeof icon === "object" && icon.cls) ? icon.cls : "";
var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("class", "jey-icon" + (cls ? " " + cls : ""));
svg.innerHTML = paths;
return svg;
}
function getIconKey(el) {
var desktop = el.closest(".desktop-icon");
if (!desktop) return null;
var id = desktop.getAttribute("data-id");
return id ? id.toLowerCase() : null;
}
function buildChrome() {
var c = document.createElement("div");
c.className = "jey-chrome";
c.innerHTML =
'<div class="jey-chrome-ring"><div class="jey-ring-glow"></div></div>' +
'<div class="jey-chrome-gap"></div>' +
'<div class="jey-chrome-ring-inner"></div>' +
'<div class="jey-chrome-face">' +
'<div class="jey-face-glow"></div>' +
'<div class="jey-face-reflection"></div>' +
"</div>";
return { chrome: c, face: c.querySelector(".jey-chrome-face") };
}
/* ==================================================================
Icon processing
================================================================== */
function wrapIcon(iconContainer) {
if (iconContainer.classList.contains("jey-chrome-done")) return;
iconContainer.classList.add("jey-chrome-done");
// Hide original children
Array.from(iconContainer.children).forEach(function (child) {
child.style.display = "none";
});
var parts = buildChrome();
var key = getIconKey(iconContainer);
parts.face.appendChild(makeSvg(getIconDef(key)));
iconContainer.appendChild(parts.chrome);
}
var DESKTOP_LABEL_OVERRIDES = {
"Frappe HR": "HR",
"ERPNext Settings": "Settings"
};
function renameDesktopLabels() {
Object.keys(DESKTOP_LABEL_OVERRIDES).forEach(function (id) {
var newLabel = DESKTOP_LABEL_OVERRIDES[id];
document.querySelectorAll('.desktop-icon[data-id="' + id + '"]').forEach(function (icon) {
var title = icon.querySelector(".icon-title");
if (title && title.textContent.trim() !== newLabel) {
title.textContent = newLabel;
title.setAttribute("data-original-title", newLabel);
}
});
});
}
function processIcons() {
document.querySelectorAll(
".desktop-icon > .icon-container:not(.jey-chrome-done)"
).forEach(function (c) {
if (c.closest(".folder-icon")) return;
wrapIcon(c);
});
document.querySelectorAll(
".desktop-icon > .icon-container.folder-icon:not(.jey-chrome-done)"
).forEach(wrapIcon);
renameDesktopLabels();
staggerAnimations();
}
function staggerAnimations() {
document.querySelectorAll(".jey-ring-glow").forEach(function (el, i) {
el.style.animationDelay = i * -0.6 + "s";
});
document.querySelectorAll(".jey-face-glow").forEach(function (el, i) {
el.style.animationDelay = i * -0.6 + "s";
});
document.querySelectorAll(".jey-face-reflection").forEach(function (el, i) {
el.style.animationDelay = i * -0.8 + "s";
});
}
/* ==================================================================
Context menu icon replacement
Replaces default ERPNext icons in .frappe-menu dropdowns with
the current theme's SVG icons (same as desktop).
================================================================== */
/* Menu titles are localized (e.g. Azerbaijani "Mühasibat uçotu"), so the
lookup key is taken from the untranslated img src filename first, then
from a reverse-lookup over frappe.boot.desktop_icons by __(label), and
only last from the displayed text as a best-effort fallback. */
function resolveEnglishKey(item) {
var img = item.querySelector("img.logo[src]");
if (img) {
var m = img.getAttribute("src").match(/\/([^\/]+)\.svg(\?|$)/);
if (m) return m[1].replace(/_/g, " ").toLowerCase();
}
var titleEl = item.querySelector(".menu-item-title, .header-title");
if (!titleEl) return null;
var titleText = titleEl.textContent.trim();
if (window.frappe && frappe.boot && Array.isArray(frappe.boot.desktop_icons) && typeof __ === "function") {
for (var i = 0; i < frappe.boot.desktop_icons.length; i++) {
var dIcon = frappe.boot.desktop_icons[i];
if (dIcon && dIcon.label && __(dIcon.label) === titleText) {
return dIcon.label.toLowerCase();
}
}
}
return titleText.toLowerCase();
}
function processContextMenuIcons() {
document.querySelectorAll(".frappe-menu .dropdown-menu-item").forEach(function (item) {
var iconDiv = item.querySelector(".menu-item-icon");
if (!iconDiv || iconDiv.querySelector(".jey-icon")) return;
// Skip chevron icons (the second .menu-item-icon with margin-left:auto)
if (iconDiv.style.marginLeft === "auto") return;
var key = resolveEnglishKey(item);
if (!hasIconDef(key)) return;
iconDiv.innerHTML = "";
iconDiv.appendChild(makeSvg(getIconDef(key)));
});
}
/* ==================================================================
Frappe-menu lucide icon remap
Some dropdowns (the user/settings menu at the top-left) render as
<svg><use href="#icon-NAME"> from the lucide sprite. Rewrite those
to semantically-close-but-different sprite ids so the theme feels
distinct from stock Frappe. Idempotent via .jey-menu-remap-done.
================================================================== */
var MENU_LUCIDE_ICON_MAP = {
"home": "house",
"sliders-horizontal": "settings-2",
"rotate-ccw": "refresh-ccw",
"maximize": "chevrons-left-right",
"moon": "moon-star",
"info": "life-buoy",
"logout": "log-out"
};
function processMenuLucideIcons() {
document.querySelectorAll(".frappe-menu .dropdown-menu-item").forEach(function (item) {
if (item.classList.contains("jey-menu-remap-done")) return;
item.classList.add("jey-menu-remap-done");
item.querySelectorAll(".menu-item-icon svg > use[href^='#icon-']").forEach(function (useEl) {
// Leave chevron-right (submenu indicator) alone.
var from = useEl.getAttribute("href").slice("#icon-".length);
if (from === "chevron-right") return;
var to = MENU_LUCIDE_ICON_MAP[from];
if (!to || to === from) return;
var newHref = "#icon-" + to;
useEl.setAttribute("href", newHref);
if (useEl.hasAttribute("xlink:href")) {
useEl.setAttribute("xlink:href", newHref);
}
});
});
}
/* ==================================================================
Sidebar header icon replacement
Replaces <img> / alphabet SVGs inside .sidebar-header with
the current theme's SVG icon.
================================================================== */
function processSidebarIcons() {
document.querySelectorAll(".sidebar-header").forEach(function (header) {
var logo = header.querySelector(".header-logo");
if (!logo || logo.querySelector(".jey-icon")) return;
var key = resolveEnglishKey(header);
if (!hasIconDef(key)) return;
logo.innerHTML = "";
logo.appendChild(makeSvg(getIconDef(key)));
});
}
/* ==================================================================
Workspace sidebar item icon remap
Rewrites <use href="#icon-NAME"> inside .sidebar-item-icon[item-icon]
to point at a different but semantically related sprite id.
Idempotent via .jey-side-item-done marker class.
================================================================== */
var SIDEBAR_ITEM_ICON_MAP = {
/* ERPNext / timeless legacy names */
"home": "house",
"accounting": "landmark",
"assets": "warehouse",
"buying": "shopping-cart",
"crm": "handshake",
"customer": "contact-round",
"customization": "palette",
"expenses": "banknote-arrow-up",
"getting-started": "rocket",
"hr": "users-round",
"integration": "plug-2",
"liabilities": "scale",
"loan": "piggy-bank",
"money-coins-1": "coins",
"non-profit": "heart-handshake",
"organization": "building-2",
"project": "folder-kanban",
"projects": "kanban",
"quality": "badge-check",
"sell": "trending-up",
"setting": "settings-2",
"stock": "package-2",
"support": "headphones",
"tool": "wrench",
"web": "earth",
"website": "globe",
"chart": "chart-column",
"criticize": "thumbs-down",
"review": "book-open-check",
"image-view": "image",
"up-arrow": "arrow-up",
"table_2": "table-properties",
"book-text": "library",
/* Lucide names found in workspace_sidebar fixtures */
"arrow-down-from-line": "download",
"arrow-left-to-line": "panel-left-close",
"arrow-right-from-line": "log-out",
"at-sign": "mail",
"badge-alert": "triangle-alert",
"badge-cent": "circle-dollar-sign",
"banknote-arrow-up": "trending-up",
"banknote-x": "receipt",
"bell": "bell-ring",
"bell-dot": "bell-ring",
"bell-ring": "megaphone",
"book-open-check": "clipboard-check",
"book-open-text": "book-marked",
"book-user": "contact-round",
"briefcase-business": "briefcase",
"building-2": "landmark",
"cable": "network",
"calendar-check": "calendar-check-2",
"calendar-check-2": "calendar-fold",
"calendar-clock": "timer",
"calendar-days": "calendar-range",
"calendar-heart": "heart",
"calendar-range": "calendar-fold",
"calendar-sync": "rotate-cw",
"caravan": "tent",
"circle-dollar-sign": "coins",
"circle-user-round": "user-round",
"clipboard-check": "clipboard-list",
"clipboard-pen": "notebook-pen",
"computer": "monitor",
"database": "hard-drive",
"database-backup": "save",
"dollar-sign": "coins",
"earth": "globe",
"factory": "warehouse",
"file": "file-text",
"file-lock": "shield",
"file-question-mark": "file-text",
"file-up": "upload",
"file-user": "contact-round",
"flag": "flame",
"folder-tree": "list-tree",
"funnel": "filter",
"git-pull-request-arrow": "git-branch",
"goal": "target",
"graduation-cap": "school",
"grid-2x2-check": "layout-grid",
"hammer": "wrench",
"heading": "text",
"heart-pulse": "stethoscope",
"import": "file-up",
"inspection-panel": "clipboard-check",
"laptop": "monitor",
"layout-dashboard": "gauge",
"lightbulb": "zap",
"link": "link-2",
"list": "list-checks",
"list-end": "list-ordered",
"list-todo": "clipboard-list",
"list-tree": "folder-tree",
"lock-keyhole": "shield",
"lock-keyhole-open": "shield-alert",
"logs": "scroll-text",
"mail": "mail-open",
"mail-x": "inbox",
"message-square-reply": "speech",
"milestone": "flag",
"monitor-check": "square-check",
"move-horizontal": "arrow-left-right",
"notebook-pen": "square-pen",
"notebook-text": "notepad-text",
"notepad-text": "scroll-text",
"orbit": "atom",
"panel-bottom-close": "panel-top-open",
"panel-top-close": "panel-bottom-open",
"pen-tool": "pen-line",
"person-standing": "user-round",
"piggy-bank": "vault",
"plane": "send",
"pointer": "mouse-pointer-2",
"printer": "file-output",
"receipt": "receipt-text",
"receipt-text": "scroll-text",
"repeat": "rotate-cw",
"repeat-2": "rotate-ccw",
"scroll-text": "book-text",
"server": "database",
"settings": "sliders-horizontal",
"sheet": "file-spreadsheet",
"split": "git-branch",
"sprout": "leaf",
"square-activity": "heart-pulse",
"square-pen": "pen-line",
"square-user-round": "contact-round",
"star": "sparkles",
"table": "table-2",
"thumbs-up": "heart",
"trash": "archive",
"trending-down": "chart-line",
"triangle": "triangle-alert",
"truck": "ship",
"tv-minimal": "monitor",
"upload": "cloud-upload",
"user": "user-round",
"user-cog": "user-round-cog",
"user-lock": "shield",
"user-round": "circle-user-round",
"user-round-check": "user-check",
"user-round-minus": "user-x",
"user-star": "award",
"users": "users-round",
"users-round": "contact-round",
"videotape": "film",
"wallpaper": "image",
"washing-machine": "container",
"webhook": "cable"
};
function processSidebarItemIcons() {
var nodes = document.querySelectorAll(
".body-sidebar .sidebar-item-icon[item-icon]:not(.jey-side-item-done)"
);
if (!nodes.length) return;
for (var i = 0; i < nodes.length; i++) {
var span = nodes[i];
var from = (span.getAttribute("item-icon") || "").trim().toLowerCase();
// Always mark done — even on unknown / identity — so we never revisit.
span.classList.add("jey-side-item-done");
var to = SIDEBAR_ITEM_ICON_MAP[from];
if (!to || to === from) continue;
var useEl = span.querySelector("svg > use");
if (!useEl) continue;
var newHref = "#icon-" + to;
if (useEl.getAttribute("href") !== newHref) {
useEl.setAttribute("href", newHref);
}
if (useEl.hasAttribute("xlink:href") &&
useEl.getAttribute("xlink:href") !== newHref) {
useEl.setAttribute("xlink:href", newHref);
}
span.setAttribute("data-jey-orig-icon", from);
span.setAttribute("item-icon", to);
}
}
/* ==================================================================
Rebuild all icons (called on theme switch)
================================================================== */
function rebuildAllIcons() {
document.querySelectorAll(".icon-container.jey-chrome-done").forEach(function (c) {
// Remove chrome DOM
var chrome = c.querySelector(".jey-chrome");
if (chrome) chrome.remove();
c.classList.remove("jey-chrome-done");
// Restore original children visibility (they stay hidden,
// wrapIcon will re-hide them anyway, but we need the class removed)
});
// Reset context menu / sidebar icons so they rebuild with new theme SVGs
document.querySelectorAll(".frappe-menu .menu-item-icon .jey-icon").forEach(function (svg) {
svg.remove();
});
document.querySelectorAll(".sidebar-header .header-logo .jey-icon").forEach(function (svg) {
svg.remove();
});
processIcons();
processContextMenuIcons();
processMenuLucideIcons();
processSidebarIcons();
processSidebarItemIcons();
}
// Expose for theme switcher
window.jeyRebuildIcons = rebuildAllIcons;
/* ==================================================================
Boot
================================================================== */
function processAll() { processIcons(); processContextMenuIcons(); processMenuLucideIcons(); processSidebarIcons(); processSidebarItemIcons(); }
$(document).on("page-change", processAll);
var attempts = 0;
var poll = setInterval(function () {
processAll();
attempts++;
if (attempts > 30) clearInterval(poll);
}, 300);
var observer = new MutationObserver(processAll);
observer.observe(document.body, { childList: true, subtree: true });
})();
/* ==========================================================================
Toggle Full Width button in sidebar (above Collapse)
========================================================================== */
(function () {
"use strict";
// Insert our labeled sidebar links at the top of .body-sidebar-bottom.
// We don't anchor on .collapse-sidebar-link anymore — Frappe brought it
// back in v16.17 as a tiny icon-button inside a horizontal flex row
// (.sidebar-bottom-actions), so inserting before it would push our links
// into that row and overflow the sidebar.
window.jeySidebarAnchor = window.jeySidebarAnchor || function () {
var bottom = document.querySelector(".body-sidebar .body-sidebar-bottom");
if (bottom) return { parent: bottom, before: bottom.firstChild };
return null;
};
function addFullWidthButton() {
if (document.querySelector(".jey-fullwidth-link")) return;
var anchor = window.jeySidebarAnchor();
if (!anchor) return;
var link = document.createElement("a");
link.className = "jey-fullwidth-link";
function updateIcon() {
link.innerHTML =
frappe.utils.icon("chevrons-left-right", "sm", "", "", "text-ink-gray-7 current-color", true) +
"<span>" + __("Full Width") + "</span>";
}
link.addEventListener("click", function () {
frappe.ui.toolbar.toggle_full_width();
updateIcon();
});
updateIcon();
anchor.parent.insertBefore(link, anchor.before);
}
$(document).ready(addFullWidthButton);
$(document).on("page-change", addFullWidthButton);
})();
/* ==========================================================================
Toggle Theme button in sidebar (above Collapse)
========================================================================== */
(function () {
"use strict";
function addThemeSwitchButton() {
if (document.querySelector(".jey-theme-switch-link")) return;
var anchor = window.jeySidebarAnchor && window.jeySidebarAnchor();
if (!anchor) return;
var link = document.createElement("a");
link.className = "jey-theme-switch-link";
link.innerHTML =
frappe.utils.icon("palette", "sm", "", "", "text-ink-gray-7 current-color", true) +
"<span>" + __("Switch Theme") + "</span>";
link.addEventListener("click", function () {
new frappe.ui.ThemeSwitcher().show();
});
anchor.parent.insertBefore(link, anchor.before);
}
$(document).ready(addThemeSwitchButton);
$(document).on("page-change", addThemeSwitchButton);
})();
/* ==========================================================================
Collapse / Expand Sidebar button (restored; v16 removed the default)
========================================================================== */
(function () {
"use strict";
function isExpanded() {
var c = document.querySelector(".body-sidebar-container");
return !!(c && c.classList.contains("expanded"));
}
function render(link) {
var expanded = isExpanded();
var iconName = expanded ? "chevrons-left" : "chevrons-right";
var label = expanded ? __("Collapse Sidebar") : __("Expand Sidebar");
var span = link.querySelector("span");
var use = link.querySelector("svg use");
if (!span || !use) {
// First render — build the DOM.
link.innerHTML =
frappe.utils.icon(iconName, "sm", "", "", "text-ink-gray-7 current-color", true) +
"<span>" + label + "</span>";
return;
}
// Update in place so the span keeps its identity and the
// max-width transition plays smoothly across collapse/expand.
use.setAttribute("href", "#icon-" + iconName);
span.textContent = label;
}
function addCollapseButton() {
if (document.querySelector(".jey-collapse-link")) return;
var anchor = window.jeySidebarAnchor && window.jeySidebarAnchor();
if (!anchor) return;
var link = document.createElement("a");
link.className = "jey-collapse-link";
render(link);
link.addEventListener("click", function () {
if (frappe.app && frappe.app.sidebar && frappe.app.sidebar.toggle_width) {
frappe.app.sidebar.toggle_width();
}
// toggle_width updates class synchronously; re-render
render(link);
});
anchor.parent.insertBefore(link, anchor.before);
// Keep label in sync if sidebar state changes elsewhere
$(document).off("sidebar-expand.jey-collapse").on("sidebar-expand.jey-collapse", function () {
var el = document.querySelector(".jey-collapse-link");
if (el) render(el);
});
}
$(document).ready(addCollapseButton);
$(document).on("page-change", addCollapseButton);
})();
/* ==========================================================================
Hide "Website" item from sidebar user dropdown
========================================================================== */
(function () {
"use strict";
function tagWebsiteItem() {
var label = __("Website");
document.querySelectorAll(".dropdown-menu-item:not(.jey-hidden-item)").forEach(function (el) {
var title = el.querySelector(".menu-item-title");
if (title && title.textContent.trim() === label) {
el.classList.add("jey-hidden-item");
}
});
}
$(document).ready(tagWebsiteItem);
var observer = new MutationObserver(tagWebsiteItem);
observer.observe(document.body, { childList: true, subtree: true });
})();
/* ==========================================================================
Form sidebar: hidden by default
Frappe shows the form sidebar for saved docs; we hide it by default and
remember the user's explicit toggle via localStorage.
========================================================================== */
(function () {
"use strict";
var STORAGE_KEY = "jey-form-sidebar"; // "hidden" | "visible"
function getPreference() {
return localStorage.getItem(STORAGE_KEY) === "visible" ? "visible" : "hidden";
}
function applyPreference(frm) {
if (!frm || !frm.sidebar || !frm.sidebar.sidebar) return;
if (frm.doc && frm.doc.__islocal) return; // Frappe already hides new docs
var wrap = frm.sidebar.sidebar.parent();
if (!wrap || !wrap.length) return;
var page_sidebar = frm.sidebar.page && frm.sidebar.page.sidebar;
if (getPreference() === "hidden") {
wrap.hide();
if (page_sidebar) page_sidebar.addClass("hide-sidebar");
} else {
wrap.show();
if (page_sidebar) page_sidebar.removeClass("hide-sidebar");
}
}
$(document).on("form-refresh", function (e, frm) {
applyPreference(frm);
});
// When user clicks "Toggle Sidebar", persist the new state
$(document.body).on("toggleSidebar", function () {
var frm = window.cur_frm;
if (!frm || !frm.sidebar || !frm.sidebar.sidebar) return;
var wrap = frm.sidebar.sidebar.parent();
if (!wrap || !wrap.length) return;
localStorage.setItem(STORAGE_KEY, wrap.is(":visible") ? "visible" : "hidden");
});
})();
/* ==========================================================================
Reload button next to Notifications in sidebar
========================================================================== */
(function () {
"use strict";
function addReloadButton() {
var notif = document.querySelector(".sidebar-notification");
if (!notif || document.querySelector(".jey-reload-btn")) return;
var btn = notif.cloneNode(true);
btn.classList.remove("sidebar-notification");
btn.classList.remove("hidden");
btn.classList.add("jey-reload-btn");
btn.removeAttribute("id");
btn.setAttribute("title", __("Reload"));
var iconWrap = btn.querySelector(".sidebar-item-icon");
if (iconWrap) {
iconWrap.setAttribute("item-icon", "rotate-ccw");
iconWrap.innerHTML = frappe.utils.icon(
"rotate-ccw", "sm", "", "", "text-ink-gray-7 current-color", true
);
}
var label = btn.querySelector(".sidebar-item-label");
if (label) label.textContent = __("Reload");
btn.addEventListener("click", function (e) {
e.preventDefault();
e.stopPropagation();
frappe.ui.toolbar.clear_cache();
});
notif.parentNode.insertBefore(btn, notif.nextSibling);
}
$(document).ready(function () {
addReloadButton();
var tries = 0;
var poll = setInterval(function () {
addReloadButton();
if (++tries > 30 || document.querySelector(".jey-reload-btn")) {
clearInterval(poll);
}
}, 300);
});
$(document).on("page-change", addReloadButton);
})();
/* ==========================================================================
Fix: nested menus left orphaned when parent menu is toggled closed
(sidebar-header app-switcher → Workspaces submenu stays on screen)
========================================================================== */
(function () {
"use strict";
function patchMenuHide() {
if (!window.frappe || !frappe.ui || !frappe.ui.menu) return false;
var proto = frappe.ui.menu.prototype;
if (proto.__jeyHidePatched) return true;
var origHide = proto.hide;
proto.hide = function () {
if (this.nested_menus && this.nested_menus.length) {
this.nested_menus.forEach(function (m) { m && m.hide && m.hide(); });
}
this.current_menu = null;
return origHide.apply(this, arguments);
};
proto.__jeyHidePatched = true;
return true;
}
if (!patchMenuHide()) {
var tries = 0;
var poll = setInterval(function () {
if (patchMenuHide() || ++tries > 50) clearInterval(poll);
}, 200);
}
})();
/* ==========================================================================
Override Frappe Theme Switcher — Jey themes
========================================================================== */
(function () {
"use strict";
var JEY_THEMES = [
{
name: "chrome",
label: "Chrome",
info: "Metallic chrome buttons with animated rings",
},
{
name: "modern",
label: "Modern",
info: "Frosted glass with soft shadows",
},
];
function getCurrentJeyTheme() {
return document.documentElement.getAttribute("data-jey-theme") || "chrome";
}
function setJeyTheme(name) {
document.documentElement.setAttribute("data-jey-theme", name);
localStorage.setItem("jey-theme", name);
// Rebuild icons with the new theme's SVGs
if (window.jeyRebuildIcons) window.jeyRebuildIcons();
}
$(document).ready(function () {
if (!frappe || !frappe.ui) return;
frappe.ui.ThemeSwitcher = class JeyThemeSwitcher {
constructor() {
this.current = getCurrentJeyTheme();
this.setup_dialog();
}
setup_dialog() {
this.dialog = new frappe.ui.Dialog({
title: __("Switch Theme"),
});
this.body = $('<div class="jey-theme-grid"></div>').appendTo(
this.dialog.$body
);
this.render();
this.bind_keys();
}
bind_keys() {
this.dialog.$wrapper.on("keydown", (e) => {
var key = frappe.ui.keys.get_key(e);
if (key === "left" || key === "right") {
var idx = JEY_THEMES.findIndex((t) => t.name === this.current);
var next = key === "right" ? idx + 1 : idx - 1;
if (JEY_THEMES[next]) this.select(JEY_THEMES[next].name);
return false;
}
if (e.keyCode === 13) { this.dialog.hide(); return false; }
});
}
render() {
var me = this;
this.body.empty();
JEY_THEMES.forEach(function (theme) {
var selected = me.current === theme.name ? "selected" : "";
var $card = $([
'<div class="jey-theme-card ' + selected + '" data-theme="' + theme.name + '">',
' <div class="jey-theme-preview jey-theme-preview--' + theme.name + '">',
' <div class="jey-theme-preview-icon"></div>',
" </div>",
' <div class="jey-theme-label">' + __(theme.label) + "</div>",
' <div class="jey-theme-info">' + __(theme.info) + "</div>",
"</div>",
].join(""));
$card.on("click", function () { me.select(theme.name); });
me.body.append($card);
});
}
select(name) {
this.current = name;
setJeyTheme(name);
this.body.find(".jey-theme-card").removeClass("selected");
this.body.find('.jey-theme-card[data-theme="' + name + '"]').addClass("selected");
frappe.show_alert(__("Theme: " + name), 3);
}
show() {
this.current = getCurrentJeyTheme();
this.render();
this.dialog.show();
}
hide() {
this.dialog.hide();
}
};
});
})();
/* ==========================================================================
Grid Column Resize — drag handles on table header columns
Uses a persistent <style> tag so widths survive Frappe DOM rebuilds.
========================================================================== */
(function () {
"use strict";
var MIN_COL_WIDTH = 40;
var STORAGE_PREFIX = "jey-col-widths:";
// Reuse the <style> element created in the init IIFE
var styleEl = document.getElementById("jey-col-resize-styles");
// Runtime widths: { "Sales Invoice:items": { item_code: 200, ... }, ... }
var allWidths = {};
function rebuildStylesheet() {
var rules = [];
Object.keys(allWidths).forEach(function (gridKey) {
var widths = allWidths[gridKey];
Object.keys(widths).forEach(function (fieldname) {
var w = widths[fieldname];
rules.push(
'[data-jey-grid="' + gridKey + '"] .grid-static-col[data-fieldname="' + fieldname + '"]' +
" { flex: 0 0 " + w + "px !important; width: " + w + "px !important; max-width: none !important; }"
);
});
});
styleEl.textContent = rules.join("\n");
}
function getGridKey(grid) {
if (grid.dataset.jeyGrid) return grid.dataset.jeyGrid;
// Build stable key: "ParentDoctype:table_fieldname"
var control = grid.closest(".frappe-control[data-fieldname]");
var tableField = control ? control.getAttribute("data-fieldname") : null;
var doctype = cur_frm ? cur_frm.doctype : null;
var key = (doctype && tableField) ? doctype + ":" + tableField : null;
if (key) {
grid.dataset.jeyGrid = key;
return key;
}
return null;
}
function saveWidths(gridKey) {
if (!gridKey || !allWidths[gridKey]) return;
localStorage.setItem(STORAGE_PREFIX + gridKey, JSON.stringify(allWidths[gridKey]));
}
function loadWidths(gridKey) {
if (!gridKey) return null;
var stored = localStorage.getItem(STORAGE_PREFIX + gridKey);
if (stored) {
try { return JSON.parse(stored); } catch (e) { return null; }
}
return null;
}
function addHandlesToGrid(grid) {
var cols = grid.querySelectorAll(
".grid-heading-row .grid-row:not(.filter-row) .grid-static-col"
);
if (!cols.length) return;
var gridKey = getGridKey(grid);
if (!gridKey) return;
// Load saved widths from localStorage on first encounter
if (!allWidths[gridKey]) {
var saved = loadWidths(gridKey);
if (saved) {
allWidths[gridKey] = saved;
rebuildStylesheet();
}
}
cols.forEach(function (col) {
if (col.querySelector(".jey-col-resize")) return;
// Don't override position:sticky on sticky columns
if (!col.classList.contains("sticky-grid-col") && !col.classList.contains("row-index")) {
col.style.position = "relative";
}
var handle = document.createElement("div");
handle.className = "jey-col-resize";
col.appendChild(handle);
handle.addEventListener("mousedown", function (e) {
e.preventDefault();
e.stopPropagation();
startResize(grid, col, e.clientX);
});
handle.addEventListener("dblclick", function (e) {
e.preventDefault();
e.stopPropagation();
autoFitColumn(grid, col);
});
});
}
function autoFitColumn(grid, headerCol) {
var fieldname = headerCol.getAttribute("data-fieldname");
var gridKey = getGridKey(grid);
if (!fieldname || !gridKey) return;
// Measure header text width
var label = headerCol.querySelector(".static-area, .sort-header, span");
var text = label ? label.textContent.trim() : fieldname;
var canvas = document.createElement("canvas");
var ctx = canvas.getContext("2d");
var style = getComputedStyle(headerCol);
ctx.font = style.fontWeight + " " + style.fontSize + " " + style.fontFamily;
var textWidth = ctx.measureText(text).width;
// Add padding (8px each side + 16px buffer for sort icon etc)
var newWidth = Math.max(MIN_COL_WIDTH, Math.ceil(textWidth) + 32);
if (!allWidths[gridKey]) allWidths[gridKey] = {};
allWidths[gridKey][fieldname] = newWidth;
rebuildStylesheet();
saveWidths(gridKey);
}
function startResize(grid, headerCol, startX) {
var fieldname = headerCol.getAttribute("data-fieldname");
if (!fieldname) return;
var gridKey = getGridKey(grid);
if (!gridKey) return;
var startWidth = headerCol.getBoundingClientRect().width;
if (!allWidths[gridKey]) allWidths[gridKey] = {};
var widths = allWidths[gridKey];
// Lock all columns that aren't already saved
grid.querySelectorAll(
".grid-heading-row .grid-row:not(.filter-row) .grid-static-col"
).forEach(function (c) {
var fn = c.getAttribute("data-fieldname");
if (fn && !widths[fn]) {
widths[fn] = c.getBoundingClientRect().width;
}
});
rebuildStylesheet();
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
function onMove(e) {
var newWidth = Math.max(MIN_COL_WIDTH, startWidth + (e.clientX - startX));
widths[fieldname] = newWidth;
rebuildStylesheet();
}
function onUp() {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
saveWidths(gridKey);
}
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}
function processGrids() {
document.querySelectorAll(".form-grid").forEach(addHandlesToGrid);
}
// Add handles when grids appear — only needs to run for new header columns
var handleTimer = null;
function scheduleHandles() {
clearTimeout(handleTimer);
handleTimer = setTimeout(processGrids, 100);
}
$(document).on("page-change", scheduleHandles);
var observer = new MutationObserver(scheduleHandles);
observer.observe(document.body, { childList: true, subtree: true });
/* ==================================================================
Column Drag & Drop Reorder
Uses Frappe's native user_settings.GridView — order is applied
by Frappe BEFORE first render, so no flash.
================================================================== */
function getGridInstance(gridEl) {
var control = gridEl.closest(".frappe-control[data-fieldname]");
if (!control || typeof cur_frm === "undefined" || !cur_frm) return null;
var fieldname = control.getAttribute("data-fieldname");
var field = cur_frm.fields_dict[fieldname];
return field && field.grid ? field.grid : null;
}
function saveColumnOrder(gridInstance, newOrder) {
if (!gridInstance.frm) return;
var settings = newOrder.map(function (fieldname) {
var vc = gridInstance.visible_columns.find(function (v) {
return v[0].fieldname === fieldname;
});
if (!vc) return null;
return {
fieldname: fieldname,
columns: vc[1],
sticky: vc[0].sticky ? 1 : 0
};
}).filter(Boolean);
var parentDoctype = gridInstance.frm.doctype;
var childDoctype = gridInstance.doctype;
// Update in-memory cache for immediate re-render
if (!frappe.model.user_settings[parentDoctype]) {
frappe.model.user_settings[parentDoctype] = {};
}
if (!frappe.model.user_settings[parentDoctype].GridView) {
frappe.model.user_settings[parentDoctype].GridView = {};
}
frappe.model.user_settings[parentDoctype].GridView[childDoctype] = settings;
// Save to server directly (bypass comparison that would skip due to cache already updated)
frappe.model.user_settings.update(parentDoctype, frappe.model.user_settings[parentDoctype]);
}
// Custom mouse-based drag (more reliable than HTML5 drag API)
var dragState = null;
function addDragListeners(gridEl) {
var cols = gridEl.querySelectorAll(
".grid-heading-row .grid-row:not(.filter-row) .grid-static-col[data-fieldname]"
);
cols.forEach(function (col) {
if (col.dataset.jeyDraggable) return;
col.dataset.jeyDraggable = "1";
col.addEventListener("mousedown", function (e) {
// Don't start drag from resize handle
if (e.target.classList.contains("jey-col-resize")) return;
// Only left button
if (e.button !== 0) return;
var fieldname = col.getAttribute("data-fieldname");
if (!fieldname) return;
dragState = {
gridEl: gridEl,
fromField: fieldname,
startX: e.clientX,
startY: e.clientY,
dragging: false
};
});
});
}
// Global mouse handlers for drag
document.addEventListener("mousemove", function (e) {
if (!dragState) return;
// Start dragging after 5px movement (prevents accidental drag on click)
if (!dragState.dragging) {
var dx = Math.abs(e.clientX - dragState.startX);
var dy = Math.abs(e.clientY - dragState.startY);
if (dx < 5 && dy < 5) return;
dragState.dragging = true;
document.body.style.cursor = "grabbing";
document.body.style.userSelect = "none";
}
// Find which header column the mouse is over
var gridEl = dragState.gridEl;
gridEl.querySelectorAll(".jey-drop-target").forEach(function (c) {
c.classList.remove("jey-drop-target");
});
var target = document.elementFromPoint(e.clientX, e.clientY);
if (target) {
var col = target.closest(".grid-heading-row .grid-static-col[data-fieldname]");
if (col && col.getAttribute("data-fieldname") !== dragState.fromField) {
// Determine direction for indicator
var headerCols = Array.from(gridEl.querySelectorAll(
".grid-heading-row .grid-row:not(.filter-row) .grid-static-col[data-fieldname]"
));
var fi = headerCols.findIndex(function (c) { return c.getAttribute("data-fieldname") === dragState.fromField; });
var ti = headerCols.indexOf(col);
col.classList.add("jey-drop-target");
col.classList.toggle("jey-drop-right", fi < ti);
col.classList.toggle("jey-drop-left", fi >= ti);
dragState.toField = col.getAttribute("data-fieldname");
} else {
dragState.toField = null;
}
}
});
document.addEventListener("mouseup", function () {
if (!dragState) return;
var state = dragState;
dragState = null;
document.body.style.cursor = "";
document.body.style.userSelect = "";
state.gridEl.querySelectorAll(".jey-drop-target").forEach(function (c) {
c.classList.remove("jey-drop-target");
});
if (!state.dragging || !state.toField) return;
var gridInstance = getGridInstance(state.gridEl);
if (!gridInstance) return;
// Build new order
var headerCols = state.gridEl.querySelectorAll(
".grid-heading-row .grid-row:not(.filter-row) .grid-static-col[data-fieldname]"
);
var order = [];
headerCols.forEach(function (c) { order.push(c.getAttribute("data-fieldname")); });
var fromIdx = order.indexOf(state.fromField);
var toIdx = order.indexOf(state.toField);
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return;
var draggingRight = fromIdx < toIdx;
// Remove dragged column
order.splice(fromIdx, 1);
// Find target in modified array
var newToIdx = order.indexOf(state.toField);
if (newToIdx === -1) return;
// Dragging right → insert AFTER target, left → insert BEFORE
order.splice(draggingRight ? newToIdx + 1 : newToIdx, 0, state.fromField);
saveColumnOrder(gridInstance, order);
gridInstance.reset_grid();
});
function processDragGrids() {
document.querySelectorAll(".form-grid").forEach(addDragListeners);
}
var dragTimer = null;
function scheduleDrag() {
clearTimeout(dragTimer);
dragTimer = setTimeout(processDragGrids, 150);
}
$(document).on("page-change", scheduleDrag);
var dragObserver = new MutationObserver(scheduleDrag);
dragObserver.observe(document.body, { childList: true, subtree: true });
})();
/* ==========================================================================
List View Column Resize + Reorder
Drag the right edge of a list header to resize; drag the header body to
reorder. Widths + order persist in localStorage per doctype:
jey-list-col-widths:<Doctype> { fieldname: pxWidth, ... }
jey-list-col-order:<Doctype> [fieldname, fieldname, ...]
========================================================================== */
(function () {
"use strict";
var MIN_COL_WIDTH = 60;
var WIDTH_PREFIX = "jey-list-col-widths:";
var ORDER_PREFIX = "jey-list-col-order:";
var DRAG_THRESHOLD = 5;
// Classnames that appear on .list-row-col but are NOT fieldnames.
var NON_FIELD_CLASSES = {
"list-row-col": 1, "ellipsis": 1, "hidden-xs": 1, "text-right": 1,
"list-subject": 1, "level": 1, "level-item": 1,
"tag-col": 1, "hide": 1, "bold": 1, "seen": 1, "notseen": 1,
"inner-group-button": 1
};
var styleEl = document.getElementById("jey-col-resize-styles");
// { "Sales Invoice": { customer: 220, ... }, ... }
var allListWidths = {};
// Tracks doctypes already initialised from localStorage during this session
var loaded = {};
function escapeClass(fn) {
return (window.CSS && CSS.escape) ? CSS.escape(fn) : fn.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
}
// Rebuild only the list-scoped portion of the shared stylesheet.
// Grid rules live inside their own <style> section? No — they share this element.
// To avoid trashing grid rules, we keep a dedicated second stylesheet for lists.
var listStyleEl = document.getElementById("jey-list-col-styles");
if (!listStyleEl) {
listStyleEl = document.createElement("style");
listStyleEl.id = "jey-list-col-styles";
document.head.appendChild(listStyleEl);
}
// FOUC-only stylesheet: used before JS has had a chance to stamp inline
// styles on each cell. Targets header cells via their native fieldname
// class (Frappe sets that from the first paint). Runtime width locking
// happens through applyWidthsToCells(), which writes inline !important
// styles by position — that's the one that has to win against Frappe's
// apply_column_widths() and the inherent flex weirdness of .level-left.
function rebuildListStylesheet() {
var rules = [];
Object.keys(allListWidths).forEach(function (doctype) {
var widths = allListWidths[doctype];
Object.keys(widths).forEach(function (fn) {
var w = widths[fn];
var esc = escapeClass(fn);
rules.push(
'.list-view[data-jey-list="' + doctype + '"] .list-row-col.' + esc +
" { flex: 0 0 " + w + "px !important; width: " + w +
"px !important; max-width: none !important; min-width: 0 !important; }"
);
});
});
listStyleEl.textContent = rules.join("\n");
}
function setCellWidthStyle(cell, w) {
cell.style.setProperty("flex", "0 0 " + w + "px", "important");
cell.style.setProperty("width", w + "px", "important");
cell.style.setProperty("max-width", "none", "important");
cell.style.setProperty("min-width", "0", "important");
}
// Freeze EVERY cell (header + data rows) at a fixed width. Saved width
// wins if we have one for the column's fieldname; otherwise we use the
// cell's current rendered width.
//
// Freezing unconditionally — even columns we can't identify a fieldname
// for (Status without a docfield, Button columns, etc.) — is essential:
// otherwise one flex-shrinkable column absorbs all of another column's
// growth and you get "right edge doesn't move, left edge slides left".
function applyWidthsToCells(listview) {
var root = listview.$result && listview.$result[0];
if (!root) return;
var saved = allListWidths[listview.doctype] || {};
var headerCells = root.querySelectorAll(".list-row-head .level-left > .list-row-col");
if (!headerCells.length) return;
// Pass 1: decide each column's target width.
var widths = [];
var fieldOrder = [];
headerCells.forEach(function (col) {
var fn = fieldnameOf(col);
fieldOrder.push(fn);
var w;
if (fn && saved[fn] != null) {
w = saved[fn];
} else {
w = Math.round(col.getBoundingClientRect().width);
}
widths.push(w);
});
// Pass 2: apply. Header first.
headerCells.forEach(function (col, idx) {
if (widths[idx] > 0) setCellWidthStyle(col, widths[idx]);
});
// Then every data row, matched to header by position.
root.querySelectorAll(".list-row:not(.list-row-head) .level-left").forEach(function (rowLeft) {
var cells = rowLeft.querySelectorAll(":scope > .list-row-col");
cells.forEach(function (cell, idx) {
if (widths[idx] > 0) setCellWidthStyle(cell, widths[idx]);
});
});
}
// During drag, only the active column's cells change. Update just those —
// same O(rows) cost as the full apply, but narrower scope.
function applyActiveColumnWidth(listview, fn, w) {
if (!fn) return;
var root = listview.$result && listview.$result[0];
if (!root) return;
var headerCells = root.querySelectorAll(".list-row-head .level-left > .list-row-col");
var activeIdx = -1;
for (var i = 0; i < headerCells.length; i++) {
if (fieldnameOf(headerCells[i]) === fn) { activeIdx = i; break; }
}
if (activeIdx === -1) return;
setCellWidthStyle(headerCells[activeIdx], w);
root.querySelectorAll(".list-row:not(.list-row-head) .level-left").forEach(function (rowLeft) {
var cells = rowLeft.querySelectorAll(":scope > .list-row-col");
if (cells[activeIdx]) setCellWidthStyle(cells[activeIdx], w);
});
}
function loadWidths(doctype) {
if (loaded[doctype]) return allListWidths[doctype] || null;
loaded[doctype] = true;
try {
var raw = localStorage.getItem(WIDTH_PREFIX + doctype);
if (raw) {
var parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
allListWidths[doctype] = parsed;
rebuildListStylesheet();
return parsed;
}
}
} catch (e) {}
return null;
}
function saveWidths(doctype) {
if (!doctype || !allListWidths[doctype]) return;
localStorage.setItem(WIDTH_PREFIX + doctype, JSON.stringify(allListWidths[doctype]));
}
function loadOrder(doctype) {
try {
var raw = localStorage.getItem(ORDER_PREFIX + doctype);
if (raw) {
var parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed;
}
} catch (e) {}
return null;
}
function saveOrder(doctype, order) {
localStorage.setItem(ORDER_PREFIX + doctype, JSON.stringify(order));
}
function fieldnameOf(colEl) {
// Prefer the stamped attribute (covers Status/Tag/etc cells that
// ship without a fieldname class).
if (colEl.dataset && colEl.dataset.jeyField) return colEl.dataset.jeyField;
// Headers always carry <span data-sort-by="<fieldname>">.
var sortSpan = colEl.querySelector && colEl.querySelector("[data-sort-by]");
if (sortSpan) {
var fn = sortSpan.getAttribute("data-sort-by");
if (fn) return fn;
}
// Fallback: first non-structural class.
var classes = (colEl.className || "").split(/\s+/);
for (var i = 0; i < classes.length; i++) {
var c = classes[i];
if (c && !NON_FIELD_CLASSES[c] && c.indexOf("jey-") !== 0) return c;
}
return null;
}
// Stamp data-jey-field on every column cell (header + data rows) so the
// rest of the module can target columns uniformly. Frappe ships some
// built-in columns (Tag, Status, Button, …) without a fieldname class
// or a data-sort-by span — we assign a synthetic position-based key
// so those still participate in resize and reorder.
function stampFieldAttributes(listview) {
var $result = listview.$result;
if (!$result || !$result.length) return;
var root = $result[0];
var headerLeft = root.querySelector(".list-row-head .level-left");
if (!headerLeft) return;
var headerCells = headerLeft.querySelectorAll(":scope > .list-row-col");
var fieldOrder = [];
headerCells.forEach(function (col, idx) {
var fn = fieldnameOf(col);
if (!fn) fn = "__jey_col_" + idx + "__";
col.dataset.jeyField = fn;
fieldOrder.push(fn);
});
root.querySelectorAll(".list-row:not(.list-row-head) .level-left").forEach(function (rowLeft) {
var cells = rowLeft.querySelectorAll(":scope > .list-row-col");
cells.forEach(function (cell, idx) {
var fn = fieldOrder[idx];
if (fn) cell.dataset.jeyField = fn;
});
});
}
function isSubject(colEl) {
return colEl.classList.contains("list-subject");
}
function getListContainer(listview) {
var root = listview && listview.$result ? listview.$result[0] : null;
if (!root) return null;
return root.closest(".list-view");
}
function applyOrder(listview) {
var doctype = listview.doctype;
var order = loadOrder(doctype);
if (!order || !order.length) return;
var $result = listview.$result;
if (!$result || !$result.length) return;
// Header first, then every data row.
var groups = [];
var headerLeft = $result[0].querySelector(".list-row-head .level-left");
if (headerLeft) groups.push(headerLeft);
$result[0].querySelectorAll(".list-row:not(.list-row-head) .level-left").forEach(function (g) {
groups.push(g);
});
groups.forEach(function (group) {
var byField = {};
group.querySelectorAll(":scope > .list-row-col").forEach(function (col) {
var fn = fieldnameOf(col);
if (fn) byField[fn] = col;
});
// Append in saved order; subject column stays where it is (skipped).
order.forEach(function (fn) {
var node = byField[fn];
if (node && !isSubject(node)) group.appendChild(node);
});
});
}
function buildCurrentOrder(listview) {
var $result = listview.$result;
if (!$result || !$result.length) return [];
var header = $result[0].querySelector(".list-row-head .level-left");
if (!header) return [];
var order = [];
header.querySelectorAll(":scope > .list-row-col").forEach(function (col) {
if (isSubject(col)) return;
var fn = fieldnameOf(col);
if (fn) order.push(fn);
});
return order;
}
function injectResizeHandles(listview) {
var $result = listview.$result;
if (!$result || !$result.length) return;
var headerCols = $result[0].querySelectorAll(".list-row-head .level-left > .list-row-col");
var doctype = listview.doctype;
headerCols.forEach(function (col) {
// Subject gets a resize handle too (right edge, well away from
// the checkbox on the left) — its content often outruns Frappe's
// auto-calculated width and users need to widen it.
if (col.querySelector(".jey-col-resize")) return;
var handle = document.createElement("div");
handle.className = "jey-col-resize";
col.appendChild(handle);
handle.addEventListener("mousedown", function (e) {
e.preventDefault();
e.stopPropagation();
startResize(listview, col, e.clientX);
});
handle.addEventListener("dblclick", function (e) {
e.preventDefault();
e.stopPropagation();
autoFit(listview, col);
});
});
}
function autoFit(listview, headerCol) {
var doctype = listview.doctype;
var fn = fieldnameOf(headerCol);
if (!fn) return;
var label = headerCol.querySelector("span, a, .list-subject");
var text = label ? label.textContent.trim() : fn;
var canvas = document.createElement("canvas");
var ctx = canvas.getContext("2d");
var style = getComputedStyle(headerCol);
ctx.font = style.fontWeight + " " + style.fontSize + " " + style.fontFamily;
var w = Math.max(MIN_COL_WIDTH, Math.ceil(ctx.measureText(text).width) + 40);
if (!allListWidths[doctype]) allListWidths[doctype] = {};
allListWidths[doctype][fn] = w;
applyActiveColumnWidth(listview, fn, w);
rebuildListStylesheet();
saveWidths(doctype);
}
function startResize(listview, headerCol, startX) {
var doctype = listview.doctype;
var fn = fieldnameOf(headerCol);
if (!fn) return;
var startWidth = headerCol.getBoundingClientRect().width;
if (!allListWidths[doctype]) allListWidths[doctype] = {};
var widths = allListWidths[doctype];
// Freeze every header column at its current rendered width so only the
// dragged one moves. Includes .list-subject — it has flex-shrink:1
// from list.scss and would otherwise compress while another column
// grows, visually anchoring the dragged column's right edge.
var cols = listview.$result[0].querySelectorAll(".list-row-head .level-left > .list-row-col");
cols.forEach(function (c) {
var f = fieldnameOf(c);
if (f && !widths[f]) {
widths[f] = Math.round(c.getBoundingClientRect().width);
}
});
applyWidthsToCells(listview);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
function onMove(e) {
var w = Math.max(MIN_COL_WIDTH, Math.round(startWidth + (e.clientX - startX)));
widths[fn] = w;
applyActiveColumnWidth(listview, fn, w);
}
function onUp() {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
rebuildListStylesheet();
saveWidths(doctype);
}
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}
/* -------- Drag to reorder -------- */
var dragState = null;
function bindReorder(listview) {
var $result = listview.$result;
if (!$result || !$result.length) return;
var headerCols = $result[0].querySelectorAll(".list-row-head .level-left > .list-row-col");
headerCols.forEach(function (col) {
if (isSubject(col)) return;
if (col.dataset.jeyDraggable) return;
col.dataset.jeyDraggable = "1";
col.addEventListener("mousedown", function (e) {
if (e.target.classList.contains("jey-col-resize")) return;
if (e.button !== 0) return;
var fn = fieldnameOf(col);
if (!fn) return;
dragState = {
listview: listview,
fromField: fn,
startX: e.clientX,
startY: e.clientY,
dragging: false,
toField: null
};
});
});
}
document.addEventListener("mousemove", function (e) {
if (!dragState) return;
if (!dragState.dragging) {
var dx = Math.abs(e.clientX - dragState.startX);
var dy = Math.abs(e.clientY - dragState.startY);
if (dx < DRAG_THRESHOLD && dy < DRAG_THRESHOLD) return;
dragState.dragging = true;
document.body.style.cursor = "grabbing";
document.body.style.userSelect = "none";
}
var root = dragState.listview.$result[0];
root.querySelectorAll(".list-row-head .jey-drop-target").forEach(function (c) {
c.classList.remove("jey-drop-target", "jey-drop-left", "jey-drop-right");
});
var target = document.elementFromPoint(e.clientX, e.clientY);
if (!target) return;
var col = target.closest(".list-row-head .level-left > .list-row-col");
if (!col || !root.contains(col) || isSubject(col)) {
dragState.toField = null;
return;
}
var fn = fieldnameOf(col);
if (!fn || fn === dragState.fromField) {
dragState.toField = null;
return;
}
var headerCols = Array.from(root.querySelectorAll(".list-row-head .level-left > .list-row-col"));
var fi = headerCols.findIndex(function (c) { return fieldnameOf(c) === dragState.fromField; });
var ti = headerCols.indexOf(col);
col.classList.add("jey-drop-target");
col.classList.toggle("jey-drop-right", fi < ti);
col.classList.toggle("jey-drop-left", fi >= ti);
dragState.toField = fn;
});
document.addEventListener("mouseup", function () {
if (!dragState) return;
var state = dragState;
dragState = null;
document.body.style.cursor = "";
document.body.style.userSelect = "";
var root = state.listview.$result ? state.listview.$result[0] : null;
if (root) {
root.querySelectorAll(".list-row-head .jey-drop-target").forEach(function (c) {
c.classList.remove("jey-drop-target", "jey-drop-left", "jey-drop-right");
});
}
if (!state.dragging || !state.toField) return;
var order = buildCurrentOrder(state.listview);
var fromIdx = order.indexOf(state.fromField);
var toIdx = order.indexOf(state.toField);
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return;
var draggingRight = fromIdx < toIdx;
order.splice(fromIdx, 1);
var newToIdx = order.indexOf(state.toField);
if (newToIdx === -1) return;
order.splice(draggingRight ? newToIdx + 1 : newToIdx, 0, state.fromField);
saveOrder(state.listview.doctype, order);
applyOrder(state.listview);
});
/* -------- Wire into ListView render pipeline -------- */
function onListRender(listview) {
if (!listview || listview.view_name !== "List") return;
var container = getListContainer(listview);
if (container) container.dataset.jeyList = listview.doctype;
loadWidths(listview.doctype);
stampFieldAttributes(listview);
applyOrder(listview);
applyWidthsToCells(listview);
injectResizeHandles(listview);
bindReorder(listview);
}
function wrapRenderList() {
if (!window.frappe || !frappe.views || !frappe.views.ListView) return false;
var proto = frappe.views.ListView.prototype;
if (proto.__jeyRenderListWrapped) return true;
proto.__jeyRenderListWrapped = true;
var originalRender = proto.render_list;
proto.render_list = function () {
var result = originalRender.apply(this, arguments);
try { onListRender(this); } catch (e) { /* noop */ }
return result;
};
return true;
}
// ListView is only loaded when the user first opens a list — poll briefly.
if (!wrapRenderList()) {
var attempts = 0;
var iv = setInterval(function () {
attempts++;
if (wrapRenderList() || attempts > 100) clearInterval(iv);
}, 200);
}
})();
/* ==========================================================================
List View — Override SVG sprite icons
Replaces <symbol> content in Frappe's SVG sprite sheets so every
<use href="#icon-..."> picks up the new artwork automatically.
========================================================================== */
(function () {
"use strict";
var SPRITE_OVERRIDES = {
/* icon-list (Lucide, 24×24, stroke) — 2×2 grid of squares */
"icon-list":
'<rect x="3" y="3" width="8" height="8" rx="1.5"/>' +
'<rect x="13" y="3" width="8" height="8" rx="1.5"/>' +
'<rect x="3" y="13" width="8" height="8" rx="1.5"/>' +
'<rect x="13" y="13" width="8" height="8" rx="1.5"/>',
/* icon-sort-descending (Timeless, 16×16, stroke) — vertical bar chart tall→short */
"icon-sort-descending":
'<path d="M3 13V3M6.33 13V5.5M9.67 13V8M13 13V10.5" ' +
'stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>',
/* icon-sort-ascending (Timeless, 16×16, stroke) — vertical bar chart short→tall */
"icon-sort-ascending":
'<path d="M3 13V10.5M6.33 13V8M9.67 13V5.5M13 13V3" ' +
'stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>',
/* es-line-filter (Espresso, 16×16, fill) — solid funnel shape */
"es-line-filter":
'<g class="es-line-filter">' +
'<path d="M1.5 2.5h13L10 8v3.5H6V8L1.5 2.5z"/>' +
'</g>',
/* icon-home — simple house silhouette */
"icon-home":
'<path d="M12 1L0 12h4v10h7v-7h2v7h7V12h4z"/>'
};
var totalIcons = Object.keys(SPRITE_OVERRIDES).length;
function overrideSprites() {
var replaced = 0;
for (var id in SPRITE_OVERRIDES) {
var symbol = document.getElementById(id);
if (symbol && symbol.tagName.toLowerCase() === "symbol") {
symbol.innerHTML = SPRITE_OVERRIDES[id];
replaced++;
}
}
return replaced;
}
function tryOverride() {
if (overrideSprites() >= totalIcons) return;
var attempts = 0;
var timer = setInterval(function () {
if (overrideSprites() >= totalIcons || ++attempts > 20) {
clearInterval(timer);
}
}, 200);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", tryOverride);
} else {
tryOverride();
}
})();
/* ==========================================================================
Rebrand: ERPNext → Jey ERP, Frappe → Framework (visible text only)
========================================================================== */
(function () {
"use strict";
var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, IFRAME: 1, CANVAS: 1 };
/* Case-aware replacements: tries to preserve the surrounding case style */
function rebrandText(str) {
// ERPNext variants
str = str.replace(/ERPNext/g, "Jey ERP");
str = str.replace(/erpnext/gi, function (m) {
if (m === "ERPNEXT") return "JEY ERP";
return "Jey ERP";
});
// Frappe variants (but not inside URLs or identifiers)
str = str.replace(/Frapp[eé]/g, "Jey ERP");
str = str.replace(/frapp[eé]/gi, function (m) {
if (m.toUpperCase() === m) return "JEY ERP";
return "Jey ERP";
});
return str;
}
function needsRebrand(str) {
return /erpnext|frapp[eé]/i.test(str);
}
/* Walk text nodes inside an element */
function walkTextNodes(root) {
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
var node;
while ((node = walker.nextNode())) {
if (SKIP_TAGS[node.parentNode.tagName]) continue;
var text = node.nodeValue;
if (needsRebrand(text)) {
node.nodeValue = rebrandText(text);
}
}
}
/* Rebrand visible attributes: placeholder, title, alt */
function walkAttributes(root) {
var els = root.querySelectorAll("[placeholder], [title], [alt]");
for (var i = 0; i < els.length; i++) {
var el = els[i];
["placeholder", "title", "alt"].forEach(function (attr) {
var val = el.getAttribute(attr);
if (val && needsRebrand(val)) {
el.setAttribute(attr, rebrandText(val));
}
});
}
}
/* Rebrand the page title */
function rebrandTitle() {
if (needsRebrand(document.title)) {
document.title = rebrandText(document.title);
}
}
function processNode(root) {
walkTextNodes(root);
walkAttributes(root);
rebrandTitle();
}
/* Initial pass */
function init() {
processNode(document.body);
rebrandTitle();
/* Watch for dynamically added content */
var observer = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var m = mutations[i];
for (var j = 0; j < m.addedNodes.length; j++) {
var node = m.addedNodes[j];
if (node.nodeType === 1 && !SKIP_TAGS[node.tagName]) {
processNode(node);
} else if (node.nodeType === 3 && node.parentNode && !SKIP_TAGS[node.parentNode.tagName] && needsRebrand(node.nodeValue)) {
node.nodeValue = rebrandText(node.nodeValue);
}
}
/* Handle direct text changes (e.g. frappe sets .textContent) */
if (m.type === "characterData" && m.target.nodeType === 3) {
if (m.target.parentNode && !SKIP_TAGS[m.target.parentNode.tagName] && needsRebrand(m.target.nodeValue)) {
m.target.nodeValue = rebrandText(m.target.nodeValue);
}
}
}
rebrandTitle();
});
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
/* ==========================================================================
Form Tabs — Re-trigger section cascade on tab switch
========================================================================== */
(function () {
document.addEventListener("click", function (e) {
var tab = e.target.closest(".form-tabs .nav-link");
if (!tab) return;
// Small delay so Frappe shows the tab content first
setTimeout(function () {
var sections = document.querySelectorAll(".form-layout .form-section .section-head, .form-layout .form-section .section-body");
sections.forEach(function (s) {
s.style.animation = "none";
// Force reflow then restart animation
void s.offsetHeight;
s.style.animation = "";
});
}, 20);
});
})();
/* ==========================================================================
Form Sections — Set exact max-height for smooth collapse animation
========================================================================== */
(function () {
document.addEventListener("click", function (e) {
var head = e.target.closest(".section-head.collapsible");
if (!head) return;
var body = head.nextElementSibling;
if (!body || !body.classList.contains("section-body")) return;
// Set exact max-height so transition has a real start/end value
body.style.maxHeight = body.scrollHeight + "px";
// Clip overflow during the transition so expanding content doesn't
// spill out before the section has grown to fit it. Removed at
// transitionend so Link-field dropdowns can escape section bounds.
body.style.overflow = "hidden";
// Force reflow — browser must register this value before Frappe toggles .collapsed
void body.offsetHeight;
}, true);
document.addEventListener("transitionend", function (e) {
var body = e.target;
if (!body.classList || !body.classList.contains("section-body")) return;
if (e.propertyName !== "max-height") return;
var head = body.previousElementSibling;
if (!head || !head.classList.contains("collapsible")) return;
// After expand: drop the inline overflow so dropdowns can escape.
// After collapse: the `.collapsed + .section-body` CSS rule keeps
// overflow hidden, so clearing the inline value is still safe.
body.style.overflow = "";
}, true);
})();
/* ==========================================================================
Search Modal — smooth close (backdrop cleanup)
========================================================================== */
// Bootstrap leaves the backdrop when we override display.
// Remove it manually when .show is removed.
(function () {
var searchModal = null;
var observer = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var m = mutations[i];
if (m.attributeName === "class" && searchModal) {
if (!searchModal.classList.contains("show") && searchModal.dataset.jeyWasOpen) {
delete searchModal.dataset.jeyWasOpen;
// After transition ends, clean up backdrop
setTimeout(function () {
var bd = document.querySelector(".modal-backdrop");
if (bd) bd.remove();
document.body.classList.remove("modal-open");
document.body.style.removeProperty("padding-right");
}, 550);
}
if (searchModal.classList.contains("show")) {
searchModal.dataset.jeyWasOpen = "1";
// Re-focus input after CSS transition so awesomplete opens
var input = searchModal.querySelector("#navbar-search");
if (input) {
setTimeout(function () {
input.blur();
input.focus();
}, 550);
}
}
}
}
});
// Find search modal and start observing
var bodyObserver = new MutationObserver(function () {
if (searchModal) return;
var el = document.querySelector(".modal:has(.cool-awesomebar-modal-footer)");
if (!el) return;
searchModal = el;
bodyObserver.disconnect();
observer.observe(searchModal, { attributes: true, attributeFilter: ["class"] });
// If .show is already present (race condition on first open), trigger focus now
if (searchModal.classList.contains("show")) {
searchModal.dataset.jeyWasOpen = "1";
var input = searchModal.querySelector("#navbar-search");
if (input) {
setTimeout(function () {
input.blur();
input.focus();
}, 550);
}
}
});
bodyObserver.observe(document.body, { childList: true, subtree: true });
})();
/* ==========================================================================
Dropdown fade-out — freeze Popper position during hide animation
Bootstrap removes .show and then Popper.destroy() wipes the menu's
inline transform/top/left, so a CSS transition would fade the menu at
(0,0) instead of its visible spot. On hide.bs.dropdown we snapshot the
computed position, then re-apply it in a microtask (after Bootstrap's
synchronous cleanup) together with the .jey-dropdown-hiding class that
plays the opacity animation. Cleanup on animation end or next show.
========================================================================== */
(function () {
if (typeof window.jQuery === "undefined") return;
function cleanup(menu) {
menu.classList.remove("jey-dropdown-hiding");
menu.style.removeProperty("transform");
menu.style.removeProperty("top");
menu.style.removeProperty("left");
menu.style.removeProperty("position");
if (menu._jeyHideTimer) {
clearTimeout(menu._jeyHideTimer);
menu._jeyHideTimer = null;
}
}
jQuery(document).on("hide.bs.dropdown", function (e) {
var menu = e.target && e.target.querySelector(".dropdown-menu.show");
if (!menu) return;
var cs = getComputedStyle(menu);
var transform = cs.transform;
var top = cs.top;
var left = cs.left;
var position = cs.position;
Promise.resolve().then(function () {
menu.style.setProperty("transform", transform, "important");
menu.style.setProperty("top", top, "important");
menu.style.setProperty("left", left, "important");
menu.style.setProperty("position", position, "important");
menu.classList.add("jey-dropdown-hiding");
menu._jeyHideTimer = setTimeout(function () {
cleanup(menu);
}, 220);
});
});
// If the dropdown reopens before fade-out finishes, clear our frozen
// styles so Popper can position it fresh.
jQuery(document).on("show.bs.dropdown", function (e) {
var menu = e.target && e.target.querySelector(".dropdown-menu");
if (!menu) return;
cleanup(menu);
});
})();
/* ==========================================================================
Awesomplete (link field) fade-out
Awesomplete.close() just toggles the [hidden] attribute, so the listbox
vanishes with no transition. On awesomplete-close we tag the ul with
.jey-awesomplete-hiding to play a fade-out animation, and clear the tag
on re-open (or after the animation finishes) so the in-animation can
play cleanly next time.
========================================================================== */
(function () {
if (typeof window.jQuery === "undefined") return;
function findListbox(input) {
var wrapper = input && input.closest && input.closest(".awesomplete");
return wrapper ? wrapper.querySelector('ul[role="listbox"]') : null;
}
jQuery(document).on("awesomplete-close", function (e) {
var ul = findListbox(e.target);
if (!ul) return;
ul.classList.add("jey-awesomplete-hiding");
if (ul._jeyHideTimer) clearTimeout(ul._jeyHideTimer);
ul._jeyHideTimer = setTimeout(function () {
ul.classList.remove("jey-awesomplete-hiding");
ul._jeyHideTimer = null;
}, 220);
});
jQuery(document).on("awesomplete-open", function (e) {
var ul = findListbox(e.target);
if (!ul) return;
ul.classList.remove("jey-awesomplete-hiding");
if (ul._jeyHideTimer) {
clearTimeout(ul._jeyHideTimer);
ul._jeyHideTimer = null;
}
});
})();
/* ==========================================================================
Sprite icon swaps
Frappe hard-codes #icon-heart for the "like" action and #es-line-chat-alt
for the comment count in list/form templates. Rewrite the <use href=...>
on render so lists/forms show a star and a comment bubble instead.
For the comment icon we inject our own sprite symbol (#jey-icon-comment)
because Frappe's bundled #icon-message-square-text is the newer lucide
variant with 3 text lines, and we want the older 2-line version.
========================================================================== */
(function () {
var SWAPS = {
"#icon-heart": "#icon-star",
"#es-line-chat-alt": "#jey-icon-comment",
};
function injectSprite() {
if (document.getElementById("jey-icon-sprite")) return;
var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.id = "jey-icon-sprite";
svg.setAttribute("width", "0");
svg.setAttribute("height", "0");
svg.setAttribute("aria-hidden", "true");
svg.style.position = "absolute";
svg.innerHTML =
'<symbol viewBox="0 0 24 24" id="jey-icon-comment">' +
'<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>' +
'<path d="M13 8H7"/>' +
'<path d="M17 12H7"/>' +
'</symbol>';
(document.body || document.documentElement).insertBefore(
svg, (document.body || document.documentElement).firstChild
);
}
function swapIn(root) {
if (!root || root.nodeType !== 1) return;
Object.keys(SWAPS).forEach(function (from) {
var to = SWAPS[from];
// href= and xlink:href= both appear in the wild
var nodes = root.querySelectorAll(
'use[href="' + from + '"], use[*|href="' + from + '"]'
);
nodes.forEach(function (u) {
if (u.getAttribute("href") === from) u.setAttribute("href", to);
if (u.getAttributeNS("http://www.w3.org/1999/xlink", "href") === from) {
u.setAttributeNS("http://www.w3.org/1999/xlink", "href", to);
}
});
});
}
function init() {
injectSprite();
swapIn(document.body);
var obs = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var m = mutations[i];
if (m.type === "childList") {
m.addedNodes.forEach(function (n) {
if (n.nodeType !== 1) return;
// If the added node itself is a <use>, handle it directly
if (n.tagName && n.tagName.toLowerCase() === "use") {
var href = n.getAttribute("href");
if (href && SWAPS[href]) n.setAttribute("href", SWAPS[href]);
}
swapIn(n);
});
} else if (m.type === "attributes" && m.target && m.target.tagName &&
m.target.tagName.toLowerCase() === "use") {
var href2 = m.target.getAttribute("href");
if (href2 && SWAPS[href2]) m.target.setAttribute("href", SWAPS[href2]);
}
}
});
obs.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["href", "xlink:href"],
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();