@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/README.md CHANGED
@@ -86,6 +86,16 @@ Shell now owns the **shapes + helpers** for navigation; apps own the data.
86
86
  - Components: `NavTools` (reads Tool[] + buildUrlWithCode internally),
87
87
  `AppBreadcrumb` (accepts `labels` prop so per-app Thai overrides stay local).
88
88
 
89
+ Sidebar highlight (0.1.58, `resolveSidebarNav`): an entry is lit only on
90
+ its own page — its URL or one of its `subPages`, exactly, after the org code,
91
+ ignoring a trailing slash — and at most one entry is lit. A record, a create
92
+ or an edit page below a menu page lights nothing; the group holding it (the
93
+ deepest URL the path sits below) opens. A page that should keep its entry lit
94
+ (a list's tab that is its own route) goes in that entry's `subPages`, with a
95
+ `permissionRouteKey` the user holds (usually the entry's own), since the
96
+ permission filter drops a sub-page without one. `getActiveModule` stays the
97
+ longest prefix; `getActiveSubModule` is exact like the sidebar.
98
+
89
99
  Each app's `navigation-data.ts` now (1) imports types/helpers from shell,
90
100
  (2) declares its own `navigationData` const + `breadcrumbLabels`, (3) wraps
91
101
  `getActiveModule`/`getActiveSubModule` to keep the 1-arg signature, and
package/dist/index.d.ts CHANGED
@@ -38,7 +38,7 @@ export { PermissionProvider, type PermissionProviderProps, type PermissionProbe,
38
38
  export { getRequiredChildGroups, normalizePathnameForPermissionRules, resolveUiRoutePermission, type UiRoutePermissionLookup, } from "./auth/permission-utils";
39
39
  export * from "./navigation/navigation-helpers";
40
40
  export type { Module, SubPage, SubModule, Tool, Company, NavigationData, } from "./navigation/navigation-types";
41
- export { canAccessNavPermissionRoute, filterNavigationToolsByPermission, filterNavigationModulesByPermission, getActiveModule, getActiveSubModule, buildUrlWithCode, } from "./navigation/navigation-data-helpers";
41
+ export { canAccessNavPermissionRoute, filterNavigationToolsByPermission, filterNavigationModulesByPermission, getActiveModule, getActiveSubModule, buildUrlWithCode, resolveActiveNav, resolveSidebarNav, type ActiveNavEntry, type SidebarNavState, type ResolveActiveNavOptions, } from "./navigation/navigation-data-helpers";
42
42
  export { ThemeProvider } from "./providers/theme-provider";
43
43
  export { FeatureFlagsProvider, useFeatureFlags, useFeatureFlag, type FeatureFlags, } from "./providers/feature-flags-provider";
44
44
  export { ReactQueryProvider } from "./providers/react-query-provider";
package/dist/index.esm.js CHANGED
@@ -703,43 +703,79 @@ function filterNavigationModulesByPermission(modules, rulesMap, userChildSegment
703
703
  })
704
704
  .filter((m) => m !== null);
705
705
  }
706
+ /** The path below the org code: "/O1/project/budgets" → "/project/budgets". */
707
+ function pathBelowCode(pathname) {
708
+ const segments = pathname.split("?")[0].split("/").filter(Boolean);
709
+ return segments.length > 1 ? "/" + segments.slice(1).join("/") : "/";
710
+ }
706
711
  /**
707
- * Find the active top-level module id from `pathname`. Strips the org-code
708
- * prefix from the path (first path segment) before matching.
712
+ * Whether `url` (relative, no org code) covers `path`: the same page, or a
713
+ * page below it on a segment boundary. "/" covers only the home itself and a
714
+ * query-only URL covers nothing.
709
715
  */
710
- function getActiveModule(pathname, modules) {
711
- const segments = pathname.split("/").filter(Boolean);
712
- const pathWithoutCode = segments.length > 1 ? "/" + segments.slice(1).join("/") : "/";
716
+ function coversPath(url, path) {
717
+ if (!url || url.includes("?") || /^https?:\/\//i.test(url))
718
+ return false;
719
+ if (url === "/")
720
+ return path === "/";
721
+ const clean = url.replace(/\/+$/, "");
722
+ return path === clean || path.startsWith(clean + "/");
723
+ }
724
+ /**
725
+ * The module and page whose URL is the longest one covering the path, over
726
+ * every module — a module's own URL, its pages and their sub-pages. Equal
727
+ * lengths go to the first in menu order.
728
+ */
729
+ function longestMatch(path, modules) {
730
+ let best = null;
731
+ const consider = (module, sub, url) => {
732
+ if (!coversPath(url, path))
733
+ return;
734
+ if (!best || url.length > best.length) {
735
+ best = { module, sub, length: url.length };
736
+ }
737
+ };
713
738
  for (const navModule of modules) {
714
- if (pathWithoutCode === navModule.url || pathname.endsWith(navModule.url)) {
715
- return navModule.id;
716
- }
717
- if (navModule.subModules) {
718
- for (const subModule of navModule.subModules) {
719
- if (pathWithoutCode.startsWith(subModule.url) ||
720
- pathname.includes(subModule.url)) {
721
- return navModule.id;
722
- }
723
- }
739
+ for (const sub of navModule.subModules ?? []) {
740
+ consider(navModule, sub, sub.url);
741
+ for (const page of sub.subPages ?? [])
742
+ consider(navModule, sub, page.url);
724
743
  }
744
+ // After its pages: on a tie the page is the more specific answer.
745
+ consider(navModule, null, navModule.url);
725
746
  }
726
- return null;
747
+ const found = best;
748
+ return found ? { module: found.module, sub: found.sub } : null;
749
+ }
750
+ /**
751
+ * Find the active top-level module id from `pathname`: the module whose URL
752
+ * (or one of whose pages' URLs) is the longest prefix of the path below the
753
+ * org code.
754
+ *
755
+ * Before 0.1.57 this took the first module with a page URL anywhere in the
756
+ * path (`includes`), so "/project" claimed /project/schedule and the sidebar,
757
+ * the trail and the tabs could disagree about where the user was.
758
+ */
759
+ function getActiveModule(pathname, modules) {
760
+ return longestMatch(pathBelowCode(pathname), modules)?.module.id ?? null;
727
761
  }
728
- /** Find the active sub-module id from `pathname`. */
762
+ /**
763
+ * Find the active sub-module id from `pathname`: the page whose own URL — or
764
+ * one of whose listed `subPages` — is the path itself (below the org code,
765
+ * ignoring a trailing slash). A record, a create or an edit page below a page
766
+ * is no page of the menu, so it has no active sub-module (0.1.58); the module
767
+ * (`getActiveModule`) still says where it belongs.
768
+ *
769
+ * Equal URLs in two modules go to the active module's page.
770
+ */
729
771
  function getActiveSubModule(pathname, modules) {
730
- const segments = pathname.split("/").filter(Boolean);
731
- const pathWithoutCode = segments.length > 1 ? "/" + segments.slice(1).join("/") : "/";
732
- for (const navModule of modules) {
733
- if (navModule.subModules) {
734
- for (const subModule of navModule.subModules) {
735
- if (pathWithoutCode.startsWith(subModule.url) ||
736
- pathname.includes(subModule.url)) {
737
- return subModule.id;
738
- }
739
- }
740
- }
741
- }
742
- return null;
772
+ const path = strip(pathBelowCode(pathname));
773
+ const isPage = (url) => isMenuPath(url) && strip(url) === path;
774
+ const pagesOf = (m) => (m.subModules ?? []).filter(sub => isPage(sub.url) || (sub.subPages ?? []).some(p => isPage(p.url)));
775
+ const moduleId = getActiveModule(pathname, modules);
776
+ const own = modules.find(m => m.id === moduleId);
777
+ const hit = (own ? pagesOf(own)[0] : undefined) ?? modules.flatMap(pagesOf)[0];
778
+ return hit?.id ?? null;
743
779
  }
744
780
  /** Prepend `code` (org code) to a relative URL so `/sales` → `/O25.../sales`.
745
781
  * Absolute `http(s)://` URLs are left intact; optional `{code}` placeholder is replaced.
@@ -761,6 +797,140 @@ function buildUrlWithCode(code, url) {
761
797
  const base = cleanPath ? `/${code}/${cleanPath}` : `/${code}`;
762
798
  return `${base}${query}${hash}`;
763
799
  }
800
+ const strip = (p) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
801
+ /** A URL the path can be compared with: not query-only, not another site. */
802
+ const isMenuPath = (url) => !!url && !url.includes("?") && !/^https?:\/\//i.test(url);
803
+ /** Same check as `isPathActive`: the page itself or one below it. */
804
+ const pathIsAtOrBelow = (pathname, url) => pathname === url || pathname.startsWith(url + "/");
805
+ /** "/O1/" and "/O1" are the same page; a query string never is. */
806
+ function samePage(pathname, url) {
807
+ if (url.includes("?"))
808
+ return false;
809
+ return strip(pathname.split("?")[0]) === strip(url);
810
+ }
811
+ /**
812
+ * How long `url` is when it is the page `pathname` shows (ignoring a trailing
813
+ * slash), or -1. Compared with and without the org code, as the menu URLs
814
+ * are written either way.
815
+ */
816
+ function pageLength(pathname, url, code) {
817
+ if (!isMenuPath(url))
818
+ return -1;
819
+ const built = buildUrlWithCode(code, url);
820
+ if (samePage(pathname, built))
821
+ return strip(built).length;
822
+ if (samePage(pathname, url))
823
+ return strip(url).length;
824
+ return -1;
825
+ }
826
+ /**
827
+ * How long the URL that makes `url` cover `pathname` is — the page itself or
828
+ * one below it — or -1. The organisation's home ("/" → "/O1") covers only
829
+ * itself: every page sits below it, and a dashboard is not the parent of
830
+ * every page.
831
+ */
832
+ function coverLength(pathname, url, code) {
833
+ if (!isMenuPath(url))
834
+ return -1;
835
+ const path = strip(pathname.split("?")[0]);
836
+ const built = strip(buildUrlWithCode(code, url));
837
+ const isRoot = url === "/" || (!!code && built === `/${code}`);
838
+ if (isRoot)
839
+ return pageLength(path, url, code);
840
+ if (pathIsAtOrBelow(path, built))
841
+ return built.length;
842
+ const raw = strip(url);
843
+ if (pathIsAtOrBelow(path, raw))
844
+ return raw.length;
845
+ return -1;
846
+ }
847
+ /**
848
+ * Where `pathname` sits in the sidebar: the entry to light and the group to
849
+ * open.
850
+ *
851
+ * Lit (0.1.58): only the entry whose own page this is — its URL or one of its
852
+ * `subPages`, exactly. A record, a create or an edit page below a menu page
853
+ * (Plannex /project/detail/<id>/schedule under "โครงการ" /project) lights
854
+ * nothing; an app lists such a page in `subPages` when it should keep its
855
+ * entry lit. At most one entry across every group (0.1.57).
856
+ *
857
+ * Open: the lit entry's group, or else the group with the deepest URL the
858
+ * path sits below.
859
+ *
860
+ * Equal lengths go to the group the app marks active (`item.isActive`, from
861
+ * its `activeModule`), then to a page over a group row, then to the first in
862
+ * menu order.
863
+ */
864
+ function resolveSidebarNav(items, pathname, { code, highlightActiveModule = false } = {}) {
865
+ const path = (pathname ?? "").split("?")[0];
866
+ // The app named a one-line module as the current one: that is the answer.
867
+ if (highlightActiveModule) {
868
+ const leaf = items.findIndex(item => !item.subModules?.length && !!item.isActive);
869
+ if (leaf >= 0) {
870
+ return {
871
+ active: { item: leaf, sub: null, subPage: null },
872
+ open: { item: leaf, sub: null },
873
+ };
874
+ }
875
+ }
876
+ const better = (c, best) => {
877
+ if (!best)
878
+ return true;
879
+ if (c.length !== best.length)
880
+ return c.length > best.length;
881
+ const cActive = !!items[c.item].isActive;
882
+ const bActive = !!items[best.item].isActive;
883
+ if (cActive !== bActive)
884
+ return cActive;
885
+ // The same sub-item by its own URL and by a listed page: the page.
886
+ if (c.item === best.item && c.sub === best.sub) {
887
+ return c.subPage !== null && best.subPage === null;
888
+ }
889
+ return c.sub !== null && best.sub === null;
890
+ };
891
+ let exact = null;
892
+ let within = null;
893
+ const consider = (c, isExact) => {
894
+ if (c.length < 0)
895
+ return;
896
+ if (isExact) {
897
+ if (better(c, exact))
898
+ exact = c;
899
+ }
900
+ else if (better(c, within)) {
901
+ within = c;
902
+ }
903
+ };
904
+ items.forEach((item, i) => {
905
+ const subs = item.subModules ?? [];
906
+ const row = { item: i, sub: null, subPage: null };
907
+ // The row itself: only on its own URL. A one-line item under
908
+ // highlightActiveModule is lit by isActive alone (handled above).
909
+ if (!(highlightActiveModule && subs.length === 0)) {
910
+ consider({ ...row, length: pageLength(path, item.url, code) }, true);
911
+ }
912
+ consider({ ...row, length: coverLength(path, item.url, code) }, false);
913
+ subs.forEach((sub, j) => {
914
+ const entry = { item: i, sub: j };
915
+ consider({ ...entry, subPage: null, length: pageLength(path, sub.url, code) }, true);
916
+ consider({ ...entry, subPage: null, length: coverLength(path, sub.url, code) }, false);
917
+ (sub.subPages ?? []).forEach((page, k) => {
918
+ consider({ ...entry, subPage: k, length: pageLength(path, page.url, code) }, true);
919
+ consider({ ...entry, subPage: k, length: coverLength(path, page.url, code) }, false);
920
+ });
921
+ });
922
+ });
923
+ const lit = exact;
924
+ const holder = lit ?? within;
925
+ return {
926
+ active: lit ? { item: lit.item, sub: lit.sub, subPage: lit.subPage } : null,
927
+ open: holder ? { item: holder.item, sub: holder.sub } : null,
928
+ };
929
+ }
930
+ /** The entry lit for `pathname` — `resolveSidebarNav(...).active`. */
931
+ function resolveActiveNav(items, pathname, options = {}) {
932
+ return resolveSidebarNav(items, pathname, options).active;
933
+ }
764
934
 
765
935
  function NavTools({ tools, closeOnNavigation = false }) {
766
936
  const pathname = usePathname();
@@ -1246,7 +1416,7 @@ function platformApiBase(app) {
1246
1416
  * This package's version, attached to help messages so the back office can
1247
1417
  * tell which shell a report came from. Keep in step with package.json.
1248
1418
  */
1249
- const SHELL_VERSION = "0.1.53";
1419
+ const SHELL_VERSION = "0.1.58";
1250
1420
 
1251
1421
  const HELP_APP_LABEL = {
1252
1422
  platform: "แพลตฟอร์ม",
@@ -1794,31 +1964,6 @@ function buildSystemLinks({ orgCode, activeApp, features, }) {
1794
1964
  }));
1795
1965
  }
1796
1966
 
1797
- // Helper function to check if pathname matches a URL pattern
1798
- // This prevents false matches like /reports/sales matching /sales
1799
- // Handles paths with or without [code] prefix
1800
- function isPathActive(pathname, url) {
1801
- // Exact match
1802
- if (pathname === url) {
1803
- return true;
1804
- }
1805
- // Check if pathname starts with url + "/" to ensure exact segment matching
1806
- // This prevents /reports/sales from matching /sales
1807
- // Example: /org123/reports/sales should NOT match /org123/sales
1808
- // But /org123/sales/overview SHOULD match /org123/sales
1809
- if (pathname.startsWith(url + "/")) {
1810
- return true;
1811
- }
1812
- return false;
1813
- }
1814
-
1815
- /** "/O1/" and "/O1" are the same page; a query string never is. */
1816
- function samePage(pathname, url) {
1817
- if (url.includes("?"))
1818
- return false;
1819
- const strip = (p) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
1820
- return strip(pathname) === strip(url);
1821
- }
1822
1967
  function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, chrome = "brand", highlightActiveModule = false, }) {
1823
1968
  const workspace = chrome === "workspace";
1824
1969
  const params = useParams();
@@ -1829,63 +1974,46 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
1829
1974
  setOpen(false);
1830
1975
  };
1831
1976
  const code = orgCode ?? params?.code;
1832
- // Which module (and sub-item) the current page belongs to.
1833
- const states = items.map(item => {
1977
+ // The one entry lit for this page — only on that entry's own page, never
1978
+ // on a record or a form below it — and the group holding the page, which
1979
+ // opens either way.
1980
+ const { active, open } = resolveSidebarNav(items, pathname, {
1981
+ code,
1982
+ highlightActiveModule,
1983
+ });
1984
+ const states = items.map((item, i) => {
1834
1985
  const itemUrl = buildUrlWithCode(code, item.url);
1835
- // Most specific (longest) matching sub-item URL so only one link is active.
1836
- const activeSubItemUrl = item.subModules?.reduce((best, subItem) => {
1837
- const subItemUrl = buildUrlWithCode(code, subItem.url);
1838
- const matches = isPathActive(pathname, subItemUrl) ||
1839
- isPathActive(pathname, subItem.url);
1840
- const subPageMatches = subItem.subPages?.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)) ||
1841
- isPathActive(pathname, subPage.url));
1842
- if (!matches && !subPageMatches)
1843
- return best;
1844
- const candidateUrl = subItemUrl;
1845
- if (!best)
1846
- return candidateUrl;
1847
- return candidateUrl.length > best.length ? candidateUrl : best;
1848
- }, null) ?? null;
1849
- const hasActiveSubItem = !!activeSubItemUrl ||
1850
- !!item.subModules?.some(subItem => subItem.subPages?.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)) ||
1851
- isPathActive(pathname, subPage.url)));
1852
- // Parent only active when path matches its URL exactly AND no child
1853
- // is active (so parent+child don't both light up). When the app opts
1854
- // into highlightActiveModule, leaf items trust `item.isActive` alone —
1855
- // needed for query-only hub actions (`/?start=1`) that share a path
1856
- // with another leaf (e.g. "แอปของฉัน" at `/`).
1857
- const isItemActive = highlightActiveModule && !item.subModules?.length
1858
- ? !!item.isActive
1859
- : (workspace ? samePage(pathname, itemUrl) : pathname === itemUrl) &&
1860
- !hasActiveSubItem;
1861
- return { itemUrl, activeSubItemUrl, hasActiveSubItem, isItemActive };
1986
+ const mine = active?.item === i ? active : null;
1987
+ return {
1988
+ itemUrl,
1989
+ activeSub: mine?.sub ?? null,
1990
+ activeSubPage: mine?.subPage ?? null,
1991
+ openSub: open?.item === i ? open.sub : null,
1992
+ isItemActive: !!mine && mine.sub === null,
1993
+ };
1862
1994
  });
1863
- // A group opens when its page becomes current and otherwise stays as the
1995
+ // A group opens when the page moves into it and otherwise stays as the
1864
1996
  // user left it. defaultOpen alone decided once, on the first render — and
1865
1997
  // on a direct load the sub-items arrive after permissions do, so the group
1866
1998
  // holding the current page stayed shut.
1867
- const activeKey = items
1868
- .filter((_, i) => states[i].isItemActive || states[i].hasActiveSubItem)
1869
- .map(item => item.id)
1870
- .join(",");
1871
- const [openIds, setOpenIds] = React.useState(() => new Set(activeKey ? activeKey.split(",") : []));
1999
+ const openId = open ? (items[open.item]?.id ?? "") : "";
2000
+ const [openIds, setOpenIds] = React.useState(() => new Set(openId ? [openId] : []));
1872
2001
  React.useEffect(() => {
1873
- if (!activeKey)
2002
+ if (!openId)
1874
2003
  return;
1875
2004
  setOpenIds(prev => {
1876
- const ids = activeKey.split(",");
1877
- if (ids.every(id => prev.has(id)))
2005
+ if (prev.has(openId))
1878
2006
  return prev;
1879
2007
  const next = new Set(prev);
1880
- ids.forEach(id => next.add(id));
2008
+ next.add(openId);
1881
2009
  return next;
1882
2010
  });
1883
- }, [activeKey]);
1884
- const setItemOpen = (id, open) => setOpenIds(prev => {
1885
- if (prev.has(id) === open)
2011
+ }, [openId]);
2012
+ const setItemOpen = (id, isOpen) => setOpenIds(prev => {
2013
+ if (prev.has(id) === isOpen)
1886
2014
  return prev;
1887
2015
  const next = new Set(prev);
1888
- if (open)
2016
+ if (isOpen)
1889
2017
  next.add(id);
1890
2018
  else
1891
2019
  next.delete(id);
@@ -1896,7 +2024,7 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
1896
2024
  item.group &&
1897
2025
  item.group !== items[index - 1]?.group ? (jsx("li", { className: "list-none px-3 pb-2.5 text-xs text-muted-foreground" +
1898
2026
  (index > 0 ? " pt-5" : ""), children: item.group }, `group-${item.id}`)) : null;
1899
- const { itemUrl, activeSubItemUrl, isItemActive } = states[index];
2027
+ const { itemUrl, activeSub, activeSubPage, openSub, isItemActive } = states[index];
1900
2028
  const IconComp = item.icon;
1901
2029
  const workspaceButton = workspace
1902
2030
  ? "h-auto gap-3 rounded-[10px] px-3 py-2.5 text-xs " +
@@ -1906,13 +2034,13 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
1906
2034
  : "";
1907
2035
  const icon = workspace && IconComp ? (jsx(IconComp, { className: "size-[18px] shrink-0" })) : null;
1908
2036
  const badge = workspace && item.badge !== undefined && item.badge !== "" ? (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;
1909
- return (jsxs(React.Fragment, { children: [groupHeading, jsx(Collapsible, { asChild: true, open: openIds.has(item.id), onOpenChange: open => setItemOpen(item.id, open), className: "group/collapsible", children: jsxs(SidebarMenuItem, { children: [jsx(CollapsibleTrigger, { asChild: true, children: jsx(SidebarMenuButton, { asChild: !item.subModules || item.subModules.length === 0, isActive: isItemActive, className: "data-[state=open]:text-sidebar-accent-foreground " +
2037
+ return (jsxs(React.Fragment, { children: [groupHeading, jsx(Collapsible, { asChild: true, open: openIds.has(item.id), onOpenChange: isOpen => setItemOpen(item.id, isOpen), className: "group/collapsible", children: jsxs(SidebarMenuItem, { children: [jsx(CollapsibleTrigger, { asChild: true, children: jsx(SidebarMenuButton, { asChild: !item.subModules || item.subModules.length === 0, isActive: isItemActive, className: "data-[state=open]:text-sidebar-accent-foreground " +
1910
2038
  workspaceButton, children: item.subModules && item.subModules.length > 0 ? (jsxs(Fragment$1, { children: [icon, jsx("span", { id: item.id, children: item.titleTh }), jsx(ChevronRight, { className: "ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" })] })) : (jsxs(Link, { href: itemUrl, prefetch: false, className: "flex items-center w-full min-w-0 flex-1 " +
1911
2039
  (workspace ? "gap-3" : "gap-2"), onClick: handleNavLinkClick, "aria-current": isItemActive ? "page" : undefined, children: [icon, jsx("span", { id: item.id, className: "flex-1 truncate", title: item.titleTh, children: item.titleTh }), badge] })) }) }), item.subModules && item.subModules.length > 0 && (jsx(CollapsibleContent, { children: jsx(SidebarMenuSub, { className: workspace
1912
2040
  ? "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]"
1913
- : undefined, children: item.subModules.map(subItem => {
2041
+ : undefined, children: item.subModules.map((subItem, subIndex) => {
1914
2042
  const subItemUrl = buildUrlWithCode(code, subItem.url);
1915
- const isSubItemActive = subItemUrl === activeSubItemUrl;
2043
+ const isSubItemActive = activeSub === subIndex;
1916
2044
  const SubIcon = subItem.icon;
1917
2045
  const approvalCount = approvalCounts && subItem.approvalCountKey
1918
2046
  ? (approvalCounts[subItem.approvalCountKey] ?? 0)
@@ -1920,10 +2048,10 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
1920
2048
  if (subItem.isCollapsible &&
1921
2049
  subItem.subPages &&
1922
2050
  subItem.subPages.length > 0) {
1923
- const hasActiveSubPage = subItem.subPages.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)));
1924
- return (jsx(Collapsible, { asChild: true, defaultOpen: isSubItemActive || hasActiveSubPage, className: "group/sub-collapsible", children: jsxs(SidebarMenuSubItem, { children: [jsx(CollapsibleTrigger, { asChild: true, children: jsxs(SidebarMenuSubButton, { className: "w-full", isActive: isSubItemActive || hasActiveSubPage, children: [SubIcon && (jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), jsx(ChevronDown, { className: "ml-auto size-4 transition-transform duration-200 group-data-[state=open]/sub-collapsible:rotate-180" })] }) }), jsx(CollapsibleContent, { children: jsx(SidebarMenuSub, { className: "ml-4", children: subItem.subPages.map(subPage => {
2051
+ return (jsx(Collapsible, { asChild: true, defaultOpen: isSubItemActive || openSub === subIndex, className: "group/sub-collapsible", children: jsxs(SidebarMenuSubItem, { children: [jsx(CollapsibleTrigger, { asChild: true, children: jsxs(SidebarMenuSubButton, { className: "w-full", isActive: isSubItemActive, children: [SubIcon && (jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), jsx(ChevronDown, { className: "ml-auto size-4 transition-transform duration-200 group-data-[state=open]/sub-collapsible:rotate-180" })] }) }), jsx(CollapsibleContent, { children: jsx(SidebarMenuSub, { className: "ml-4", children: subItem.subPages.map((subPage, subPageIndex) => {
1925
2052
  const subPageUrl = buildUrlWithCode(code, subPage.url);
1926
- const isSubPageActive = isPathActive(pathname, subPageUrl);
2053
+ const isSubPageActive = isSubItemActive &&
2054
+ activeSubPage === subPageIndex;
1927
2055
  const SubPageIcon = subPage.icon;
1928
2056
  return (jsx(SidebarMenuSubItem, { children: jsx(SidebarMenuSubButton, { asChild: true, className: `font-thai w-full text-sm ${isSubPageActive
1929
2057
  ? "text-sidebar-accent-foreground font-medium"
@@ -3376,6 +3504,24 @@ function PermissionProvider({ children, usePermission, fallbackRulesMap, }) {
3376
3504
  return jsx(Fragment$1, { children: children });
3377
3505
  }
3378
3506
 
3507
+ // Helper function to check if pathname matches a URL pattern
3508
+ // This prevents false matches like /reports/sales matching /sales
3509
+ // Handles paths with or without [code] prefix
3510
+ function isPathActive(pathname, url) {
3511
+ // Exact match
3512
+ if (pathname === url) {
3513
+ return true;
3514
+ }
3515
+ // Check if pathname starts with url + "/" to ensure exact segment matching
3516
+ // This prevents /reports/sales from matching /sales
3517
+ // Example: /org123/reports/sales should NOT match /org123/sales
3518
+ // But /org123/sales/overview SHOULD match /org123/sales
3519
+ if (pathname.startsWith(url + "/")) {
3520
+ return true;
3521
+ }
3522
+ return false;
3523
+ }
3524
+
3379
3525
  /** Cookie name for the cross-subdomain theme choice. */
3380
3526
  const THEME_COOKIE = "erp-theme";
3381
3527
  /**
@@ -3621,5 +3767,5 @@ const getAxiosErrorMessage = (error) => {
3621
3767
  return String(error);
3622
3768
  };
3623
3769
 
3624
- export { AccessDenied, ApiError, AppBreadcrumb, AppLayoutShell, AppSidebar, EMPLOYEE_RECORD_OK_EVENT, EMPLOYEE_RECORD_REQUIRED, EMPLOYEE_RECORD_REQUIRED_EVENT, EmployeeRequiredGate, FeatureFlagsProvider, HeaderBackground, HelpButton, HelpDialogHost, NavMain, NavTools, NavUser, OWNER_CHIP, OrganizationSwitcher, PLAN_FEATURES, PermissionGateShell, PermissionProvider, PermissionRulesProvider, ReactQueryProvider, SHELL_VERSION, SessionBackedProfileCacheSeeder, SessionRefreshTrigger, SidebarContent, SidebarFooter, SidebarHeader, SidebarHelpCard, SidebarHelpLink, SidebarProvider, SidebarTrigger, TapMenu, ThemeProvider, WORKSPACE_HEADER_VARS, WORKSPACE_LIGHT_HEADER_VARS, WorkspaceBrand, instance as axiosInstance, buildSystemLinks, buildUrlWithCode, canAccessNavPermissionRoute, clearAccessTokenCache, clearOrganizationId, closeHelp, decodeJWT, describeAttached, describeBrowser, detectHelpApp, filterNavigationModulesByPermission, filterNavigationToolsByPermission, getAccessToken, getActiveModule, getActiveSubModule, getAxiosErrorMessage, getOrganizationId, getQueryClientConfig, getRequiredChildGroups, hasPlanFeature, isLikelyTransientNetworkError, isPathActive, myRolesQueryKey, normalizePathnameForPermissionRules, openHelp, parseMyRoles, resolveUiRoutePermission, roleSummary, runGlobalRequestPrecheck, runWithTransientRetry, setOrganizationId, toApiError, unwrapApiResponse, updateOrganizationId, useClearOrgOnUserChange, useFeatureFlag, useFeatureFlags, useInvalidateQueriesOnOrgChange, useKeycloakRoles, useMyRoles, useOrganizationIdState, usePermissionRules, useSessionRefresh, useSidebar, useSidebarToggle };
3770
+ export { AccessDenied, ApiError, AppBreadcrumb, AppLayoutShell, AppSidebar, EMPLOYEE_RECORD_OK_EVENT, EMPLOYEE_RECORD_REQUIRED, EMPLOYEE_RECORD_REQUIRED_EVENT, EmployeeRequiredGate, FeatureFlagsProvider, HeaderBackground, HelpButton, HelpDialogHost, NavMain, NavTools, NavUser, OWNER_CHIP, OrganizationSwitcher, PLAN_FEATURES, PermissionGateShell, PermissionProvider, PermissionRulesProvider, ReactQueryProvider, SHELL_VERSION, SessionBackedProfileCacheSeeder, SessionRefreshTrigger, SidebarContent, SidebarFooter, SidebarHeader, SidebarHelpCard, SidebarHelpLink, SidebarProvider, SidebarTrigger, TapMenu, ThemeProvider, WORKSPACE_HEADER_VARS, WORKSPACE_LIGHT_HEADER_VARS, WorkspaceBrand, instance as axiosInstance, buildSystemLinks, buildUrlWithCode, canAccessNavPermissionRoute, clearAccessTokenCache, clearOrganizationId, closeHelp, decodeJWT, describeAttached, describeBrowser, detectHelpApp, filterNavigationModulesByPermission, filterNavigationToolsByPermission, getAccessToken, getActiveModule, getActiveSubModule, getAxiosErrorMessage, getOrganizationId, getQueryClientConfig, getRequiredChildGroups, hasPlanFeature, isLikelyTransientNetworkError, isPathActive, myRolesQueryKey, normalizePathnameForPermissionRules, openHelp, parseMyRoles, resolveActiveNav, resolveSidebarNav, resolveUiRoutePermission, roleSummary, runGlobalRequestPrecheck, runWithTransientRetry, setOrganizationId, toApiError, unwrapApiResponse, updateOrganizationId, useClearOrgOnUserChange, useFeatureFlag, useFeatureFlags, useInvalidateQueriesOnOrgChange, useKeycloakRoles, useMyRoles, useOrganizationIdState, usePermissionRules, useSessionRefresh, useSidebar, useSidebarToggle };
3625
3771
  //# sourceMappingURL=index.esm.js.map