@eintrek/erp-shell 0.1.56 → 0.1.58

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,43 +722,79 @@ 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
+ /**
782
+ * Find the active sub-module id from `pathname`: the page whose own URL — or
783
+ * one of whose listed `subPages` — is the path itself (below the org code,
784
+ * ignoring a trailing slash). A record, a create or an edit page below a page
785
+ * is no page of the menu, so it has no active sub-module (0.1.58); the module
786
+ * (`getActiveModule`) still says where it belongs.
787
+ *
788
+ * Equal URLs in two modules go to the active module's page.
789
+ */
748
790
  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;
791
+ const path = strip(pathBelowCode(pathname));
792
+ const isPage = (url) => isMenuPath(url) && strip(url) === path;
793
+ const pagesOf = (m) => (m.subModules ?? []).filter(sub => isPage(sub.url) || (sub.subPages ?? []).some(p => isPage(p.url)));
794
+ const moduleId = getActiveModule(pathname, modules);
795
+ const own = modules.find(m => m.id === moduleId);
796
+ const hit = (own ? pagesOf(own)[0] : undefined) ?? modules.flatMap(pagesOf)[0];
797
+ return hit?.id ?? null;
762
798
  }
763
799
  /** Prepend `code` (org code) to a relative URL so `/sales` → `/O25.../sales`.
764
800
  * Absolute `http(s)://` URLs are left intact; optional `{code}` placeholder is replaced.
@@ -780,6 +816,140 @@ function buildUrlWithCode(code, url) {
780
816
  const base = cleanPath ? `/${code}/${cleanPath}` : `/${code}`;
781
817
  return `${base}${query}${hash}`;
782
818
  }
819
+ const strip = (p) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
820
+ /** A URL the path can be compared with: not query-only, not another site. */
821
+ const isMenuPath = (url) => !!url && !url.includes("?") && !/^https?:\/\//i.test(url);
822
+ /** Same check as `isPathActive`: the page itself or one below it. */
823
+ const pathIsAtOrBelow = (pathname, url) => pathname === url || pathname.startsWith(url + "/");
824
+ /** "/O1/" and "/O1" are the same page; a query string never is. */
825
+ function samePage(pathname, url) {
826
+ if (url.includes("?"))
827
+ return false;
828
+ return strip(pathname.split("?")[0]) === strip(url);
829
+ }
830
+ /**
831
+ * How long `url` is when it is the page `pathname` shows (ignoring a trailing
832
+ * slash), or -1. Compared with and without the org code, as the menu URLs
833
+ * are written either way.
834
+ */
835
+ function pageLength(pathname, url, code) {
836
+ if (!isMenuPath(url))
837
+ return -1;
838
+ const built = buildUrlWithCode(code, url);
839
+ if (samePage(pathname, built))
840
+ return strip(built).length;
841
+ if (samePage(pathname, url))
842
+ return strip(url).length;
843
+ return -1;
844
+ }
845
+ /**
846
+ * How long the URL that makes `url` cover `pathname` is — the page itself or
847
+ * one below it — or -1. The organisation's home ("/" → "/O1") covers only
848
+ * itself: every page sits below it, and a dashboard is not the parent of
849
+ * every page.
850
+ */
851
+ function coverLength(pathname, url, code) {
852
+ if (!isMenuPath(url))
853
+ return -1;
854
+ const path = strip(pathname.split("?")[0]);
855
+ const built = strip(buildUrlWithCode(code, url));
856
+ const isRoot = url === "/" || (!!code && built === `/${code}`);
857
+ if (isRoot)
858
+ return pageLength(path, url, code);
859
+ if (pathIsAtOrBelow(path, built))
860
+ return built.length;
861
+ const raw = strip(url);
862
+ if (pathIsAtOrBelow(path, raw))
863
+ return raw.length;
864
+ return -1;
865
+ }
866
+ /**
867
+ * Where `pathname` sits in the sidebar: the entry to light and the group to
868
+ * open.
869
+ *
870
+ * Lit (0.1.58): only the entry whose own page this is — its URL or one of its
871
+ * `subPages`, exactly. A record, a create or an edit page below a menu page
872
+ * (Plannex /project/detail/<id>/schedule under "โครงการ" /project) lights
873
+ * nothing; an app lists such a page in `subPages` when it should keep its
874
+ * entry lit. At most one entry across every group (0.1.57).
875
+ *
876
+ * Open: the lit entry's group, or else the group with the deepest URL the
877
+ * path sits below.
878
+ *
879
+ * Equal lengths go to the group the app marks active (`item.isActive`, from
880
+ * its `activeModule`), then to a page over a group row, then to the first in
881
+ * menu order.
882
+ */
883
+ function resolveSidebarNav(items, pathname, { code, highlightActiveModule = false } = {}) {
884
+ const path = (pathname ?? "").split("?")[0];
885
+ // The app named a one-line module as the current one: that is the answer.
886
+ if (highlightActiveModule) {
887
+ const leaf = items.findIndex(item => !item.subModules?.length && !!item.isActive);
888
+ if (leaf >= 0) {
889
+ return {
890
+ active: { item: leaf, sub: null, subPage: null },
891
+ open: { item: leaf, sub: null },
892
+ };
893
+ }
894
+ }
895
+ const better = (c, best) => {
896
+ if (!best)
897
+ return true;
898
+ if (c.length !== best.length)
899
+ return c.length > best.length;
900
+ const cActive = !!items[c.item].isActive;
901
+ const bActive = !!items[best.item].isActive;
902
+ if (cActive !== bActive)
903
+ return cActive;
904
+ // The same sub-item by its own URL and by a listed page: the page.
905
+ if (c.item === best.item && c.sub === best.sub) {
906
+ return c.subPage !== null && best.subPage === null;
907
+ }
908
+ return c.sub !== null && best.sub === null;
909
+ };
910
+ let exact = null;
911
+ let within = null;
912
+ const consider = (c, isExact) => {
913
+ if (c.length < 0)
914
+ return;
915
+ if (isExact) {
916
+ if (better(c, exact))
917
+ exact = c;
918
+ }
919
+ else if (better(c, within)) {
920
+ within = c;
921
+ }
922
+ };
923
+ items.forEach((item, i) => {
924
+ const subs = item.subModules ?? [];
925
+ const row = { item: i, sub: null, subPage: null };
926
+ // The row itself: only on its own URL. A one-line item under
927
+ // highlightActiveModule is lit by isActive alone (handled above).
928
+ if (!(highlightActiveModule && subs.length === 0)) {
929
+ consider({ ...row, length: pageLength(path, item.url, code) }, true);
930
+ }
931
+ consider({ ...row, length: coverLength(path, item.url, code) }, false);
932
+ subs.forEach((sub, j) => {
933
+ const entry = { item: i, sub: j };
934
+ consider({ ...entry, subPage: null, length: pageLength(path, sub.url, code) }, true);
935
+ consider({ ...entry, subPage: null, length: coverLength(path, sub.url, code) }, false);
936
+ (sub.subPages ?? []).forEach((page, k) => {
937
+ consider({ ...entry, subPage: k, length: pageLength(path, page.url, code) }, true);
938
+ consider({ ...entry, subPage: k, length: coverLength(path, page.url, code) }, false);
939
+ });
940
+ });
941
+ });
942
+ const lit = exact;
943
+ const holder = lit ?? within;
944
+ return {
945
+ active: lit ? { item: lit.item, sub: lit.sub, subPage: lit.subPage } : null,
946
+ open: holder ? { item: holder.item, sub: holder.sub } : null,
947
+ };
948
+ }
949
+ /** The entry lit for `pathname` — `resolveSidebarNav(...).active`. */
950
+ function resolveActiveNav(items, pathname, options = {}) {
951
+ return resolveSidebarNav(items, pathname, options).active;
952
+ }
783
953
 
784
954
  function NavTools({ tools, closeOnNavigation = false }) {
785
955
  const pathname = navigation.usePathname();
@@ -1265,7 +1435,7 @@ function platformApiBase(app) {
1265
1435
  * This package's version, attached to help messages so the back office can
1266
1436
  * tell which shell a report came from. Keep in step with package.json.
1267
1437
  */
1268
- const SHELL_VERSION = "0.1.53";
1438
+ const SHELL_VERSION = "0.1.58";
1269
1439
 
1270
1440
  const HELP_APP_LABEL = {
1271
1441
  platform: "แพลตฟอร์ม",
@@ -1813,31 +1983,6 @@ function buildSystemLinks({ orgCode, activeApp, features, }) {
1813
1983
  }));
1814
1984
  }
1815
1985
 
1816
- // Helper function to check if pathname matches a URL pattern
1817
- // This prevents false matches like /reports/sales matching /sales
1818
- // Handles paths with or without [code] prefix
1819
- function isPathActive(pathname, url) {
1820
- // Exact match
1821
- if (pathname === url) {
1822
- return true;
1823
- }
1824
- // Check if pathname starts with url + "/" to ensure exact segment matching
1825
- // This prevents /reports/sales from matching /sales
1826
- // Example: /org123/reports/sales should NOT match /org123/sales
1827
- // But /org123/sales/overview SHOULD match /org123/sales
1828
- if (pathname.startsWith(url + "/")) {
1829
- return true;
1830
- }
1831
- return false;
1832
- }
1833
-
1834
- /** "/O1/" and "/O1" are the same page; a query string never is. */
1835
- function samePage(pathname, url) {
1836
- if (url.includes("?"))
1837
- return false;
1838
- const strip = (p) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
1839
- return strip(pathname) === strip(url);
1840
- }
1841
1986
  function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, chrome = "brand", highlightActiveModule = false, }) {
1842
1987
  const workspace = chrome === "workspace";
1843
1988
  const params = navigation.useParams();
@@ -1848,63 +1993,46 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
1848
1993
  setOpen(false);
1849
1994
  };
1850
1995
  const code = orgCode ?? params?.code;
1851
- // Which module (and sub-item) the current page belongs to.
1852
- const states = items.map(item => {
1996
+ // The one entry lit for this page — only on that entry's own page, never
1997
+ // on a record or a form below it — and the group holding the page, which
1998
+ // opens either way.
1999
+ const { active, open } = resolveSidebarNav(items, pathname, {
2000
+ code,
2001
+ highlightActiveModule,
2002
+ });
2003
+ const states = items.map((item, i) => {
1853
2004
  const itemUrl = buildUrlWithCode(code, item.url);
1854
- // Most specific (longest) matching sub-item URL so only one link is active.
1855
- const activeSubItemUrl = item.subModules?.reduce((best, subItem) => {
1856
- const subItemUrl = buildUrlWithCode(code, subItem.url);
1857
- const matches = isPathActive(pathname, subItemUrl) ||
1858
- isPathActive(pathname, subItem.url);
1859
- const subPageMatches = subItem.subPages?.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)) ||
1860
- isPathActive(pathname, subPage.url));
1861
- if (!matches && !subPageMatches)
1862
- return best;
1863
- const candidateUrl = subItemUrl;
1864
- if (!best)
1865
- return candidateUrl;
1866
- return candidateUrl.length > best.length ? candidateUrl : best;
1867
- }, null) ?? null;
1868
- const hasActiveSubItem = !!activeSubItemUrl ||
1869
- !!item.subModules?.some(subItem => subItem.subPages?.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)) ||
1870
- isPathActive(pathname, subPage.url)));
1871
- // Parent only active when path matches its URL exactly AND no child
1872
- // is active (so parent+child don't both light up). When the app opts
1873
- // into highlightActiveModule, leaf items trust `item.isActive` alone —
1874
- // needed for query-only hub actions (`/?start=1`) that share a path
1875
- // with another leaf (e.g. "แอปของฉัน" at `/`).
1876
- const isItemActive = highlightActiveModule && !item.subModules?.length
1877
- ? !!item.isActive
1878
- : (workspace ? samePage(pathname, itemUrl) : pathname === itemUrl) &&
1879
- !hasActiveSubItem;
1880
- return { itemUrl, activeSubItemUrl, hasActiveSubItem, isItemActive };
2005
+ const mine = active?.item === i ? active : null;
2006
+ return {
2007
+ itemUrl,
2008
+ activeSub: mine?.sub ?? null,
2009
+ activeSubPage: mine?.subPage ?? null,
2010
+ openSub: open?.item === i ? open.sub : null,
2011
+ isItemActive: !!mine && mine.sub === null,
2012
+ };
1881
2013
  });
1882
- // A group opens when its page becomes current and otherwise stays as the
2014
+ // A group opens when the page moves into it and otherwise stays as the
1883
2015
  // user left it. defaultOpen alone decided once, on the first render — and
1884
2016
  // on a direct load the sub-items arrive after permissions do, so the group
1885
2017
  // holding the current page stayed shut.
1886
- const activeKey = items
1887
- .filter((_, i) => states[i].isItemActive || states[i].hasActiveSubItem)
1888
- .map(item => item.id)
1889
- .join(",");
1890
- const [openIds, setOpenIds] = React__namespace.useState(() => new Set(activeKey ? activeKey.split(",") : []));
2018
+ const openId = open ? (items[open.item]?.id ?? "") : "";
2019
+ const [openIds, setOpenIds] = React__namespace.useState(() => new Set(openId ? [openId] : []));
1891
2020
  React__namespace.useEffect(() => {
1892
- if (!activeKey)
2021
+ if (!openId)
1893
2022
  return;
1894
2023
  setOpenIds(prev => {
1895
- const ids = activeKey.split(",");
1896
- if (ids.every(id => prev.has(id)))
2024
+ if (prev.has(openId))
1897
2025
  return prev;
1898
2026
  const next = new Set(prev);
1899
- ids.forEach(id => next.add(id));
2027
+ next.add(openId);
1900
2028
  return next;
1901
2029
  });
1902
- }, [activeKey]);
1903
- const setItemOpen = (id, open) => setOpenIds(prev => {
1904
- if (prev.has(id) === open)
2030
+ }, [openId]);
2031
+ const setItemOpen = (id, isOpen) => setOpenIds(prev => {
2032
+ if (prev.has(id) === isOpen)
1905
2033
  return prev;
1906
2034
  const next = new Set(prev);
1907
- if (open)
2035
+ if (isOpen)
1908
2036
  next.add(id);
1909
2037
  else
1910
2038
  next.delete(id);
@@ -1915,7 +2043,7 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
1915
2043
  item.group &&
1916
2044
  item.group !== items[index - 1]?.group ? (jsxRuntime.jsx("li", { className: "list-none px-3 pb-2.5 text-xs text-muted-foreground" +
1917
2045
  (index > 0 ? " pt-5" : ""), children: item.group }, `group-${item.id}`)) : null;
1918
- const { itemUrl, activeSubItemUrl, isItemActive } = states[index];
2046
+ const { itemUrl, activeSub, activeSubPage, openSub, isItemActive } = states[index];
1919
2047
  const IconComp = item.icon;
1920
2048
  const workspaceButton = workspace
1921
2049
  ? "h-auto gap-3 rounded-[10px] px-3 py-2.5 text-xs " +
@@ -1925,13 +2053,13 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
1925
2053
  : "";
1926
2054
  const icon = workspace && IconComp ? (jsxRuntime.jsx(IconComp, { className: "size-[18px] shrink-0" })) : null;
1927
2055
  const badge = workspace && item.badge !== undefined && item.badge !== "" ? (jsxRuntime.jsx("span", { className: "ml-auto min-w-5 rounded-[5px] bg-primary/15 px-1 text-center text-xs leading-5 font-bold tabular-nums text-primary", children: item.badge })) : null;
1928
- return (jsxRuntime.jsxs(React__namespace.Fragment, { children: [groupHeading, jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, open: openIds.has(item.id), onOpenChange: open => setItemOpen(item.id, open), className: "group/collapsible", children: jsxRuntime.jsxs(SidebarMenuItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsx(SidebarMenuButton, { asChild: !item.subModules || item.subModules.length === 0, isActive: isItemActive, className: "data-[state=open]:text-sidebar-accent-foreground " +
2056
+ return (jsxRuntime.jsxs(React__namespace.Fragment, { children: [groupHeading, jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, open: openIds.has(item.id), onOpenChange: isOpen => setItemOpen(item.id, isOpen), className: "group/collapsible", children: jsxRuntime.jsxs(SidebarMenuItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsx(SidebarMenuButton, { asChild: !item.subModules || item.subModules.length === 0, isActive: isItemActive, className: "data-[state=open]:text-sidebar-accent-foreground " +
1929
2057
  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 " +
1930
2058
  (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
1931
2059
  ? "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]"
1932
- : undefined, children: item.subModules.map(subItem => {
2060
+ : undefined, children: item.subModules.map((subItem, subIndex) => {
1933
2061
  const subItemUrl = buildUrlWithCode(code, subItem.url);
1934
- const isSubItemActive = subItemUrl === activeSubItemUrl;
2062
+ const isSubItemActive = activeSub === subIndex;
1935
2063
  const SubIcon = subItem.icon;
1936
2064
  const approvalCount = approvalCounts && subItem.approvalCountKey
1937
2065
  ? (approvalCounts[subItem.approvalCountKey] ?? 0)
@@ -1939,10 +2067,10 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
1939
2067
  if (subItem.isCollapsible &&
1940
2068
  subItem.subPages &&
1941
2069
  subItem.subPages.length > 0) {
1942
- const hasActiveSubPage = subItem.subPages.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)));
1943
- 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 => {
2070
+ return (jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, defaultOpen: isSubItemActive || openSub === subIndex, 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) => {
1944
2071
  const subPageUrl = buildUrlWithCode(code, subPage.url);
1945
- const isSubPageActive = isPathActive(pathname, subPageUrl);
2072
+ const isSubPageActive = isSubItemActive &&
2073
+ activeSubPage === subPageIndex;
1946
2074
  const SubPageIcon = subPage.icon;
1947
2075
  return (jsxRuntime.jsx(SidebarMenuSubItem, { children: jsxRuntime.jsx(SidebarMenuSubButton, { asChild: true, className: `font-thai w-full text-sm ${isSubPageActive
1948
2076
  ? "text-sidebar-accent-foreground font-medium"
@@ -3395,6 +3523,24 @@ function PermissionProvider({ children, usePermission, fallbackRulesMap, }) {
3395
3523
  return jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
3396
3524
  }
3397
3525
 
3526
+ // Helper function to check if pathname matches a URL pattern
3527
+ // This prevents false matches like /reports/sales matching /sales
3528
+ // Handles paths with or without [code] prefix
3529
+ function isPathActive(pathname, url) {
3530
+ // Exact match
3531
+ if (pathname === url) {
3532
+ return true;
3533
+ }
3534
+ // Check if pathname starts with url + "/" to ensure exact segment matching
3535
+ // This prevents /reports/sales from matching /sales
3536
+ // Example: /org123/reports/sales should NOT match /org123/sales
3537
+ // But /org123/sales/overview SHOULD match /org123/sales
3538
+ if (pathname.startsWith(url + "/")) {
3539
+ return true;
3540
+ }
3541
+ return false;
3542
+ }
3543
+
3398
3544
  /** Cookie name for the cross-subdomain theme choice. */
3399
3545
  const THEME_COOKIE = "erp-theme";
3400
3546
  /**
@@ -3709,6 +3855,8 @@ exports.myRolesQueryKey = myRolesQueryKey;
3709
3855
  exports.normalizePathnameForPermissionRules = normalizePathnameForPermissionRules;
3710
3856
  exports.openHelp = openHelp;
3711
3857
  exports.parseMyRoles = parseMyRoles;
3858
+ exports.resolveActiveNav = resolveActiveNav;
3859
+ exports.resolveSidebarNav = resolveSidebarNav;
3712
3860
  exports.resolveUiRoutePermission = resolveUiRoutePermission;
3713
3861
  exports.roleSummary = roleSummary;
3714
3862
  exports.runGlobalRequestPrecheck = runGlobalRequestPrecheck;