@eintrek/erp-shell 0.1.59 → 0.1.60
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/hooks/use-organization-id-state.d.ts +5 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.esm.js +366 -134
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +369 -130
- package/dist/index.js.map +1 -1
- package/dist/navigation/app-layout-shell.d.ts +16 -1
- package/dist/navigation/nav-row.d.ts +24 -0
- package/dist/navigation/sidebar-components.d.ts +5 -0
- package/dist/navigation/url-org-gate.d.ts +56 -0
- package/dist/organization/active-organization.d.ts +33 -0
- package/dist/providers/query-scope.d.ts +25 -0
- package/dist/providers/react-query-config.d.ts +3 -1
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
package/dist/index.esm.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
import * as React from 'react';
|
|
3
3
|
import React__default, { useRef, useEffect, useMemo, useState, useCallback, createContext, useContext, Fragment, useSyncExternalStore, useTransition, Suspense } from 'react';
|
|
4
4
|
import { useSession, signOut } from 'next-auth/react';
|
|
5
|
-
import { useQueryClient, useQuery, QueryClientContext, QueryClient, MutationCache, QueryCache, QueryClientProvider } from '@tanstack/react-query';
|
|
5
|
+
import { useQueryClient, useQuery, QueryClientContext, QueryClient, hashKey, MutationCache, QueryCache, QueryClientProvider } from '@tanstack/react-query';
|
|
6
6
|
import { jsx, jsxs, Fragment as Fragment$1 } from 'react/jsx-runtime';
|
|
7
|
-
import { cn, Button, Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage, Avatar, AvatarImage, AvatarFallback, Collapsible, CollapsibleTrigger, CollapsibleContent, Badge, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuLabel, DropdownMenuItem, DropdownMenuSeparator, Skeleton, Dialog, DialogContent, DialogHeader, DialogTitle, Label, Textarea, DialogFooter, Card, CardHeader, CardContent } from '@eintrek/erp-theme';
|
|
8
|
-
import { PanelLeftClose, Menu, ChevronRight, Building2, Palette, ChevronUp, Sun, Moon, Monitor, Globe, User, LayoutGrid, ExternalLink, CircleHelp, Loader2, LogOut, Calculator, Warehouse, FolderKanban, Users, Store, Headset, ChevronDown, Plus, ChevronsUpDown, ImagePlus, X, CircleCheck, ArrowLeft,
|
|
7
|
+
import { cn, Button, Tooltip, TooltipTrigger, TooltipContent, Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage, Avatar, AvatarImage, AvatarFallback, Collapsible, CollapsibleTrigger, CollapsibleContent, Badge, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuLabel, DropdownMenuItem, DropdownMenuSeparator, Skeleton, Dialog, DialogContent, DialogHeader, DialogTitle, Label, Textarea, DialogFooter, Card, CardHeader, CardContent } from '@eintrek/erp-theme';
|
|
8
|
+
import { PanelLeftClose, Menu, ChevronRight, Building2, Palette, ChevronUp, Sun, Moon, Monitor, Globe, User, LayoutGrid, ExternalLink, CircleHelp, Loader2, LogOut, Calculator, Warehouse, FolderKanban, Users, Store, Headset, ChevronDown, Plus, ChevronsUpDown, ImagePlus, X, CircleCheck, ArrowLeft, Ban, Home, ArrowRight, Compass, ArrowUpRight, ContactRound, UserPlus } from 'lucide-react';
|
|
9
9
|
import { create } from 'zustand';
|
|
10
10
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
|
11
11
|
import { useRouter, usePathname, useParams } from 'next/navigation';
|
|
@@ -240,6 +240,87 @@ async function runWithTransientRetry(fn, attempts = 3) {
|
|
|
240
240
|
throw last;
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
/**
|
|
244
|
+
* The organisation this browser tab is working in.
|
|
245
|
+
*
|
|
246
|
+
* Every request carries an X-Organization-ID header, and every cached query
|
|
247
|
+
* belongs to one organisation. Both used to come from the `erp.organizationId`
|
|
248
|
+
* cookie (then localStorage), which is the organisation the BROWSER last had
|
|
249
|
+
* active — shared by every tab and, on antniti.com, by every app. A tab
|
|
250
|
+
* showing /O26000021/… therefore sent whatever another tab, or another app,
|
|
251
|
+
* had picked last, and the URL lost.
|
|
252
|
+
*
|
|
253
|
+
* The tab's own value is set by `useOrganizationIdState` — the state that
|
|
254
|
+
* AppLayoutShell drives to the organisation the URL names — and read first by
|
|
255
|
+
* `getRequestOrganizationId()`. Until a tab has one (a cold load before the
|
|
256
|
+
* provider mounts), localStorage and then the cookie answer as before.
|
|
257
|
+
*
|
|
258
|
+
* Module state, so browser-only: on the server one module instance serves
|
|
259
|
+
* every request, and a value written there would leak between users.
|
|
260
|
+
*/
|
|
261
|
+
const ORGANIZATION_ID_KEY = "erp.organizationId";
|
|
262
|
+
let tabOrganizationId = null;
|
|
263
|
+
const isBrowser$1 = () => typeof window !== "undefined";
|
|
264
|
+
function normalise(id) {
|
|
265
|
+
if (id === null || id === undefined)
|
|
266
|
+
return null;
|
|
267
|
+
const value = String(id).trim();
|
|
268
|
+
return value === "" ? null : value;
|
|
269
|
+
}
|
|
270
|
+
/** The organisation this tab is working in, or null before it has one. */
|
|
271
|
+
function getTabOrganizationId() {
|
|
272
|
+
return isBrowser$1() ? tabOrganizationId : null;
|
|
273
|
+
}
|
|
274
|
+
/** Record the organisation this tab is working in (browser only). */
|
|
275
|
+
function setTabOrganizationId(id) {
|
|
276
|
+
if (!isBrowser$1())
|
|
277
|
+
return;
|
|
278
|
+
tabOrganizationId = normalise(id);
|
|
279
|
+
}
|
|
280
|
+
function readOrganizationCookie() {
|
|
281
|
+
if (!isBrowser$1())
|
|
282
|
+
return null;
|
|
283
|
+
const prefix = `${ORGANIZATION_ID_KEY}=`;
|
|
284
|
+
const entry = document.cookie
|
|
285
|
+
.split(";")
|
|
286
|
+
.map(part => part.trim())
|
|
287
|
+
.find(part => part.startsWith(prefix));
|
|
288
|
+
if (!entry)
|
|
289
|
+
return null;
|
|
290
|
+
const raw = entry.slice(prefix.length);
|
|
291
|
+
try {
|
|
292
|
+
return normalise(decodeURIComponent(raw));
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return normalise(raw);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function readOrganizationStorage() {
|
|
299
|
+
if (!isBrowser$1())
|
|
300
|
+
return null;
|
|
301
|
+
try {
|
|
302
|
+
return normalise(localStorage.getItem(ORGANIZATION_ID_KEY));
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* The organisation a request from this tab belongs to: the tab's own, then
|
|
310
|
+
* localStorage, then the cookie. Use it for the X-Organization-ID header —
|
|
311
|
+
* and read it before any `await` in the interceptor, so the header names the
|
|
312
|
+
* organisation that was active when the request was made.
|
|
313
|
+
*
|
|
314
|
+
* localStorage before the cookie: setOrganizationId writes localStorage
|
|
315
|
+
* synchronously and then awaits a server action for the cookie, so during
|
|
316
|
+
* that round trip the cookie still names the previous organisation.
|
|
317
|
+
*/
|
|
318
|
+
function getRequestOrganizationId() {
|
|
319
|
+
return (getTabOrganizationId() ??
|
|
320
|
+
readOrganizationStorage() ??
|
|
321
|
+
readOrganizationCookie());
|
|
322
|
+
}
|
|
323
|
+
|
|
243
324
|
const SELECTED_ORG_STORAGE_KEY = "erp.organizationId";
|
|
244
325
|
const SYSTEM_ROUTE_FIRST_SEGMENTS = [
|
|
245
326
|
"auth",
|
|
@@ -257,10 +338,15 @@ const SYSTEM_ROUTE_FIRST_SEGMENTS = [
|
|
|
257
338
|
*
|
|
258
339
|
* On set, writes through to localStorage + cookie before flipping React state
|
|
259
340
|
* so all readers see a consistent value across navigations.
|
|
341
|
+
*
|
|
342
|
+
* The tab's own organisation (organization/active-organization.ts) follows
|
|
343
|
+
* this state, and it is written synchronously — in the initialiser and at the
|
|
344
|
+
* start of every set — because requests read it: an effect would run after
|
|
345
|
+
* the children's effects, which is where their queries start.
|
|
260
346
|
*/
|
|
261
347
|
function useOrganizationIdState(initialOrganizationId, actions) {
|
|
262
348
|
const previousOrgIdRef = useRef(initialOrganizationId || null);
|
|
263
|
-
|
|
349
|
+
function readInitialOrganizationId() {
|
|
264
350
|
if (initialOrganizationId) {
|
|
265
351
|
if (typeof window !== "undefined") {
|
|
266
352
|
setOrganizationId(initialOrganizationId);
|
|
@@ -292,8 +378,16 @@ function useOrganizationIdState(initialOrganizationId, actions) {
|
|
|
292
378
|
console.error("[useOrganizationIdState] Error reading localStorage:", error);
|
|
293
379
|
}
|
|
294
380
|
return null;
|
|
381
|
+
}
|
|
382
|
+
const [organizationIdState, setOrganizationIdState] = useState(() => {
|
|
383
|
+
const initial = readInitialOrganizationId();
|
|
384
|
+
setTabOrganizationId(initial);
|
|
385
|
+
return initial;
|
|
295
386
|
});
|
|
296
387
|
const setOrganizationId$1 = useCallback(async (id) => {
|
|
388
|
+
// Before the cookie round trip: from here on this tab's requests are
|
|
389
|
+
// the new organisation's, whatever the shared cookie still says.
|
|
390
|
+
setTabOrganizationId(id);
|
|
297
391
|
try {
|
|
298
392
|
if (id === null) {
|
|
299
393
|
setOrganizationId(null);
|
|
@@ -316,6 +410,7 @@ function useOrganizationIdState(initialOrganizationId, actions) {
|
|
|
316
410
|
useEffect(() => {
|
|
317
411
|
if (initialOrganizationId &&
|
|
318
412
|
initialOrganizationId !== String(organizationIdState)) {
|
|
413
|
+
setTabOrganizationId(initialOrganizationId);
|
|
319
414
|
setOrganizationIdState(initialOrganizationId);
|
|
320
415
|
previousOrgIdRef.current = initialOrganizationId;
|
|
321
416
|
}
|
|
@@ -587,6 +682,46 @@ const TapMenu = ({ tabDatas, starterTab, currentTabIndex, variant = "main", }) =
|
|
|
587
682
|
}) }), tabDatas[currentTab]?.page] }));
|
|
588
683
|
};
|
|
589
684
|
|
|
685
|
+
/**
|
|
686
|
+
* How a sidebar row looks, apart from its layout.
|
|
687
|
+
*
|
|
688
|
+
* Exactly one row is lit — the page's own (resolveSidebarNav). Until 0.1.60 a
|
|
689
|
+
* row under the pointer took the lit row's fill and colour, and an open group
|
|
690
|
+
* (or a one-line row once clicked, which was a collapsible too) took its
|
|
691
|
+
* colour, so the menu showed several "current pages" at once.
|
|
692
|
+
*/
|
|
693
|
+
/** A row under the pointer: a faint neutral tint, never the lit row's look. */
|
|
694
|
+
const NAV_ROW_HOVER = "hover:bg-sidebar-foreground/5 hover:text-sidebar-foreground";
|
|
695
|
+
/** The lit row: the accent fill and text. The pointer changes nothing. */
|
|
696
|
+
const NAV_ROW_LIT = "bg-sidebar-accent text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground";
|
|
697
|
+
/** Fill and colour for a row, lit or not. */
|
|
698
|
+
function navRowTone(lit) {
|
|
699
|
+
return lit ? NAV_ROW_LIT : NAV_ROW_HOVER;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* A group opens and shuts; any other row is a plain link with no open state.
|
|
703
|
+
*/
|
|
704
|
+
function isNavGroup(item) {
|
|
705
|
+
return (item.subModules?.length ?? 0) > 0;
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* The open groups after `id` is opened or shut. An id that is not a group's
|
|
709
|
+
* is ignored — a one-line row has nothing to open.
|
|
710
|
+
*/
|
|
711
|
+
function setNavGroupOpen(openIds, items, id, isOpen) {
|
|
712
|
+
if (!id || !items.some(item => item.id === id && isNavGroup(item))) {
|
|
713
|
+
return openIds;
|
|
714
|
+
}
|
|
715
|
+
if (openIds.has(id) === isOpen)
|
|
716
|
+
return openIds;
|
|
717
|
+
const next = new Set(openIds);
|
|
718
|
+
if (isOpen)
|
|
719
|
+
next.add(id);
|
|
720
|
+
else
|
|
721
|
+
next.delete(id);
|
|
722
|
+
return next;
|
|
723
|
+
}
|
|
724
|
+
|
|
590
725
|
/**
|
|
591
726
|
* Lightweight sidebar primitives — plain styled wrappers, no `useSidebar()`
|
|
592
727
|
* context dependency. The full `@eintrek/erp-theme` sidebar suite ships its
|
|
@@ -610,21 +745,25 @@ const SidebarMenuButton = React.forwardRef(({ className, asChild = false, isActi
|
|
|
610
745
|
default: "h-8 text-sm",
|
|
611
746
|
lg: "h-12 text-sm",
|
|
612
747
|
};
|
|
613
|
-
const baseClassName = cn("flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left outline-none transition-colors
|
|
614
|
-
|
|
748
|
+
const baseClassName = cn("flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-sidebar-ring disabled:pointer-events-none disabled:opacity-50", sizeClasses[size], navRowTone(isActive), isActive && "font-medium", className);
|
|
749
|
+
let row;
|
|
615
750
|
if (asChild) {
|
|
616
751
|
if (!children || !React.isValidElement(children)) {
|
|
617
752
|
console.error("SidebarMenuButton: Invalid children for asChild prop", children);
|
|
618
753
|
return null;
|
|
619
754
|
}
|
|
620
|
-
|
|
755
|
+
row = React.cloneElement(children, {
|
|
621
756
|
className: cn(baseClassName, children.props
|
|
622
757
|
?.className),
|
|
623
|
-
title: tooltip,
|
|
624
758
|
...props,
|
|
625
759
|
});
|
|
626
760
|
}
|
|
627
|
-
|
|
761
|
+
else {
|
|
762
|
+
row = (jsx("button", { ref: ref, className: baseClassName, ...props, children: children }));
|
|
763
|
+
}
|
|
764
|
+
if (!tooltip)
|
|
765
|
+
return row;
|
|
766
|
+
return (jsxs(Tooltip, { children: [jsx(TooltipTrigger, { asChild: true, children: row }), jsx(TooltipContent, { side: "right", sideOffset: 10, children: tooltip })] }));
|
|
628
767
|
});
|
|
629
768
|
SidebarMenuButton.displayName = "SidebarMenuButton";
|
|
630
769
|
const SidebarMenuSub = React.forwardRef(({ className, ...props }, ref) => (jsx("ul", { ref: ref, className: cn("mx-3.5 flex min-w-0 bg-sidebar translate-x-px flex-col gap-1 px-2.5 py-0.5", className), ...props })));
|
|
@@ -632,7 +771,7 @@ SidebarMenuSub.displayName = "SidebarMenuSub";
|
|
|
632
771
|
const SidebarMenuSubItem = React.forwardRef(({ ...props }, ref) => jsx("li", { ref: ref, ...props }));
|
|
633
772
|
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
|
|
634
773
|
const SidebarMenuSubButton = React.forwardRef(({ asChild = false, size = "md", isActive, className, children, ...props }, ref) => {
|
|
635
|
-
const baseClassName = cn("flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none transition-colors
|
|
774
|
+
const baseClassName = cn("flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-sidebar-ring disabled:pointer-events-none disabled:opacity-50", navRowTone(!!isActive), size === "sm" && "text-xs", size === "md" && "text-sm", className);
|
|
636
775
|
if (asChild) {
|
|
637
776
|
return React.cloneElement(children, {
|
|
638
777
|
className: cn(baseClassName, children.props
|
|
@@ -947,7 +1086,7 @@ function NavTools({ tools, closeOnNavigation = false }) {
|
|
|
947
1086
|
// /reports/approval doesn't light up the /reports tool.
|
|
948
1087
|
const isToolActive = pathname === toolUrl;
|
|
949
1088
|
const Icon = tool.icon;
|
|
950
|
-
return (jsx(SidebarMenuItem, { children: jsx(SidebarMenuButton, { asChild: true, isActive: isToolActive, className: "font-thai data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", children: jsxs(Link, { href: toolUrl, className: "flex items-center gap-2 w-full", onClick: handleNavLinkClick, children: [jsx(Icon, { className: "sidebar-module-icon" }), jsx("span", { children: tool.titleTh })] }) }) }, tool.id));
|
|
1089
|
+
return (jsx(SidebarMenuItem, { children: jsx(SidebarMenuButton, { asChild: true, isActive: isToolActive, className: "font-thai data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", children: jsxs(Link, { href: toolUrl, className: "flex items-center gap-2 w-full", onClick: handleNavLinkClick, "aria-current": isToolActive ? "page" : undefined, children: [jsx(Icon, { className: "sidebar-module-icon" }), jsx("span", { children: tool.titleTh })] }) }) }, tool.id));
|
|
951
1090
|
}) }) }));
|
|
952
1091
|
}
|
|
953
1092
|
|
|
@@ -1276,16 +1415,6 @@ const runGlobalRequestPrecheck = (config) => {
|
|
|
1276
1415
|
const isBrowser = () => typeof window !== "undefined";
|
|
1277
1416
|
const isAuthEndpoint = (url) => !!url && url.includes("/api/auth/");
|
|
1278
1417
|
const isAuthPage = () => isBrowser() && window.location.pathname.startsWith("/auth/");
|
|
1279
|
-
const getOrgFromCookie = () => {
|
|
1280
|
-
if (!isBrowser())
|
|
1281
|
-
return null;
|
|
1282
|
-
const cookies = document.cookie.split(";").map(cookie => cookie.trim());
|
|
1283
|
-
const orgCookie = cookies.find(cookie => cookie.startsWith("erp.organizationId="));
|
|
1284
|
-
if (!orgCookie)
|
|
1285
|
-
return null;
|
|
1286
|
-
const raw = orgCookie.substring("erp.organizationId=".length);
|
|
1287
|
-
return raw ? decodeURIComponent(raw) : null;
|
|
1288
|
-
};
|
|
1289
1418
|
const redirectToLogout = async () => {
|
|
1290
1419
|
if (!isBrowser() || isAuthPage())
|
|
1291
1420
|
return;
|
|
@@ -1320,23 +1449,16 @@ instance.interceptors.request.use(async (config) => {
|
|
|
1320
1449
|
}
|
|
1321
1450
|
if (!isBrowser())
|
|
1322
1451
|
return config;
|
|
1452
|
+
// Read before the token await: the header must name the organisation that
|
|
1453
|
+
// was active when the request was made (and under which its query is
|
|
1454
|
+
// cached), not whatever is active once the token resolves.
|
|
1455
|
+
const organizationId = getRequestOrganizationId();
|
|
1323
1456
|
const token = await getAccessToken(false);
|
|
1324
1457
|
if (token) {
|
|
1325
1458
|
config.headers.Authorization = `Bearer ${token}`;
|
|
1326
1459
|
}
|
|
1327
|
-
//
|
|
1328
|
-
//
|
|
1329
|
-
// Both are written by the same state machine, but not at the same moment:
|
|
1330
|
-
// setOrganizationId writes localStorage synchronously and then awaits a
|
|
1331
|
-
// server action for the cookie. Preferring the cookie meant every request
|
|
1332
|
-
// fired during that round trip carried the previous organisation — while
|
|
1333
|
-
// the query params, built from React state, already carried the new one.
|
|
1334
|
-
//
|
|
1335
|
-
// The backend scopes by this header and ignores organization_id in the
|
|
1336
|
-
// query, so those requests asked for organisation A and were handed B, and
|
|
1337
|
-
// the answer landed in the cache under A's key. No amount of cache clearing
|
|
1338
|
-
// reaches that: the entry is wrong, not stale.
|
|
1339
|
-
const organizationId = getOrganizationId() || getOrgFromCookie();
|
|
1460
|
+
// The tab's own organisation, then localStorage, then the cookie — see
|
|
1461
|
+
// getRequestOrganizationId.
|
|
1340
1462
|
if (organizationId) {
|
|
1341
1463
|
config.headers["X-Organization-ID"] = organizationId;
|
|
1342
1464
|
}
|
|
@@ -1416,7 +1538,7 @@ function platformApiBase(app) {
|
|
|
1416
1538
|
* This package's version, attached to help messages so the back office can
|
|
1417
1539
|
* tell which shell a report came from. Keep in step with package.json.
|
|
1418
1540
|
*/
|
|
1419
|
-
const SHELL_VERSION = "0.1.
|
|
1541
|
+
const SHELL_VERSION = "0.1.60";
|
|
1420
1542
|
|
|
1421
1543
|
const HELP_APP_LABEL = {
|
|
1422
1544
|
platform: "แพลตฟอร์ม",
|
|
@@ -2021,30 +2143,18 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
|
|
|
2021
2143
|
// A group opens when the page moves into it and otherwise stays as the
|
|
2022
2144
|
// user left it. defaultOpen alone decided once, on the first render — and
|
|
2023
2145
|
// on a direct load the sub-items arrive after permissions do, so the group
|
|
2024
|
-
// holding the current page stayed shut.
|
|
2025
|
-
|
|
2146
|
+
// holding the current page stayed shut. Only groups have an open state: a
|
|
2147
|
+
// one-line row is a plain link (0.1.60 — it was a collapsible too, and a
|
|
2148
|
+
// click left it "open", drawn in the accent colour for the session).
|
|
2149
|
+
const openItem = open ? items[open.item] : undefined;
|
|
2150
|
+
const openId = openItem && isNavGroup(openItem) ? openItem.id : "";
|
|
2026
2151
|
const [openIds, setOpenIds] = React.useState(() => new Set(openId ? [openId] : []));
|
|
2027
2152
|
React.useEffect(() => {
|
|
2028
2153
|
if (!openId)
|
|
2029
2154
|
return;
|
|
2030
|
-
setOpenIds(prev =>
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
const next = new Set(prev);
|
|
2034
|
-
next.add(openId);
|
|
2035
|
-
return next;
|
|
2036
|
-
});
|
|
2037
|
-
}, [openId]);
|
|
2038
|
-
const setItemOpen = (id, isOpen) => setOpenIds(prev => {
|
|
2039
|
-
if (prev.has(id) === isOpen)
|
|
2040
|
-
return prev;
|
|
2041
|
-
const next = new Set(prev);
|
|
2042
|
-
if (isOpen)
|
|
2043
|
-
next.add(id);
|
|
2044
|
-
else
|
|
2045
|
-
next.delete(id);
|
|
2046
|
-
return next;
|
|
2047
|
-
});
|
|
2155
|
+
setOpenIds(prev => setNavGroupOpen(prev, items, openId, true));
|
|
2156
|
+
}, [openId, items]);
|
|
2157
|
+
const setItemOpen = (id, isOpen) => setOpenIds(prev => setNavGroupOpen(prev, items, id, isOpen));
|
|
2048
2158
|
return (jsx(SidebarGroup, { "data-sidebar": true, className: workspace ? "p-0" : undefined, children: jsx(SidebarMenu, { className: workspace ? "gap-[3px]" : undefined, children: items.map((item, index) => {
|
|
2049
2159
|
const groupHeading = workspace &&
|
|
2050
2160
|
item.group &&
|
|
@@ -2060,9 +2170,15 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
|
|
|
2060
2170
|
: "";
|
|
2061
2171
|
const icon = workspace && IconComp ? (jsx(IconComp, { className: "size-[18px] shrink-0" })) : null;
|
|
2062
2172
|
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;
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2173
|
+
if (!isNavGroup(item)) {
|
|
2174
|
+
// A one-line row: a link, lit on its own page only. No open
|
|
2175
|
+
// state, no title tooltip.
|
|
2176
|
+
return (jsxs(React.Fragment, { children: [groupHeading, jsx(SidebarMenuItem, { children: jsx(SidebarMenuButton, { asChild: true, isActive: isItemActive, className: workspaceButton, children: jsxs(Link, { href: itemUrl, prefetch: false, className: "flex items-center w-full min-w-0 flex-1 " +
|
|
2177
|
+
(workspace ? "gap-3" : "gap-2"), onClick: handleNavLinkClick, "aria-current": isItemActive ? "page" : undefined, children: [icon, jsx("span", { id: item.id, className: "flex-1 truncate", children: item.titleTh }), badge] }) }) })] }, item.id));
|
|
2178
|
+
}
|
|
2179
|
+
// A group: its header opens and shuts it (the chevron says which).
|
|
2180
|
+
// An open group is not the current page — only a row in it is.
|
|
2181
|
+
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: jsxs(SidebarMenuButton, { isActive: isItemActive, className: workspaceButton, children: [icon, jsx("span", { id: item.id, className: "flex-1 truncate text-left", children: item.titleTh }), jsx(ChevronRight, { className: "ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" })] }) }), item.subModules && (jsx(CollapsibleContent, { children: jsx(SidebarMenuSub, { className: workspace
|
|
2066
2182
|
? "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]"
|
|
2067
2183
|
: undefined, children: item.subModules.map((subItem, subIndex) => {
|
|
2068
2184
|
const subItemUrl = buildUrlWithCode(code, subItem.url);
|
|
@@ -2074,20 +2190,24 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
|
|
|
2074
2190
|
if (subItem.isCollapsible &&
|
|
2075
2191
|
subItem.subPages &&
|
|
2076
2192
|
subItem.subPages.length > 0) {
|
|
2077
|
-
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
|
|
2193
|
+
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 &&
|
|
2194
|
+
activeSubPage === null, children: [SubIcon && (jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", 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) => {
|
|
2078
2195
|
const subPageUrl = buildUrlWithCode(code, subPage.url);
|
|
2079
2196
|
const isSubPageActive = isSubItemActive &&
|
|
2080
2197
|
activeSubPage === subPageIndex;
|
|
2081
2198
|
const SubPageIcon = subPage.icon;
|
|
2082
|
-
return (jsx(SidebarMenuSubItem, { children: jsx(SidebarMenuSubButton, { asChild: true, className:
|
|
2083
|
-
|
|
2084
|
-
|
|
2199
|
+
return (jsx(SidebarMenuSubItem, { children: jsx(SidebarMenuSubButton, { asChild: true, className: "font-thai w-full text-sm" +
|
|
2200
|
+
(isSubPageActive
|
|
2201
|
+
? " font-medium"
|
|
2202
|
+
: ""), isActive: isSubPageActive, children: jsxs(Link, { href: subPageUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, "aria-current": isSubPageActive
|
|
2203
|
+
? "page"
|
|
2204
|
+
: undefined, children: [SubPageIcon && (jsx(SubPageIcon, { className: "sidebar-subpage-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", children: subPage.titleTh })] }) }) }, subPage.id));
|
|
2085
2205
|
}) }) })] }) }, subItem.id));
|
|
2086
2206
|
}
|
|
2087
2207
|
return (jsx(SidebarMenuSubItem, { children: jsx(SidebarMenuSubButton, { asChild: true, className: "w-full" +
|
|
2088
2208
|
(workspace && isSubItemActive
|
|
2089
2209
|
? " font-semibold"
|
|
2090
|
-
: ""), isActive: isSubItemActive, children: jsxs(Link, { href: subItemUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, children: [SubIcon && (jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate",
|
|
2210
|
+
: ""), isActive: isSubItemActive, children: jsxs(Link, { href: subItemUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, "aria-current": isSubItemActive ? "page" : undefined, children: [SubIcon && (jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", children: subItem.titleTh }), approvalCount > 0 && (jsx(Badge, { variant: "destructive", className: "ml-auto h-5 min-w-5 px-1.5 text-xs font-medium tabular-nums", children: approvalCount > 99
|
|
2091
2211
|
? "99+"
|
|
2092
2212
|
: approvalCount }))] }) }) }, subItem.id));
|
|
2093
2213
|
}) }) }))] }) })] }, item.id));
|
|
@@ -3055,6 +3175,86 @@ function ThreadBubble({ mine, label, at, body, images, }) {
|
|
|
3055
3175
|
(mine ? "border border-primary/30 bg-primary/5" : "bg-muted"), children: [jsxs("p", { className: "text-xs text-muted-foreground", children: [label, " \u00B7 ", when(at)] }), jsx("p", { className: "mt-1 whitespace-pre-wrap break-words text-sm", children: body }), jsx(HelpImageStrip, { images: images })] }) }));
|
|
3056
3176
|
}
|
|
3057
3177
|
|
|
3178
|
+
/**
|
|
3179
|
+
* Central "no access" screen shared by every policy check (route-level
|
|
3180
|
+
* PermissionProvider and page-level PermissionGateShell). One look everywhere:
|
|
3181
|
+
* an illustration, a 403 headline, the page-specific reason, and recovery
|
|
3182
|
+
* actions.
|
|
3183
|
+
*/
|
|
3184
|
+
function AccessDenied({ title = "ขออภัย, คุณไม่มีสิทธิ์เข้าถึงหน้านี้ (403)", message = "คุณไม่มีสิทธิ์เข้าถึงหน้านี้ ขออภัยในความไม่สะดวก", homeHref, homeLabel = "กลับไปหน้าหลัก", supportHref, supportLabel = "ติดต่อฝ่ายสนับสนุน", fullScreen = true, className, }) {
|
|
3185
|
+
// "/" is not the home page inside an app — it bounces to organization
|
|
3186
|
+
// selection, so the button read "กลับไปหน้าหลัก" and dropped the user into a
|
|
3187
|
+
// picker for the org they were already in. Send them to that org's home.
|
|
3188
|
+
const params = useParams();
|
|
3189
|
+
const code = typeof params?.code === "string" ? params.code : "";
|
|
3190
|
+
const homeTarget = homeHref ?? (code ? `/${code}` : "/");
|
|
3191
|
+
const go = (href) => {
|
|
3192
|
+
if (typeof window !== "undefined")
|
|
3193
|
+
window.location.href = href;
|
|
3194
|
+
};
|
|
3195
|
+
const wrapperClass = [
|
|
3196
|
+
"flex min-w-0 flex-1 items-center justify-center p-4",
|
|
3197
|
+
fullScreen ? "min-h-[70vh]" : "",
|
|
3198
|
+
className ?? "",
|
|
3199
|
+
]
|
|
3200
|
+
.filter(Boolean)
|
|
3201
|
+
.join(" ");
|
|
3202
|
+
return (jsx("div", { className: wrapperClass, children: jsx("div", { className: "relative w-full max-w-3xl overflow-hidden rounded-2xl border border-border/60 bg-card/60 p-8 shadow-sm backdrop-blur sm:p-10", children: jsxs("div", { className: "flex flex-col items-center gap-8 text-center sm:flex-row sm:text-left", children: [jsxs("div", { className: "relative shrink-0", children: [jsx("div", { className: "absolute inset-0 rounded-full bg-destructive/20 blur-2xl" }), jsx("div", { className: "relative flex h-28 w-28 items-center justify-center rounded-3xl border border-border/60 bg-gradient-to-br from-muted/40 to-background shadow-inner", children: jsx("div", { className: "flex h-16 w-16 items-center justify-center rounded-full border border-destructive/40 bg-destructive/10", children: jsx(Ban, { className: "h-8 w-8 text-destructive", strokeWidth: 2.5 }) }) })] }), jsxs("div", { className: "min-w-0 flex-1", children: [jsx("h1", { className: "text-xl font-bold text-foreground sm:text-2xl", children: title }), message ? (jsx("p", { className: "mt-3 whitespace-pre-line text-sm leading-relaxed text-muted-foreground sm:text-base", children: message })) : null, jsxs("div", { className: "mt-6 flex flex-col gap-3 sm:flex-row", children: [jsxs(Button, { type: "button", onClick: () => go(homeTarget), className: "w-full sm:w-auto", children: [jsx(Home, { className: "mr-2 h-4 w-4" }), homeLabel] }), supportHref ? (jsxs(Button, { type: "button", variant: "outline", onClick: () => go(supportHref), className: "w-full sm:w-auto", children: [jsx(Headset, { className: "mr-2 h-4 w-4" }), supportLabel] })) : null] })] })] }) }) }));
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
/**
|
|
3206
|
+
* What AppLayoutShell does with the page while the organisation in the URL and
|
|
3207
|
+
* the tab's active organisation are being reconciled. Pure, so it is tested
|
|
3208
|
+
* without React (url-org-gate.test.ts).
|
|
3209
|
+
*
|
|
3210
|
+
* The URL's `[code]` is authoritative. The page — and with it every request
|
|
3211
|
+
* the page makes — mounts only once the active organisation IS the one the
|
|
3212
|
+
* URL names:
|
|
3213
|
+
*
|
|
3214
|
+
* ready the active organisation is the URL's: render the page.
|
|
3215
|
+
* resolving the caller's organisations are still loading, so the code
|
|
3216
|
+
* cannot be resolved yet: hold the page.
|
|
3217
|
+
* switching the code is one of the caller's organisations but not the
|
|
3218
|
+
* active one: switch to it (`targetId`), hold the page meanwhile.
|
|
3219
|
+
* not-member the list loaded and the code is not in it: refuse.
|
|
3220
|
+
* unverified the list could not be loaded: render as before the gate
|
|
3221
|
+
* existed (the API still scopes every request), rather than
|
|
3222
|
+
* leave the page on a loader for good.
|
|
3223
|
+
*/
|
|
3224
|
+
/** The organisation a `[code]` segment names, among the caller's. */
|
|
3225
|
+
function findOrganizationForCode(code, organizations) {
|
|
3226
|
+
if (!code)
|
|
3227
|
+
return null;
|
|
3228
|
+
return (organizations.find(org => org.code === code || String(org.id) === code) ??
|
|
3229
|
+
null);
|
|
3230
|
+
}
|
|
3231
|
+
function resolveUrlOrgGate({ code, organizations, organizationsStatus, organizationId, }) {
|
|
3232
|
+
// No code in the path: the shell sends the user to pick an organisation.
|
|
3233
|
+
if (!code)
|
|
3234
|
+
return { state: "ready", targetId: null };
|
|
3235
|
+
const target = findOrganizationForCode(code, organizations);
|
|
3236
|
+
if (target) {
|
|
3237
|
+
const targetId = String(target.id);
|
|
3238
|
+
const activeId = organizationId === null || organizationId === undefined
|
|
3239
|
+
? ""
|
|
3240
|
+
: String(organizationId).trim();
|
|
3241
|
+
return activeId === targetId
|
|
3242
|
+
? { state: "ready", targetId }
|
|
3243
|
+
: { state: "switching", targetId };
|
|
3244
|
+
}
|
|
3245
|
+
const status = organizationsStatus ?? (organizations.length > 0 ? "success" : undefined);
|
|
3246
|
+
switch (status) {
|
|
3247
|
+
case "success":
|
|
3248
|
+
return { state: "not-member" };
|
|
3249
|
+
case "pending":
|
|
3250
|
+
return { state: "resolving" };
|
|
3251
|
+
default:
|
|
3252
|
+
// "error", or a host that does not report the list's status and has
|
|
3253
|
+
// an empty one.
|
|
3254
|
+
return { state: "unverified" };
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3058
3258
|
/**
|
|
3059
3259
|
* Top-level app chrome: sticky header, sidebar slot, content gutter and the
|
|
3060
3260
|
* session/org-context plumbing that both Accounting and HR need. Apps wrap
|
|
@@ -3067,7 +3267,7 @@ function ThreadBubble({ mine, label, at, body, images, }) {
|
|
|
3067
3267
|
* an erp-shell bump — domain components must NOT add their own root padding
|
|
3068
3268
|
* or content gets indented twice.
|
|
3069
3269
|
*/
|
|
3070
|
-
function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permissionProvider, organizationId, setOrganizationId, organizations, signInUrl, selectOrganizationUrl, chrome = "brand", workspaceHeader = "brand", headerEnd, mobileBrand, showHelp = true, helpApp, }) {
|
|
3270
|
+
function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permissionProvider, organizationId, setOrganizationId, organizations, organizationsStatus, notMemberComponent, signInUrl, selectOrganizationUrl, chrome = "brand", workspaceHeader = "brand", headerEnd, mobileBrand, showHelp = true, helpApp, }) {
|
|
3071
3271
|
const { data: session, status } = useSession();
|
|
3072
3272
|
const { open, setOpen } = useSidebar();
|
|
3073
3273
|
const router = useRouter();
|
|
@@ -3084,33 +3284,37 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
|
|
|
3084
3284
|
if (extendedSession?.accessToken)
|
|
3085
3285
|
hasSeenAccessTokenRef.current = true;
|
|
3086
3286
|
}, [extendedSession?.accessToken]);
|
|
3087
|
-
//
|
|
3088
|
-
//
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
//
|
|
3095
|
-
//
|
|
3096
|
-
//
|
|
3287
|
+
// The URL's `[code]` is authoritative: the page mounts only once the active
|
|
3288
|
+
// organisation is the one it names (see url-org-gate.ts).
|
|
3289
|
+
//
|
|
3290
|
+
// The gate used to start only when the code had resolved AND an active id
|
|
3291
|
+
// existed, which left two holes. While the organisation list loaded, the
|
|
3292
|
+
// page mounted at once and its first requests carried the previous
|
|
3293
|
+
// organisation — /O26000021/procurement/purchase-requisition/<id> fetched
|
|
3294
|
+
// the document from O26000019 before the switch. And a code naming none of
|
|
3295
|
+
// the user's organisations never resolved, so the page rendered the
|
|
3296
|
+
// previous organisation's data under it.
|
|
3297
|
+
const urlOrgGate = resolveUrlOrgGate({
|
|
3298
|
+
code,
|
|
3299
|
+
organizations,
|
|
3300
|
+
organizationsStatus,
|
|
3301
|
+
organizationId,
|
|
3302
|
+
});
|
|
3303
|
+
const switchTargetId = urlOrgGate.state === "switching" ? urlOrgGate.targetId : null;
|
|
3304
|
+
// Switch the active organisation to the URL's. Once per target: the setter
|
|
3305
|
+
// writes the cookie through a server action, and re-firing it on every
|
|
3306
|
+
// render while that round trip is in flight only queues duplicates.
|
|
3307
|
+
const requestedSwitchRef = useRef(null);
|
|
3097
3308
|
useEffect(() => {
|
|
3098
|
-
if (!
|
|
3099
|
-
|
|
3100
|
-
if (!organizations.length)
|
|
3309
|
+
if (!switchTargetId) {
|
|
3310
|
+
requestedSwitchRef.current = null;
|
|
3101
3311
|
return;
|
|
3102
|
-
if (!orgFromCode)
|
|
3103
|
-
return;
|
|
3104
|
-
if (String(organizationId) !== String(orgFromCode.id)) {
|
|
3105
|
-
void setOrganizationId(orgFromCode.id);
|
|
3106
3312
|
}
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
setOrganizationId
|
|
3111
|
-
|
|
3112
|
-
organizations.length,
|
|
3113
|
-
]);
|
|
3313
|
+
if (requestedSwitchRef.current === switchTargetId)
|
|
3314
|
+
return;
|
|
3315
|
+
requestedSwitchRef.current = switchTargetId;
|
|
3316
|
+
void setOrganizationId(switchTargetId);
|
|
3317
|
+
}, [switchTargetId, setOrganizationId]);
|
|
3114
3318
|
// Key for the content subtree. It exists so switching organizations throws
|
|
3115
3319
|
// away the previous org's component state — but it must react ONLY to a real
|
|
3116
3320
|
// switch. organizationId legitimately passes through null while the context
|
|
@@ -3132,21 +3336,20 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
|
|
|
3132
3336
|
const contentOrgKey = lastOrgKeyRef.current;
|
|
3133
3337
|
// Hold the page back until the active organisation is the one the URL names.
|
|
3134
3338
|
//
|
|
3135
|
-
// The
|
|
3136
|
-
//
|
|
3137
|
-
// previous organisation
|
|
3138
|
-
//
|
|
3139
|
-
//
|
|
3140
|
-
//
|
|
3141
|
-
// a company the user was not looking at.
|
|
3339
|
+
// The switch writes the cookie through a server action, which is a round
|
|
3340
|
+
// trip. Until it lands, every request the page fires on mount would carry
|
|
3341
|
+
// the previous organisation — so /O26000021/work-record asked about
|
|
3342
|
+
// O26000033 and was correctly told there was nothing there. An empty table
|
|
3343
|
+
// is the mild version: the create form posts to the same header, so a
|
|
3344
|
+
// record could be filed against a company the user was not looking at.
|
|
3142
3345
|
//
|
|
3143
|
-
//
|
|
3144
|
-
//
|
|
3145
|
-
//
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3346
|
+
// The whole layout waits, not just the content: the chrome reads the URL
|
|
3347
|
+
// too. The breadcrumb looks up the document in the path to print its
|
|
3348
|
+
// number, so with only the content held it fetched
|
|
3349
|
+
// /purchase-requests/<id> under the previous organisation and printed that
|
|
3350
|
+
// organisation's document number under the new one's URL.
|
|
3351
|
+
const holdLayout = urlOrgGate.state === "resolving" || urlOrgGate.state === "switching";
|
|
3352
|
+
const refusePage = urlOrgGate.state === "not-member";
|
|
3150
3353
|
// ≤1024px: sidebar renders as a modal overlay (backdrop dims content). >1024px:
|
|
3151
3354
|
// sidebar is docked and the content gets a left margin to clear it.
|
|
3152
3355
|
const [isSidebarModalLayout, setIsSidebarModalLayout] = React.useState(false);
|
|
@@ -3195,6 +3398,9 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
|
|
|
3195
3398
|
router.push(selectOrganizationUrl);
|
|
3196
3399
|
return jsx(Fragment$1, { children: loadingComponent });
|
|
3197
3400
|
}
|
|
3401
|
+
if (holdLayout) {
|
|
3402
|
+
return jsx(Fragment$1, { children: loadingComponent });
|
|
3403
|
+
}
|
|
3198
3404
|
// When the sidebar is docked-open it consumes 21.5rem on the left; otherwise
|
|
3199
3405
|
// the content takes the full width and we add a left gutter inside.
|
|
3200
3406
|
const workspace = chrome === "workspace";
|
|
@@ -3205,6 +3411,9 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
|
|
|
3205
3411
|
: "";
|
|
3206
3412
|
const needsLeftGutter = workspace || !(open && !isSidebarModalLayout);
|
|
3207
3413
|
const wrap = permissionProvider ?? ((c) => c);
|
|
3414
|
+
// No trail over a refusal: it would look up the path's document under the
|
|
3415
|
+
// active organisation, which is not the URL's.
|
|
3416
|
+
const trail = refusePage ? null : breadcrumb;
|
|
3208
3417
|
return (jsxs("div", { className: "flex min-h-screen w-full bg-background", children: [open && (jsx("div", { className: "fixed left-0 top-0 z-50 h-screen", children: sidebar })), open && isSidebarModalLayout && (jsx("button", { type: "button", "aria-label": "\u0E1B\u0E34\u0E14\u0E40\u0E21\u0E19\u0E39", className: "fixed inset-0 z-40 bg-black/50", onClick: () => setOpen(false) })), jsx("div", { className: cx("relative flex min-w-0 flex-1 flex-col transition-all", contentMargin), children: jsxs("main", { className: cx("flex flex-1 flex-col min-h-screen", workspace ? "gap-0" : "gap-4"), children: [workspace ? (jsxs("div", { className: cx("sticky top-0 z-30 shrink-0 w-full", workspaceHeader === "light" &&
|
|
3209
3418
|
"border-b border-border bg-card/85 backdrop-blur-sm"), style: workspaceHeader === "light"
|
|
3210
3419
|
? WORKSPACE_LIGHT_HEADER_VARS
|
|
@@ -3214,9 +3423,11 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
|
|
|
3214
3423
|
// As in the design: the mark, then a menu button, and the
|
|
3215
3424
|
// organisation on the right — no trail, no title, so the
|
|
3216
3425
|
// organisation's name has the room it needs on a phone.
|
|
3217
|
-
jsxs(Fragment$1, { children: [mobileBrand ? (jsx("div", { className: "shrink-0", children: mobileBrand })) : null, jsx("button", { type: "button", onClick: () => setOpen(!open), "aria-label": open ? "ปิดเมนู" : "เปิดเมนู", className: "grid size-[34px] shrink-0 place-items-center rounded-lg text-[var(--header-fg-muted)] transition-colors hover:bg-[var(--header-hover)] hover:text-[var(--header-fg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--header-fg-muted)]", children: jsx(LayoutGrid, { className: "size-5", strokeWidth: 1.65 }) })] })) : (jsx(SidebarTrigger, { className: "shrink-0 text-[var(--header-fg-muted)] hover:bg-[var(--header-hover)] hover:text-[var(--header-fg)] [&_svg]:!text-current" })), jsxs("div", { className: "flex min-w-0 flex-1 flex-col justify-center gap-0.5", children: [jsx("div", { className: "hidden min-w-0 sm:block", children:
|
|
3426
|
+
jsxs(Fragment$1, { children: [mobileBrand ? (jsx("div", { className: "shrink-0", children: mobileBrand })) : null, jsx("button", { type: "button", onClick: () => setOpen(!open), "aria-label": open ? "ปิดเมนู" : "เปิดเมนู", className: "grid size-[34px] shrink-0 place-items-center rounded-lg text-[var(--header-fg-muted)] transition-colors hover:bg-[var(--header-hover)] hover:text-[var(--header-fg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--header-fg-muted)]", children: jsx(LayoutGrid, { className: "size-5", strokeWidth: 1.65 }) })] })) : (jsx(SidebarTrigger, { className: "shrink-0 text-[var(--header-fg-muted)] hover:bg-[var(--header-hover)] hover:text-[var(--header-fg)] [&_svg]:!text-current" })), jsxs("div", { className: "flex min-w-0 flex-1 flex-col justify-center gap-0.5", children: [jsx("div", { className: "hidden min-w-0 sm:block", children: trail }), jsx("div", { id: "header-title", className: "min-w-0 max-sm:hidden" })] }), headerEnd || showHelp ? (jsxs("div", { className: "flex shrink-0 items-center gap-3 sm:gap-5", children: [headerEnd, showHelp ? jsx(HelpButton, {}) : null] })) : null] })] })) : (jsxs("div", { className: "sticky top-0 z-30 shrink-0 w-full", children: [jsx(HeaderBackground, {}), jsxs("header", { className: "relative shrink-0 gap-2 py-4 h-auto min-h-[123.5px] w-full z-10", children: [jsxs("div", { className: "flex w-full items-center gap-2 px-3 sm:px-4 lg:px-6", children: [jsx(SidebarTrigger, { className: "shrink-0 text-white hover:bg-white/10" }), jsx("div", { className: "flex-1 min-w-0", children: trail }), headerEnd || showHelp ? (jsxs("div", { className: "flex shrink-0 items-center gap-3", children: [headerEnd, showHelp ? jsx(HelpButton, {}) : null] })) : null] }), jsx("div", { id: "header-title" })] })] })), jsx(Suspense, { fallback: jsx("div", { className: "relative z-10 min-h-[calc(100vh-8rem)]", children: jsx("div", { className: "flex h-full items-center justify-center", children: jsxs("div", { className: "space-y-4 text-center", children: [jsx("div", { className: "mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-primary" }), jsx("p", { className: "text-sm text-muted-foreground", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14..." })] }) }) }), children: jsx("div", { className: workspace
|
|
3218
3427
|
? "relative z-10 px-[18px] pt-[26px] pb-4 sm:px-6 lg:px-10 lg:pt-[38px]"
|
|
3219
|
-
: cx("relative z-10 pr-6", needsLeftGutter && "pl-6"), children:
|
|
3428
|
+
: cx("relative z-10 pr-6", needsLeftGutter && "pl-6"), children: refusePage
|
|
3429
|
+
? (notMemberComponent ?? (jsx(AccessDenied, { title: "\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C\u0E40\u0E02\u0E49\u0E32\u0E16\u0E36\u0E07\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23\u0E19\u0E35\u0E49", message: `บัญชีนี้ไม่ได้เป็นสมาชิกขององค์กร ${code}`, homeHref: selectOrganizationUrl, homeLabel: "\u0E40\u0E25\u0E37\u0E2D\u0E01\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" })))
|
|
3430
|
+
: wrap(children) }, `org-${contentOrgKey}`) })] }) }), showHelp ? jsx(HelpDialogHost, { app: helpApp, orgCode: code }) : null] }));
|
|
3220
3431
|
}
|
|
3221
3432
|
/** Local tiny classnames helper — avoids pulling clsx/tailwind-merge into
|
|
3222
3433
|
* the shell just for the layout file. Falsy values are skipped. */
|
|
@@ -3255,33 +3466,6 @@ function SidebarHelpLink({ label, href, icon, className }) {
|
|
|
3255
3466
|
return external ? (jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: cls, children: body })) : (jsx(Link, { href: href, className: cls, children: body }));
|
|
3256
3467
|
}
|
|
3257
3468
|
|
|
3258
|
-
/**
|
|
3259
|
-
* Central "no access" screen shared by every policy check (route-level
|
|
3260
|
-
* PermissionProvider and page-level PermissionGateShell). One look everywhere:
|
|
3261
|
-
* an illustration, a 403 headline, the page-specific reason, and recovery
|
|
3262
|
-
* actions.
|
|
3263
|
-
*/
|
|
3264
|
-
function AccessDenied({ title = "ขออภัย, คุณไม่มีสิทธิ์เข้าถึงหน้านี้ (403)", message = "คุณไม่มีสิทธิ์เข้าถึงหน้านี้ ขออภัยในความไม่สะดวก", homeHref, homeLabel = "กลับไปหน้าหลัก", supportHref, supportLabel = "ติดต่อฝ่ายสนับสนุน", fullScreen = true, className, }) {
|
|
3265
|
-
// "/" is not the home page inside an app — it bounces to organization
|
|
3266
|
-
// selection, so the button read "กลับไปหน้าหลัก" and dropped the user into a
|
|
3267
|
-
// picker for the org they were already in. Send them to that org's home.
|
|
3268
|
-
const params = useParams();
|
|
3269
|
-
const code = typeof params?.code === "string" ? params.code : "";
|
|
3270
|
-
const homeTarget = homeHref ?? (code ? `/${code}` : "/");
|
|
3271
|
-
const go = (href) => {
|
|
3272
|
-
if (typeof window !== "undefined")
|
|
3273
|
-
window.location.href = href;
|
|
3274
|
-
};
|
|
3275
|
-
const wrapperClass = [
|
|
3276
|
-
"flex min-w-0 flex-1 items-center justify-center p-4",
|
|
3277
|
-
fullScreen ? "min-h-[70vh]" : "",
|
|
3278
|
-
className ?? "",
|
|
3279
|
-
]
|
|
3280
|
-
.filter(Boolean)
|
|
3281
|
-
.join(" ");
|
|
3282
|
-
return (jsx("div", { className: wrapperClass, children: jsx("div", { className: "relative w-full max-w-3xl overflow-hidden rounded-2xl border border-border/60 bg-card/60 p-8 shadow-sm backdrop-blur sm:p-10", children: jsxs("div", { className: "flex flex-col items-center gap-8 text-center sm:flex-row sm:text-left", children: [jsxs("div", { className: "relative shrink-0", children: [jsx("div", { className: "absolute inset-0 rounded-full bg-destructive/20 blur-2xl" }), jsx("div", { className: "relative flex h-28 w-28 items-center justify-center rounded-3xl border border-border/60 bg-gradient-to-br from-muted/40 to-background shadow-inner", children: jsx("div", { className: "flex h-16 w-16 items-center justify-center rounded-full border border-destructive/40 bg-destructive/10", children: jsx(Ban, { className: "h-8 w-8 text-destructive", strokeWidth: 2.5 }) }) })] }), jsxs("div", { className: "min-w-0 flex-1", children: [jsx("h1", { className: "text-xl font-bold text-foreground sm:text-2xl", children: title }), message ? (jsx("p", { className: "mt-3 whitespace-pre-line text-sm leading-relaxed text-muted-foreground sm:text-base", children: message })) : null, jsxs("div", { className: "mt-6 flex flex-col gap-3 sm:flex-row", children: [jsxs(Button, { type: "button", onClick: () => go(homeTarget), className: "w-full sm:w-auto", children: [jsx(Home, { className: "mr-2 h-4 w-4" }), homeLabel] }), supportHref ? (jsxs(Button, { type: "button", variant: "outline", onClick: () => go(supportHref), className: "w-full sm:w-auto", children: [jsx(Headset, { className: "mr-2 h-4 w-4" }), supportLabel] })) : null] })] })] }) }) }));
|
|
3283
|
-
}
|
|
3284
|
-
|
|
3285
3469
|
const EMPLOYEE_RECORD_REQUIRED = "EMPLOYEE_RECORD_REQUIRED";
|
|
3286
3470
|
const EMPLOYEE_RECORD_REQUIRED_EVENT = "erp:employee-record-required";
|
|
3287
3471
|
const EMPLOYEE_RECORD_OK_EVENT = "erp:employee-record-ok";
|
|
@@ -3674,6 +3858,51 @@ function useFeatureFlag(flag) {
|
|
|
3674
3858
|
return flags[flag];
|
|
3675
3859
|
}
|
|
3676
3860
|
|
|
3861
|
+
// No relative imports: node --test loads this file as is (query-scope.test.ts).
|
|
3862
|
+
/**
|
|
3863
|
+
* Every query is cached per organisation.
|
|
3864
|
+
*
|
|
3865
|
+
* Requests are scoped by the X-Organization-ID header, so almost no query key
|
|
3866
|
+
* names the organisation — ["purchase-requests", "detail", id] is the same key
|
|
3867
|
+
* in every company. A key that is the same in two organisations is one cache
|
|
3868
|
+
* entry: what was fetched in one was a fresh cache hit in the other, and a
|
|
3869
|
+
* page opened under another organisation's URL rendered the first one's
|
|
3870
|
+
* document until something refetched it.
|
|
3871
|
+
*
|
|
3872
|
+
* The hash — the identity of a cache entry — therefore carries the
|
|
3873
|
+
* organisation requests are sent with right now (getQueryClientConfig passes
|
|
3874
|
+
* getRequestOrganizationId()). The key itself is untouched,
|
|
3875
|
+
* so partial-key invalidation and every existing key keep working; a switch
|
|
3876
|
+
* simply lands on a different set of entries.
|
|
3877
|
+
*/
|
|
3878
|
+
function organizationScopedQueryHash(queryKey, organizationId) {
|
|
3879
|
+
return `${hashKey(queryKey)}@org:${organizationId ?? ""}`;
|
|
3880
|
+
}
|
|
3881
|
+
function errorStatus(error) {
|
|
3882
|
+
if (!error || typeof error !== "object")
|
|
3883
|
+
return undefined;
|
|
3884
|
+
const e = error;
|
|
3885
|
+
const status = typeof e.status === "number" ? e.status : e.response?.status;
|
|
3886
|
+
return typeof status === "number" ? status : undefined;
|
|
3887
|
+
}
|
|
3888
|
+
/**
|
|
3889
|
+
* Retry a failed query at most twice — unless the API answered with a 4xx.
|
|
3890
|
+
* A 404 for a document that is not in this organisation will not appear on a
|
|
3891
|
+
* second ask; retrying held the page on its skeleton for three seconds before
|
|
3892
|
+
* it could say "not found". 408 and 429 are transient and still retried.
|
|
3893
|
+
*/
|
|
3894
|
+
function shouldRetryQuery(failureCount, error) {
|
|
3895
|
+
const status = errorStatus(error);
|
|
3896
|
+
if (status !== undefined &&
|
|
3897
|
+
status >= 400 &&
|
|
3898
|
+
status < 500 &&
|
|
3899
|
+
status !== 408 &&
|
|
3900
|
+
status !== 429) {
|
|
3901
|
+
return false;
|
|
3902
|
+
}
|
|
3903
|
+
return failureCount < 2;
|
|
3904
|
+
}
|
|
3905
|
+
|
|
3677
3906
|
const getAxiosErrorMessage$1 = (error) => {
|
|
3678
3907
|
if (error.response?.data?.message) {
|
|
3679
3908
|
return error.response.data.message;
|
|
@@ -3697,7 +3926,9 @@ const formatError = (handlerName, error) => {
|
|
|
3697
3926
|
};
|
|
3698
3927
|
/**
|
|
3699
3928
|
* Default React Query config used across ERP apps:
|
|
3700
|
-
* -
|
|
3929
|
+
* - cache entries scoped by organisation (organizationScopedQueryHash)
|
|
3930
|
+
* - 2 retries on queries except 4xx answers / none on mutations,
|
|
3931
|
+
* exponential backoff
|
|
3701
3932
|
* - 5 min staleTime, refetchOnWindowFocus
|
|
3702
3933
|
* - swallows "no org context" errors to silence noise on the
|
|
3703
3934
|
* select-organization page
|
|
@@ -3708,7 +3939,8 @@ const formatError = (handlerName, error) => {
|
|
|
3708
3939
|
const getQueryClientConfig = () => ({
|
|
3709
3940
|
defaultOptions: {
|
|
3710
3941
|
queries: {
|
|
3711
|
-
|
|
3942
|
+
queryKeyHashFn: queryKey => organizationScopedQueryHash(queryKey, getRequestOrganizationId()),
|
|
3943
|
+
retry: shouldRetryQuery,
|
|
3712
3944
|
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
|
|
3713
3945
|
staleTime: 5 * 60 * 1000,
|
|
3714
3946
|
refetchOnWindowFocus: true,
|
|
@@ -3793,5 +4025,5 @@ const getAxiosErrorMessage = (error) => {
|
|
|
3793
4025
|
return String(error);
|
|
3794
4026
|
};
|
|
3795
4027
|
|
|
3796
|
-
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 };
|
|
4028
|
+
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, findOrganizationForCode, getAccessToken, getActiveModule, getActiveSubModule, getAxiosErrorMessage, getOrganizationId, getQueryClientConfig, getRequestOrganizationId, getRequiredChildGroups, getTabOrganizationId, hasPlanFeature, isLikelyTransientNetworkError, isPathActive, myRolesQueryKey, normalizePathnameForPermissionRules, openHelp, organizationScopedQueryHash, parseMyRoles, resolveActiveNav, resolveSidebarNav, resolveUiRoutePermission, resolveUrlOrgGate, roleSummary, runGlobalRequestPrecheck, runWithTransientRetry, setOrganizationId, setTabOrganizationId, shouldRetryQuery, toApiError, unwrapApiResponse, updateOrganizationId, useClearOrgOnUserChange, useFeatureFlag, useFeatureFlags, useInvalidateQueriesOnOrgChange, useKeycloakRoles, useMyRoles, useOrganizationIdState, usePermissionRules, useSessionRefresh, useSidebar, useSidebarToggle };
|
|
3797
4029
|
//# sourceMappingURL=index.esm.js.map
|