@eintrek/erp-shell 0.1.55 → 0.1.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -722,46 +722,70 @@ function filterNavigationModulesByPermission(modules, rulesMap, userChildSegment
722
722
  })
723
723
  .filter((m) => m !== null);
724
724
  }
725
+ /** The path below the org code: "/O1/project/budgets" → "/project/budgets". */
726
+ function pathBelowCode(pathname) {
727
+ const segments = pathname.split("?")[0].split("/").filter(Boolean);
728
+ return segments.length > 1 ? "/" + segments.slice(1).join("/") : "/";
729
+ }
725
730
  /**
726
- * Find the active top-level module id from `pathname`. Strips the org-code
727
- * prefix from the path (first path segment) before matching.
731
+ * Whether `url` (relative, no org code) covers `path`: the same page, or a
732
+ * page below it on a segment boundary. "/" covers only the home itself and a
733
+ * query-only URL covers nothing.
728
734
  */
729
- function getActiveModule(pathname, modules) {
730
- const segments = pathname.split("/").filter(Boolean);
731
- const pathWithoutCode = segments.length > 1 ? "/" + segments.slice(1).join("/") : "/";
735
+ function coversPath(url, path) {
736
+ if (!url || url.includes("?") || /^https?:\/\//i.test(url))
737
+ return false;
738
+ if (url === "/")
739
+ return path === "/";
740
+ const clean = url.replace(/\/+$/, "");
741
+ return path === clean || path.startsWith(clean + "/");
742
+ }
743
+ /**
744
+ * The module and page whose URL is the longest one covering the path, over
745
+ * every module — a module's own URL, its pages and their sub-pages. Equal
746
+ * lengths go to the first in menu order.
747
+ */
748
+ function longestMatch(path, modules) {
749
+ let best = null;
750
+ const consider = (module, sub, url) => {
751
+ if (!coversPath(url, path))
752
+ return;
753
+ if (!best || url.length > best.length) {
754
+ best = { module, sub, length: url.length };
755
+ }
756
+ };
732
757
  for (const navModule of modules) {
733
- if (pathWithoutCode === navModule.url || pathname.endsWith(navModule.url)) {
734
- return navModule.id;
735
- }
736
- if (navModule.subModules) {
737
- for (const subModule of navModule.subModules) {
738
- if (pathWithoutCode.startsWith(subModule.url) ||
739
- pathname.includes(subModule.url)) {
740
- return navModule.id;
741
- }
742
- }
758
+ for (const sub of navModule.subModules ?? []) {
759
+ consider(navModule, sub, sub.url);
760
+ for (const page of sub.subPages ?? [])
761
+ consider(navModule, sub, page.url);
743
762
  }
763
+ // After its pages: on a tie the page is the more specific answer.
764
+ consider(navModule, null, navModule.url);
744
765
  }
745
- return null;
766
+ const found = best;
767
+ return found ? { module: found.module, sub: found.sub } : null;
768
+ }
769
+ /**
770
+ * Find the active top-level module id from `pathname`: the module whose URL
771
+ * (or one of whose pages' URLs) is the longest prefix of the path below the
772
+ * org code.
773
+ *
774
+ * Before 0.1.57 this took the first module with a page URL anywhere in the
775
+ * path (`includes`), so "/project" claimed /project/schedule and the sidebar,
776
+ * the trail and the tabs could disagree about where the user was.
777
+ */
778
+ function getActiveModule(pathname, modules) {
779
+ return longestMatch(pathBelowCode(pathname), modules)?.module.id ?? null;
746
780
  }
747
- /** Find the active sub-module id from `pathname`. */
781
+ /** Find the active sub-module id from `pathname`, by the same rule. */
748
782
  function getActiveSubModule(pathname, modules) {
749
- const segments = pathname.split("/").filter(Boolean);
750
- const pathWithoutCode = segments.length > 1 ? "/" + segments.slice(1).join("/") : "/";
751
- for (const navModule of modules) {
752
- if (navModule.subModules) {
753
- for (const subModule of navModule.subModules) {
754
- if (pathWithoutCode.startsWith(subModule.url) ||
755
- pathname.includes(subModule.url)) {
756
- return subModule.id;
757
- }
758
- }
759
- }
760
- }
761
- return null;
783
+ // Null when the module's own URL is the best match: no page of it is.
784
+ return longestMatch(pathBelowCode(pathname), modules)?.sub?.id ?? null;
762
785
  }
763
786
  /** Prepend `code` (org code) to a relative URL so `/sales` → `/O25.../sales`.
764
787
  * Absolute `http(s)://` URLs are left intact; optional `{code}` placeholder is replaced.
788
+ * Query/hash are preserved: `/?start=1` → `/O25...?start=1` (not `/O25.../?start=1`).
765
789
  */
766
790
  function buildUrlWithCode(code, url) {
767
791
  if (/^https?:\/\//i.test(url)) {
@@ -771,8 +795,109 @@ function buildUrlWithCode(code, url) {
771
795
  }
772
796
  if (!code)
773
797
  return url;
774
- const cleanUrl = url.startsWith("/") ? url.slice(1) : url;
775
- return `/${code}/${cleanUrl}`;
798
+ const match = url.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
799
+ const path = match?.[1] ?? url;
800
+ const query = match?.[2] ?? "";
801
+ const hash = match?.[3] ?? "";
802
+ const cleanPath = path.startsWith("/") ? path.slice(1) : path;
803
+ const base = cleanPath ? `/${code}/${cleanPath}` : `/${code}`;
804
+ return `${base}${query}${hash}`;
805
+ }
806
+ const strip = (p) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
807
+ /** Same check as `isPathActive`: the page itself or one below it. */
808
+ const pathIsAtOrBelow = (pathname, url) => pathname === url || pathname.startsWith(url + "/");
809
+ /** "/O1/" and "/O1" are the same page; a query string never is. */
810
+ function samePage(pathname, url) {
811
+ if (url.includes("?"))
812
+ return false;
813
+ return strip(pathname) === strip(url);
814
+ }
815
+ /**
816
+ * How long the URL that makes `url` cover `pathname` is — or -1 when it does
817
+ * not. The organisation's home ("/" → "/O1") covers only itself: every page
818
+ * sits below it, and a dashboard is not the parent of every page.
819
+ */
820
+ function coverLength(pathname, url, code) {
821
+ const built = buildUrlWithCode(code, url);
822
+ const isRoot = url === "/" || (!!code && strip(built) === `/${code}`);
823
+ if (isRoot) {
824
+ return samePage(pathname, built) || samePage(pathname, url)
825
+ ? strip(built).length
826
+ : -1;
827
+ }
828
+ if (pathIsAtOrBelow(pathname, built))
829
+ return built.length;
830
+ if (pathIsAtOrBelow(pathname, url))
831
+ return url.length;
832
+ return -1;
833
+ }
834
+ /**
835
+ * Pick the single sidebar entry for `pathname`: the longest URL that covers
836
+ * the path, across every group. Before 0.1.57 the longest match was taken in
837
+ * each group separately, so /project/schedule lit both "โครงการ" (/project,
838
+ * under โครงการ) and "ภาพรวมแผน" (/project/schedule, under แผนงาน).
839
+ *
840
+ * Equal lengths go to the group the app marks active (`item.isActive`, from
841
+ * its `activeModule`), then to a page over a group row, then to the first in
842
+ * menu order.
843
+ */
844
+ function resolveActiveNav(items, pathname, { code, workspace = false, highlightActiveModule = false } = {}) {
845
+ const path = pathname ?? "";
846
+ // The app named a one-line module as the current one: that is the answer.
847
+ if (highlightActiveModule) {
848
+ const leaf = items.findIndex(item => !item.subModules?.length && !!item.isActive);
849
+ if (leaf >= 0)
850
+ return { item: leaf, sub: null, subPage: null };
851
+ }
852
+ let best = null;
853
+ const consider = (c) => {
854
+ if (!best) {
855
+ best = c;
856
+ return;
857
+ }
858
+ if (c.length !== best.length) {
859
+ if (c.length > best.length)
860
+ best = c;
861
+ return;
862
+ }
863
+ const cActive = !!items[c.item].isActive;
864
+ const bActive = !!items[best.item].isActive;
865
+ if (cActive !== bActive) {
866
+ if (cActive)
867
+ best = c;
868
+ return;
869
+ }
870
+ if (c.sub !== null && best.sub === null)
871
+ best = c;
872
+ };
873
+ items.forEach((item, i) => {
874
+ const itemUrl = buildUrlWithCode(code, item.url);
875
+ const subs = item.subModules ?? [];
876
+ // The row itself: only on its own URL. A one-line item under
877
+ // highlightActiveModule is lit by isActive alone (handled above).
878
+ if (!(highlightActiveModule && subs.length === 0)) {
879
+ const own = workspace ? samePage(path, itemUrl) : path === itemUrl;
880
+ if (own)
881
+ consider({ item: i, sub: null, subPage: null, length: strip(itemUrl).length });
882
+ }
883
+ subs.forEach((sub, j) => {
884
+ // The sub-item counts by its own URL or by the longest of its pages.
885
+ let subPage = null;
886
+ let pageLength = -1;
887
+ (sub.subPages ?? []).forEach((page, k) => {
888
+ const l = coverLength(path, page.url, code);
889
+ if (l > pageLength) {
890
+ pageLength = l;
891
+ subPage = k;
892
+ }
893
+ });
894
+ const length = Math.max(coverLength(path, sub.url, code), pageLength);
895
+ if (length >= 0)
896
+ consider({ item: i, sub: j, subPage, length });
897
+ });
898
+ });
899
+ const found = best;
900
+ return found ? { item: found.item, sub: found.sub, subPage: found.subPage } : null;
776
901
  }
777
902
 
778
903
  function NavTools({ tools, closeOnNavigation = false }) {
@@ -1259,7 +1384,7 @@ function platformApiBase(app) {
1259
1384
  * This package's version, attached to help messages so the back office can
1260
1385
  * tell which shell a report came from. Keep in step with package.json.
1261
1386
  */
1262
- const SHELL_VERSION = "0.1.53";
1387
+ const SHELL_VERSION = "0.1.57";
1263
1388
 
1264
1389
  const HELP_APP_LABEL = {
1265
1390
  platform: "แพลตฟอร์ม",
@@ -1807,31 +1932,6 @@ function buildSystemLinks({ orgCode, activeApp, features, }) {
1807
1932
  }));
1808
1933
  }
1809
1934
 
1810
- // Helper function to check if pathname matches a URL pattern
1811
- // This prevents false matches like /reports/sales matching /sales
1812
- // Handles paths with or without [code] prefix
1813
- function isPathActive(pathname, url) {
1814
- // Exact match
1815
- if (pathname === url) {
1816
- return true;
1817
- }
1818
- // Check if pathname starts with url + "/" to ensure exact segment matching
1819
- // This prevents /reports/sales from matching /sales
1820
- // Example: /org123/reports/sales should NOT match /org123/sales
1821
- // But /org123/sales/overview SHOULD match /org123/sales
1822
- if (pathname.startsWith(url + "/")) {
1823
- return true;
1824
- }
1825
- return false;
1826
- }
1827
-
1828
- /** "/O1/" and "/O1" are the same page; a query string never is. */
1829
- function samePage(pathname, url) {
1830
- if (url.includes("?"))
1831
- return false;
1832
- const strip = (p) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
1833
- return strip(pathname) === strip(url);
1834
- }
1835
1935
  function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, chrome = "brand", highlightActiveModule = false, }) {
1836
1936
  const workspace = chrome === "workspace";
1837
1937
  const params = navigation.useParams();
@@ -1842,32 +1942,24 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
1842
1942
  setOpen(false);
1843
1943
  };
1844
1944
  const code = orgCode ?? params?.code;
1845
- // Which module (and sub-item) the current page belongs to.
1846
- const states = items.map(item => {
1945
+ // The one entry that stands for this page — the longest matching URL
1946
+ // across every group, not per group (which lit /project and
1947
+ // /project/schedule together).
1948
+ const active = resolveActiveNav(items, pathname, {
1949
+ code,
1950
+ workspace,
1951
+ highlightActiveModule,
1952
+ });
1953
+ const states = items.map((item, i) => {
1847
1954
  const itemUrl = buildUrlWithCode(code, item.url);
1848
- // Most specific (longest) matching sub-item URL so only one link is active.
1849
- const activeSubItemUrl = item.subModules?.reduce((best, subItem) => {
1850
- const subItemUrl = buildUrlWithCode(code, subItem.url);
1851
- const matches = isPathActive(pathname, subItemUrl) ||
1852
- isPathActive(pathname, subItem.url);
1853
- const subPageMatches = subItem.subPages?.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)) ||
1854
- isPathActive(pathname, subPage.url));
1855
- if (!matches && !subPageMatches)
1856
- return best;
1857
- const candidateUrl = subItemUrl;
1858
- if (!best)
1859
- return candidateUrl;
1860
- return candidateUrl.length > best.length ? candidateUrl : best;
1861
- }, null) ?? null;
1862
- const hasActiveSubItem = !!activeSubItemUrl ||
1863
- !!item.subModules?.some(subItem => subItem.subPages?.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)) ||
1864
- isPathActive(pathname, subPage.url)));
1865
- // Parent only active when path matches its URL exactly AND no child
1866
- // is active (so parent+child don't both light up).
1867
- const isItemActive = (highlightActiveModule && !item.subModules?.length && !!item.isActive) ||
1868
- ((workspace ? samePage(pathname, itemUrl) : pathname === itemUrl) &&
1869
- !hasActiveSubItem);
1870
- return { itemUrl, activeSubItemUrl, hasActiveSubItem, isItemActive };
1955
+ const mine = active?.item === i ? active : null;
1956
+ return {
1957
+ itemUrl,
1958
+ activeSub: mine?.sub ?? null,
1959
+ activeSubPage: mine?.subPage ?? null,
1960
+ hasActiveSubItem: mine?.sub != null,
1961
+ isItemActive: !!mine && mine.sub === null,
1962
+ };
1871
1963
  });
1872
1964
  // A group opens when its page becomes current and otherwise stays as the
1873
1965
  // user left it. defaultOpen alone decided once, on the first render — and
@@ -1905,7 +1997,7 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
1905
1997
  item.group &&
1906
1998
  item.group !== items[index - 1]?.group ? (jsxRuntime.jsx("li", { className: "list-none px-3 pb-2.5 text-xs text-muted-foreground" +
1907
1999
  (index > 0 ? " pt-5" : ""), children: item.group }, `group-${item.id}`)) : null;
1908
- const { itemUrl, activeSubItemUrl, isItemActive } = states[index];
2000
+ const { itemUrl, activeSub, activeSubPage, isItemActive } = states[index];
1909
2001
  const IconComp = item.icon;
1910
2002
  const workspaceButton = workspace
1911
2003
  ? "h-auto gap-3 rounded-[10px] px-3 py-2.5 text-xs " +
@@ -1919,9 +2011,9 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
1919
2011
  workspaceButton, children: item.subModules && item.subModules.length > 0 ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [icon, jsxRuntime.jsx("span", { id: item.id, children: item.titleTh }), jsxRuntime.jsx(lucideReact.ChevronRight, { className: "ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" })] })) : (jsxRuntime.jsxs(Link, { href: itemUrl, prefetch: false, className: "flex items-center w-full min-w-0 flex-1 " +
1920
2012
  (workspace ? "gap-3" : "gap-2"), onClick: handleNavLinkClick, "aria-current": isItemActive ? "page" : undefined, children: [icon, jsxRuntime.jsx("span", { id: item.id, className: "flex-1 truncate", title: item.titleTh, children: item.titleTh }), badge] })) }) }), item.subModules && item.subModules.length > 0 && (jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: workspace
1921
2013
  ? "mx-0 mt-1 mb-3 ml-[21px] gap-0.5 border-l border-sidebar-border pl-2.5 [&_a]:h-auto [&_a]:gap-[11px] [&_a]:px-3 [&_a]:py-2 [&_a]:text-xs [&_svg]:!size-[17px]"
1922
- : undefined, children: item.subModules.map(subItem => {
2014
+ : undefined, children: item.subModules.map((subItem, subIndex) => {
1923
2015
  const subItemUrl = buildUrlWithCode(code, subItem.url);
1924
- const isSubItemActive = subItemUrl === activeSubItemUrl;
2016
+ const isSubItemActive = activeSub === subIndex;
1925
2017
  const SubIcon = subItem.icon;
1926
2018
  const approvalCount = approvalCounts && subItem.approvalCountKey
1927
2019
  ? (approvalCounts[subItem.approvalCountKey] ?? 0)
@@ -1929,10 +2021,10 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
1929
2021
  if (subItem.isCollapsible &&
1930
2022
  subItem.subPages &&
1931
2023
  subItem.subPages.length > 0) {
1932
- const hasActiveSubPage = subItem.subPages.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)));
1933
- return (jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, defaultOpen: isSubItemActive || hasActiveSubPage, className: "group/sub-collapsible", children: jsxRuntime.jsxs(SidebarMenuSubItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsxs(SidebarMenuSubButton, { className: "w-full", isActive: isSubItemActive || hasActiveSubPage, children: [SubIcon && (jsxRuntime.jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), jsxRuntime.jsx(lucideReact.ChevronDown, { className: "ml-auto size-4 transition-transform duration-200 group-data-[state=open]/sub-collapsible:rotate-180" })] }) }), jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: "ml-4", children: subItem.subPages.map(subPage => {
2024
+ return (jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, defaultOpen: isSubItemActive, className: "group/sub-collapsible", children: jsxRuntime.jsxs(SidebarMenuSubItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsxs(SidebarMenuSubButton, { className: "w-full", isActive: isSubItemActive, children: [SubIcon && (jsxRuntime.jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), jsxRuntime.jsx(lucideReact.ChevronDown, { className: "ml-auto size-4 transition-transform duration-200 group-data-[state=open]/sub-collapsible:rotate-180" })] }) }), jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: "ml-4", children: subItem.subPages.map((subPage, subPageIndex) => {
1934
2025
  const subPageUrl = buildUrlWithCode(code, subPage.url);
1935
- const isSubPageActive = isPathActive(pathname, subPageUrl);
2026
+ const isSubPageActive = isSubItemActive &&
2027
+ activeSubPage === subPageIndex;
1936
2028
  const SubPageIcon = subPage.icon;
1937
2029
  return (jsxRuntime.jsx(SidebarMenuSubItem, { children: jsxRuntime.jsx(SidebarMenuSubButton, { asChild: true, className: `font-thai w-full text-sm ${isSubPageActive
1938
2030
  ? "text-sidebar-accent-foreground font-medium"
@@ -3385,6 +3477,24 @@ function PermissionProvider({ children, usePermission, fallbackRulesMap, }) {
3385
3477
  return jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
3386
3478
  }
3387
3479
 
3480
+ // Helper function to check if pathname matches a URL pattern
3481
+ // This prevents false matches like /reports/sales matching /sales
3482
+ // Handles paths with or without [code] prefix
3483
+ function isPathActive(pathname, url) {
3484
+ // Exact match
3485
+ if (pathname === url) {
3486
+ return true;
3487
+ }
3488
+ // Check if pathname starts with url + "/" to ensure exact segment matching
3489
+ // This prevents /reports/sales from matching /sales
3490
+ // Example: /org123/reports/sales should NOT match /org123/sales
3491
+ // But /org123/sales/overview SHOULD match /org123/sales
3492
+ if (pathname.startsWith(url + "/")) {
3493
+ return true;
3494
+ }
3495
+ return false;
3496
+ }
3497
+
3388
3498
  /** Cookie name for the cross-subdomain theme choice. */
3389
3499
  const THEME_COOKIE = "erp-theme";
3390
3500
  /**
@@ -3699,6 +3809,7 @@ exports.myRolesQueryKey = myRolesQueryKey;
3699
3809
  exports.normalizePathnameForPermissionRules = normalizePathnameForPermissionRules;
3700
3810
  exports.openHelp = openHelp;
3701
3811
  exports.parseMyRoles = parseMyRoles;
3812
+ exports.resolveActiveNav = resolveActiveNav;
3702
3813
  exports.resolveUiRoutePermission = resolveUiRoutePermission;
3703
3814
  exports.roleSummary = roleSummary;
3704
3815
  exports.runGlobalRequestPrecheck = runGlobalRequestPrecheck;