@eintrek/erp-shell
v0.1.32
Published
Shared frontend platform for ERP apps — HTTP client, providers, navigation, hooks.
Maintainers
Readme
@eintrek/erp-shell
Shared frontend platform for ERP apps (erp-accounting, erp-hr, erp-platform, …).
Holds the cross-app glue that was previously duplicated in every consumer: HTTP client, auth/token plumbing, organization-id context, app chrome (sidebar/header), context providers, common hooks.
Sibling package to
@eintrek/erp-theme(UI primitives — Button, Form, Dialog, etc.).erp-shelldepends onerp-themefor visual components.
Status — what's in here today
Phase 1 ✅ — HTTP client + organization-id
axiosInstance— preconfigured Axios with auth token + X-Organization-ID injection, single-flight 401 refresh, GR precheck validatorApiError,toApiError,isAxiosError,getAxiosErrorMessagegetAccessToken,clearAccessTokenCacheunwrapApiResponsegetOrganizationId,setOrganizationId,clearOrganizationId,updateOrganizationId
Phase 2a ✅ — Lightweight context providers (zero / low coupling)
ThemeProvider— wraps next-themesFeatureFlagsProvider+useFeatureFlags+useFeatureFlag+FeatureFlagstypeReactQueryProvider— preconfigured with sharedgetQueryClientConfig(5 min staleTime, 2 retries, swallows "no org context" noise)getQueryClientConfigexported standalone for callers that want to bring their own QueryClient
Consumer apps' @/context/{theme,feature-flags,react-query}-provider
shims now re-export from this package.
Phase 2b (partial) ✅ — Smaller heavy providers (DI design)
PermissionRulesProvider(generic) +usePermissionRules— consumer passes indata(from itsuseUiRoutePermissions()hook),isLoading,isError, andfallbackPolicies(its static JSON).SessionBackedProfileCacheSeeder<T>(generic) — consumer passesqueryKey+mapper(session)to seed React Query cache from NextAuth session.- App-side wrappers in
context/{permission-rules-context,session-backed-profile-cache-seeder}.tsxnow inject local hooks into the shell generics.
Phase 2b (cont'd) ✅ — erp-provider state machine + side-effects extracted
Rather than move the whole 471-line provider (tightly coupled to org / employee / department / position data fetchers), the stable bits moved to shell:
useOrganizationIdState(initialId, { setOrganizationCookie, deleteOrganizationCookie })→ returns{ organizationId, setOrganizationId, hasOrganizationContext }. Owns the cookie + localStorage + state precedence machine + transient-retry on cookie writes.useInvalidateQueriesOnOrgChange(organizationId)— effect that callsqueryClient.invalidateQueries()when the selected org changes (skips initial mount).useClearOrgOnUserChange(userId, setOrganizationId)— effect that resets the selected org when the user switches.runWithTransientRetry,isLikelyTransientNetworkError— small fetch retry helper.
Each app's context/erp-provider.tsx now composes these — dropped from
471 → 311 lines (~160 dedup × 2 apps).
Phase 5a ✅ — Pure navigation chrome
8 self-contained sidebar pieces (no app context, no per-app navigation data):
SidebarProvider + useSidebar, SidebarContent, SidebarFooter,
SidebarTrigger, HeaderBackground, useSidebarToggle (zustand), TapMenu,
plus navigation-helpers (isPathActive). Consumer shims at
shared/components/ui/navigation/*.{ts,tsx} re-export from the shell.
Phase 5b ✅ — Navigation-data split + data-shape-coupled components
Shell now owns the shapes + helpers for navigation; apps own the data.
- Types:
Module,SubPage,SubModule(withapprovalCountKey?: stringslot for app narrowing),Tool,Company,NavigationData. - Helpers:
canAccessNavPermissionRoute,filterNavigationToolsByPermission,filterNavigationModulesByPermission,getActiveModule(pathname, modules),getActiveSubModule(pathname, modules),buildUrlWithCode(code, url)(absolutehttp(s)://URLs pass through;{code}placeholder is replaced). - Components:
NavTools(reads Tool[] + buildUrlWithCode internally),AppBreadcrumb(acceptslabelsprop so per-app Thai overrides stay local).
Each app's navigation-data.ts now (1) imports types/helpers from shell,
(2) declares its own navigationData const + breadcrumbLabels, (3) wraps
getActiveModule/getActiveSubModule to keep the 1-arg signature, and
(4) re-exports the shell types so existing callsites don't change.
Phase 5c ✅ — Context-coupled chrome (props-driven)
Five components moved to shell as props-driven components. Each
consumer app keeps a thin local wrapper that pulls from context
(useERP, usePermissionRules, useApprovalCounts, etc.) and passes
the data in as props:
NavUser— acceptsorganization?,profileHref?,selectOrganizationHref?,signInRedirectHref?. ReadsuseSessionuseThemeinternally.
NavMain— acceptsitems,orgCode?,closeOnNavigation?,approvalCounts?(HR uses the last one for queue badges).OrganizationSwitcher— acceptsactiveOrganization,organizations,isContextLoading?,isLoadingOrgs?,setOrganizationCookie,registerOrganizationHref?. Server action injected per app.SidebarHeader— generic slot (children), so each app drops its own<OrganizationSwitcher>(or any brand) in.AppSidebar— orchestrator that takesmodules,tools,activeModule,orgCode,approvalCounts, plusheader+footerslots.
Each app's shared/components/ui/navigation/{nav-user,nav-main,organization-switcher,sidebar-header,app-sidebar}.tsx
shrank from a combined ~956 lines to ~220 lines of pure wrapper code
(shell holds the ~700 line implementation once, instead of duplicated
across 2 apps).
session-monitor.tsx (1541 lines) was deleted in both repos — it
was a dead diagnostics page with no consumer imports.
Phase 3a ✅ — Pure / session-only hooks + jwt-utils
decodeJWT— base64url-safe JWT payload decodeuseSessionRefresh+SessionRefreshTrigger— periodic NextAuthsession.update()to keep the Keycloak access token freshuseKeycloakRoles— parses thegroupsclaim into organization/role pairs withhasRole/hasRoleInOrganization/belongsToOrganization/ etc.
Consumer apps' @/shared/lib/jwt-utils, @/shared/hooks/use-session-refresh,
@/shared/hooks/use-keycloak-roles shims now re-export from this package.
Roadmap
| Phase | Scope | Files to move from each app |
|-------|-----------------------------------------------------------------------|------------------------------------------------------------------|
| 1 | ✅ HTTP client | client/http/*, client/utils/organization-id.ts |
| 2a | ✅ Lightweight providers | context/{theme,feature-flags,react-query}-provider.tsx |
| 2b | ✅ Heavy providers — DI design: PermissionRulesProvider (generic), SessionBackedProfileCacheSeeder<T> (generic), erp-provider state-machine + side-effect hooks | context/{permission-rules-context,session-backed-profile-cache-seeder,erp-provider}.tsx |
| 3a | ✅ Pure / session hooks + jwt-utils | shared/hooks/{use-session-refresh,use-keycloak-roles}.ts, shared/lib/jwt-utils.ts |
| 3b | Permission hooks (after Phase 2b) | shared/hooks/{use-permission,use-api-policy-access,use-route-access}.ts (each depends on permission-rules-context and/or erp-provider) |
| 4 | Data table primitives — already lives in @eintrek/erp-theme. erp-accounting migrated to shim; erp-hr blocked on bumping @eintrek/erp-theme 1.0.4 → 1.4.0 (separate effort, not via erp-shell). | shared/components/data-table/** (no shell move needed) |
| 5a | ✅ Pure sidebar pieces (provider, content/footer/trigger, tap-menu, header-background, useSidebarToggle, navigation-helpers) | shared/components/ui/navigation/{sidebar-provider,sidebar-content,sidebar-footer,sidebar-trigger,header-background,use-sidebar-toggle,tap-menu,navigation-helpers}.* |
| 5b | ✅ Navigation-data split + 2 data-shape-coupled components (NavTools, AppBreadcrumb) | shell now owns types + filter helpers; per-app navigation-data.ts only owns rows + Thai labels |
| 5c | ✅ Context-coupled chrome (props-driven): NavUser, NavMain (HR badges), OrganizationSwitcher, SidebarHeader (slot), AppSidebar (orchestrator). session-monitor skipped — dead file. | each app keeps a thin wrapper at shared/components/ui/navigation/* |
App-specific files that stay in each app:
navigation-data.ts(menu lists differ per app)domains/**(sales / hr / project / etc.)- API clients for app-specific endpoints
Development
Requires Node 22.13+ and pnpm 11 (same as other ERP packages).
pnpm install
pnpm build # rollup → dist/{index.js,index.esm.js,index.d.ts}
pnpm dev # watch
pnpm pack # produce .tgz for local consumer testingLocal linking into a consumer (during migration)
Easiest dev loop: tarball install (no symlinks, no peer-dep surprises):
# 1) Build + pack here
cd /path/to/erp-shell
pnpm pack # produces eintrek-erp-shell-0.1.0.tgz
# 2) Install in consumer
cd /path/to/erp-accounting
pnpm add file:../erp-shell/eintrek-erp-shell-0.1.0.tgzOr use pnpm workspace: protocol once the apps + packages share a
workspace root.
Migration recipe for consumers
// Before
import axiosInstance, { getAxiosErrorMessage, ApiError } from "@/client/axios"
import { getOrganizationId } from "@/client/utils/organization-id"
// After (Phase 1)
import {
axiosInstance,
getAxiosErrorMessage,
ApiError,
getOrganizationId,
} from "@eintrek/erp-shell"Once every callsite migrates, delete the corresponding files under
client/http/, client/utils/organization-id.ts, and the
client/axios.ts barrel.
