@goplusvn/core 0.1.32 → 0.1.34
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/CHANGELOG.md +44 -0
- package/package.json +1 -1
- package/src/ui/layout/horizontal-layout-header.tsx +6 -1
- package/src/ui/layout/horizontal-layout.tsx +3 -0
- package/src/ui/layout/main-layout.tsx +9 -0
- package/src/ui/layout/page-tabs.tsx +99 -20
- package/src/ui/layout/tab-navigation-provider.tsx +139 -18
- package/src/ui/layout/vertical-layout-header.tsx +4 -1
- package/src/ui/layout/vertical-layout.tsx +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.34 — Tab navigation: gom nhóm module, tiêu đề tab chi tiết, nút làm tươi
|
|
4
|
+
|
|
5
|
+
**PageTabs:**
|
|
6
|
+
|
|
7
|
+
- **Gom nhóm theo module**: tab sắp theo nhóm (segment đầu pathname) — tab chi
|
|
8
|
+
tiết đứng ngay sau tab danh sách cùng module, màu gradient hash theo NHÓM
|
|
9
|
+
(trước đây theo path → mỗi tab chi tiết một màu, không nhận ra nhóm). Tab
|
|
10
|
+
chi tiết có mũi tên rẽ nhánh (↳) + khe hở nhỏ giữa các nhóm.
|
|
11
|
+
- **Nút làm tươi** trên tab active: `router.refresh()` + phát
|
|
12
|
+
`CustomEvent(TAB_REFRESH_EVENT)` để trang SWR revalidate (refresh RSC không
|
|
13
|
+
đụng được cache SWR). Context menu "Reload Tab" dùng chung đường này.
|
|
14
|
+
|
|
15
|
+
**TabNavigationProvider — API mới cho app:**
|
|
16
|
+
|
|
17
|
+
- `useTabTitle(title)`: trang chi tiết đặt tiêu đề tab theo dữ liệu nghiệp vụ
|
|
18
|
+
(vd `useTabTitle(order?.orderNumber)`) — 5 tab chi tiết không còn trùng tên.
|
|
19
|
+
- Fallback tự động cho trang chi tiết CHƯA gắn hook: segment cuối trông như ID
|
|
20
|
+
(cuid/uuid/số) → tiêu đề `"<Tên module> · <5 ký tự cuối id>"` — mọi tab chi
|
|
21
|
+
tiết phân biệt được ngay khỏi cần sửa từng trang.
|
|
22
|
+
- `useTabRefreshListener(handler)`: trang client-fetch đăng ký revalidate khi
|
|
23
|
+
user bấm nút làm tươi trên tab. Kèm export `TAB_REFRESH_EVENT`.
|
|
24
|
+
- Context thêm `setTabTitle(pathname, title)` (action `UPDATE_TAB_TITLE`).
|
|
25
|
+
|
|
26
|
+
## 0.1.33 — Tab navigation: giữ bộ lọc + dữ liệu tươi; MainLayout `notificationSlot`
|
|
27
|
+
|
|
28
|
+
**Tab navigation (PageTabs / TabNavigationProvider):**
|
|
29
|
+
|
|
30
|
+
- Tab lưu **path đầy đủ gồm query string** — quay lại tab khôi phục đúng bộ lọc/
|
|
31
|
+
trang đang đứng. Tab id vẫn key theo pathname (đổi bộ lọc không đẻ tab mới);
|
|
32
|
+
query mới nhất được cập nhật vào `tab.path` (action `UPDATE_TAB_PATH`).
|
|
33
|
+
`useSearchParams` nằm trong leaf `SearchParamsChangeSignal` bọc `Suspense`
|
|
34
|
+
(tránh lỗi build trên route static).
|
|
35
|
+
- **Bỏ prefetch-all-tabs** (mỗi lần đổi tab bắn full RSC render mọi tab còn lại
|
|
36
|
+
— nặng server) và **bỏ hover-prefetch** (payload FULL-prefetch bị client cache
|
|
37
|
+
~5 phút → quay lại tab thấy dữ liệu cũ). Chuyển tab giờ luôn fetch tươi.
|
|
38
|
+
|
|
39
|
+
**MainLayout: prop `notificationSlot` (chuông thông báo do app cấp)**
|
|
40
|
+
|
|
41
|
+
Thêm prop optional `notificationSlot?: ReactNode` cho `MainLayout` (chảy xuống
|
|
42
|
+
`Vertical/HorizontalLayout` → header). Khi truyền, nó thay `<NotificationDropdown>`
|
|
43
|
+
tĩnh mặc định trong header — để app tự cấp chuông self-fetch dữ liệu của mình.
|
|
44
|
+
Bỏ trống → giữ nguyên dropdown tĩnh cũ (**backward-compat**, consumer khác không
|
|
45
|
+
phải sửa gì).
|
|
46
|
+
|
|
3
47
|
## 0.1.32 — Gỡ NextAuth khỏi core (BREAKING cho app chưa migrate)
|
|
4
48
|
|
|
5
49
|
Core không còn phụ thuộc next-auth (bỏ peer dependency):
|
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import { useParams } from "next/navigation";
|
|
|
5
5
|
import { Settings } from "lucide-react";
|
|
6
6
|
|
|
7
7
|
import type { DictionaryType } from "../../hooks";
|
|
8
|
+
import type { ReactNode } from "react";
|
|
8
9
|
import type { LocaleType, NavigationType } from "../../types";
|
|
9
10
|
|
|
10
11
|
import { ensureLocalizedPathname } from "../../utils";
|
|
@@ -23,9 +24,11 @@ import { TopBarHeaderMenubar } from "./top-bar-header-menubar";
|
|
|
23
24
|
export function HorizontalLayoutHeader({
|
|
24
25
|
dictionary,
|
|
25
26
|
navigation,
|
|
27
|
+
notificationSlot,
|
|
26
28
|
}: {
|
|
27
29
|
dictionary: DictionaryType;
|
|
28
30
|
navigation?: NavigationType[];
|
|
31
|
+
notificationSlot?: ReactNode;
|
|
29
32
|
}) {
|
|
30
33
|
const params = useParams();
|
|
31
34
|
const locale = params.lang as LocaleType;
|
|
@@ -56,7 +59,9 @@ export function HorizontalLayoutHeader({
|
|
|
56
59
|
navigation={navigation ?? []}
|
|
57
60
|
/>
|
|
58
61
|
<div className="hidden sm:flex items-center gap-2">
|
|
59
|
-
|
|
62
|
+
{notificationSlot ?? (
|
|
63
|
+
<NotificationDropdown dictionary={dictionary} />
|
|
64
|
+
)}
|
|
60
65
|
<FullScreenToggle />
|
|
61
66
|
</div>
|
|
62
67
|
<Customizer
|
|
@@ -16,6 +16,7 @@ interface HorizontalLayoutProps {
|
|
|
16
16
|
onGlobalSearch?: (query: string) => void;
|
|
17
17
|
searchResults?: any[];
|
|
18
18
|
searchLoading?: boolean;
|
|
19
|
+
notificationSlot?: ReactNode;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export function HorizontalLayout({
|
|
@@ -25,6 +26,7 @@ export function HorizontalLayout({
|
|
|
25
26
|
onGlobalSearch,
|
|
26
27
|
searchResults,
|
|
27
28
|
searchLoading,
|
|
29
|
+
notificationSlot,
|
|
28
30
|
}: HorizontalLayoutProps) {
|
|
29
31
|
return (
|
|
30
32
|
<SidebarProvider>
|
|
@@ -39,6 +41,7 @@ export function HorizontalLayout({
|
|
|
39
41
|
<HorizontalLayoutHeader
|
|
40
42
|
dictionary={dictionary}
|
|
41
43
|
navigation={navigation}
|
|
44
|
+
notificationSlot={notificationSlot}
|
|
42
45
|
/>
|
|
43
46
|
<main className="w-full mx-auto flex-1 min-h-0 px-4 md:px-8 py-4">
|
|
44
47
|
{children}
|
|
@@ -15,6 +15,12 @@ interface LayoutProps {
|
|
|
15
15
|
onGlobalSearch?: (query: string) => void;
|
|
16
16
|
searchResults?: any[];
|
|
17
17
|
searchLoading?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Chuông thông báo do app tự cấp (self-fetch dữ liệu app). Khi truyền, thay
|
|
20
|
+
* cho <NotificationDropdown> tĩnh mặc định trong header. Bỏ trống → giữ
|
|
21
|
+
* dropdown tĩnh cũ (backward-compat cho consumer chưa nối dữ liệu).
|
|
22
|
+
*/
|
|
23
|
+
notificationSlot?: ReactNode;
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
export function MainLayout({
|
|
@@ -24,6 +30,7 @@ export function MainLayout({
|
|
|
24
30
|
onGlobalSearch,
|
|
25
31
|
searchResults,
|
|
26
32
|
searchLoading,
|
|
33
|
+
notificationSlot,
|
|
27
34
|
}: LayoutProps) {
|
|
28
35
|
const mounted = useMounted();
|
|
29
36
|
const isVertical = useIsVertical();
|
|
@@ -40,6 +47,7 @@ export function MainLayout({
|
|
|
40
47
|
onGlobalSearch={onGlobalSearch}
|
|
41
48
|
searchResults={searchResults}
|
|
42
49
|
searchLoading={searchLoading}
|
|
50
|
+
notificationSlot={notificationSlot}
|
|
43
51
|
>
|
|
44
52
|
{children}
|
|
45
53
|
</VerticalLayout>
|
|
@@ -50,6 +58,7 @@ export function MainLayout({
|
|
|
50
58
|
onGlobalSearch={onGlobalSearch}
|
|
51
59
|
searchResults={searchResults}
|
|
52
60
|
searchLoading={searchLoading}
|
|
61
|
+
notificationSlot={notificationSlot}
|
|
53
62
|
>
|
|
54
63
|
{children}
|
|
55
64
|
</HorizontalLayout>
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
X,
|
|
6
|
+
MoreHorizontal,
|
|
7
|
+
Loader2,
|
|
8
|
+
Pin,
|
|
9
|
+
RefreshCw,
|
|
10
|
+
CornerDownRight,
|
|
11
|
+
} from "lucide-react";
|
|
5
12
|
import { useParams, useRouter } from "next/navigation";
|
|
6
13
|
import type { DictionaryType } from "../../hooks";
|
|
7
14
|
import type { LocaleType } from "../../types";
|
|
@@ -19,9 +26,23 @@ import {
|
|
|
19
26
|
ContextMenuTrigger,
|
|
20
27
|
} from "../primitives/client";
|
|
21
28
|
|
|
22
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
useTabNavigation,
|
|
31
|
+
TAB_REFRESH_EVENT,
|
|
32
|
+
type Tab,
|
|
33
|
+
} from "./tab-navigation-provider";
|
|
23
34
|
import { useRouteCache } from "./route-cache";
|
|
24
35
|
|
|
36
|
+
/** Nhóm tab theo module — segment đầu của pathname đã bỏ locale. */
|
|
37
|
+
function groupKeyOf(tab: Tab): string {
|
|
38
|
+
return tab.id.split("/").filter(Boolean)[0] ?? "home";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Độ sâu path: 1 = trang danh sách, >1 = trang chi tiết (tab con). */
|
|
42
|
+
function depthOf(tab: Tab): number {
|
|
43
|
+
return tab.id.split("/").filter(Boolean).length;
|
|
44
|
+
}
|
|
45
|
+
|
|
25
46
|
interface PageTabsProps {
|
|
26
47
|
dictionary?: DictionaryType;
|
|
27
48
|
className?: string;
|
|
@@ -47,6 +68,7 @@ export function PageTabs({
|
|
|
47
68
|
const router = useRouter();
|
|
48
69
|
const locale = params?.lang as LocaleType | undefined;
|
|
49
70
|
const [contextMenuTabId, setContextMenuTabId] = useState<string | null>(null);
|
|
71
|
+
const [refreshingTabId, setRefreshingTabId] = useState<string | null>(null);
|
|
50
72
|
|
|
51
73
|
// Don't render if no tabs
|
|
52
74
|
if (tabs.length === 0) {
|
|
@@ -57,9 +79,16 @@ export function PageTabs({
|
|
|
57
79
|
setActiveTab(tabId);
|
|
58
80
|
};
|
|
59
81
|
|
|
60
|
-
|
|
61
|
-
|
|
82
|
+
// Làm tươi tab: refresh RSC payload + báo cho trang SWR revalidate
|
|
83
|
+
// (router.refresh không đụng được cache SWR nên cần event kèm theo).
|
|
84
|
+
const handleReloadTab = (tab: Tab) => {
|
|
85
|
+
reloadTab(tab.path);
|
|
62
86
|
router.refresh();
|
|
87
|
+
window.dispatchEvent(
|
|
88
|
+
new CustomEvent(TAB_REFRESH_EVENT, { detail: { path: tab.path } }),
|
|
89
|
+
);
|
|
90
|
+
setRefreshingTabId(tab.id);
|
|
91
|
+
setTimeout(() => setRefreshingTabId(null), 800);
|
|
63
92
|
};
|
|
64
93
|
|
|
65
94
|
const handleCloseTab = (e: React.MouseEvent, tabId: string) => {
|
|
@@ -71,10 +100,23 @@ export function PageTabs({
|
|
|
71
100
|
setContextMenuTabId(tabId);
|
|
72
101
|
};
|
|
73
102
|
|
|
74
|
-
//
|
|
103
|
+
// Gom nhóm theo module: tab chi tiết đứng ngay sau tab danh sách cùng module.
|
|
104
|
+
// Thứ tự nhóm theo tab xuất hiện sớm nhất; trong nhóm: trang danh sách
|
|
105
|
+
// (path nông hơn) trước, rồi theo thời gian mở.
|
|
106
|
+
const groupOrder = new Map<string, number>();
|
|
107
|
+
for (const tab of [...tabs].sort((a, b) => a.createdAt - b.createdAt)) {
|
|
108
|
+
const key = groupKeyOf(tab);
|
|
109
|
+
if (!groupOrder.has(key)) groupOrder.set(key, groupOrder.size);
|
|
110
|
+
}
|
|
111
|
+
|
|
75
112
|
const sortedTabs = [...tabs].sort((a, b) => {
|
|
76
|
-
if (a.isPinned
|
|
77
|
-
|
|
113
|
+
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
|
|
114
|
+
const groupDiff =
|
|
115
|
+
(groupOrder.get(groupKeyOf(a)) ?? 0) -
|
|
116
|
+
(groupOrder.get(groupKeyOf(b)) ?? 0);
|
|
117
|
+
if (groupDiff !== 0) return groupDiff;
|
|
118
|
+
const depthDiff = depthOf(a) - depthOf(b);
|
|
119
|
+
if (depthDiff !== 0) return depthDiff;
|
|
78
120
|
return a.createdAt - b.createdAt;
|
|
79
121
|
});
|
|
80
122
|
|
|
@@ -86,13 +128,13 @@ export function PageTabs({
|
|
|
86
128
|
const hasOtherTabs = sortedTabs.length > 1;
|
|
87
129
|
|
|
88
130
|
// Nền tab: active = mặt phẳng trang (trắng); inactive = 1 gradient tối theo hash
|
|
89
|
-
// của
|
|
90
|
-
//
|
|
91
|
-
|
|
131
|
+
// của NHÓM module (bộ 21 màu cố định) — mọi tab cùng module (danh sách + các
|
|
132
|
+
// trang chi tiết) chung một màu để nhận ra nhóm ngay. KHÔNG dùng --primary
|
|
133
|
+
// để mỗi nhóm có màu riêng, dễ phân biệt.
|
|
134
|
+
const getTabBackgroundColor = (groupKey: string, isActive: boolean) => {
|
|
92
135
|
if (isActive) return "bg-background";
|
|
93
136
|
|
|
94
|
-
const
|
|
95
|
-
const hash = normalizedPath.split("").reduce((acc, char) => {
|
|
137
|
+
const hash = groupKey.split("").reduce((acc, char) => {
|
|
96
138
|
return (acc << 5) - acc + char.charCodeAt(0);
|
|
97
139
|
}, 0);
|
|
98
140
|
|
|
@@ -135,6 +177,10 @@ export function PageTabs({
|
|
|
135
177
|
{sortedTabs.map((tab, index) => {
|
|
136
178
|
const isActive = tab.id === activeTabId;
|
|
137
179
|
const isLast = index === sortedTabs.length - 1;
|
|
180
|
+
const isChild = depthOf(tab) > 1;
|
|
181
|
+
const isNewGroup =
|
|
182
|
+
index > 0 &&
|
|
183
|
+
groupKeyOf(sortedTabs[index - 1]) !== groupKeyOf(tab);
|
|
138
184
|
return (
|
|
139
185
|
<ContextMenu
|
|
140
186
|
key={tab.id}
|
|
@@ -146,12 +192,9 @@ export function PageTabs({
|
|
|
146
192
|
tabIndex={0}
|
|
147
193
|
onClick={() => handleTabClick(tab.id)}
|
|
148
194
|
onContextMenu={() => handleContextMenu(tab.id)}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
router.prefetch(tab.path);
|
|
153
|
-
}
|
|
154
|
-
}}
|
|
195
|
+
// No hover prefetch: imperative prefetch is FULL-kind and its
|
|
196
|
+
// payload is client-cached ~5 minutes, so switching back to a
|
|
197
|
+
// tab would render stale data on these force-dynamic pages.
|
|
155
198
|
onKeyDown={(e) => {
|
|
156
199
|
if (e.key === "Enter" || e.key === " ") {
|
|
157
200
|
e.preventDefault();
|
|
@@ -163,7 +206,9 @@ export function PageTabs({
|
|
|
163
206
|
"border border-transparent",
|
|
164
207
|
"cursor-pointer",
|
|
165
208
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
|
166
|
-
getTabBackgroundColor(tab
|
|
209
|
+
getTabBackgroundColor(groupKeyOf(tab), isActive),
|
|
210
|
+
// Khe hở nhỏ giữa các nhóm module cho dễ quét mắt
|
|
211
|
+
isNewGroup && "ml-1.5",
|
|
167
212
|
variant === "default" &&
|
|
168
213
|
(isActive
|
|
169
214
|
? "bg-background text-foreground border-b-2 border-primary shadow-sm shadow-primary/10 border-t border-x border-b-0 rounded-t-md -mb-px z-10"
|
|
@@ -210,6 +255,16 @@ export function PageTabs({
|
|
|
210
255
|
/>
|
|
211
256
|
) : null}
|
|
212
257
|
</div>
|
|
258
|
+
{/* Tab con (trang chi tiết): mũi tên rẽ nhánh để phân biệt
|
|
259
|
+
với tab danh sách của cùng module */}
|
|
260
|
+
{isChild && (
|
|
261
|
+
<CornerDownRight
|
|
262
|
+
className={cn(
|
|
263
|
+
"h-3 w-3 shrink-0",
|
|
264
|
+
isActive ? "text-muted-foreground" : "text-white/60",
|
|
265
|
+
)}
|
|
266
|
+
/>
|
|
267
|
+
)}
|
|
213
268
|
<span
|
|
214
269
|
className={cn(
|
|
215
270
|
"truncate max-w-[150px]",
|
|
@@ -232,6 +287,30 @@ export function PageTabs({
|
|
|
232
287
|
: tab.badge}
|
|
233
288
|
</Badge>
|
|
234
289
|
)}
|
|
290
|
+
{/* Nút làm tươi: chỉ hiện trên tab đang active — refresh
|
|
291
|
+
RSC + phát event cho trang SWR revalidate */}
|
|
292
|
+
{isActive && (
|
|
293
|
+
<Button
|
|
294
|
+
variant="ghost"
|
|
295
|
+
size="icon"
|
|
296
|
+
className={cn(
|
|
297
|
+
"h-4 w-4 ml-0.5 shrink-0",
|
|
298
|
+
"hover:bg-primary/10 hover:text-primary",
|
|
299
|
+
)}
|
|
300
|
+
onClick={(e) => {
|
|
301
|
+
e.stopPropagation();
|
|
302
|
+
handleReloadTab(tab);
|
|
303
|
+
}}
|
|
304
|
+
aria-label={`Reload ${tab.title}`}
|
|
305
|
+
>
|
|
306
|
+
<RefreshCw
|
|
307
|
+
className={cn(
|
|
308
|
+
"h-2.5 w-2.5",
|
|
309
|
+
refreshingTabId === tab.id && "animate-spin",
|
|
310
|
+
)}
|
|
311
|
+
/>
|
|
312
|
+
</Button>
|
|
313
|
+
)}
|
|
235
314
|
<Button
|
|
236
315
|
variant="ghost"
|
|
237
316
|
size="icon"
|
|
@@ -248,7 +327,7 @@ export function PageTabs({
|
|
|
248
327
|
</div>
|
|
249
328
|
</ContextMenuTrigger>
|
|
250
329
|
<ContextMenuContent>
|
|
251
|
-
<ContextMenuItem onClick={() => handleReloadTab(tab
|
|
330
|
+
<ContextMenuItem onClick={() => handleReloadTab(tab)}>
|
|
252
331
|
Reload Tab
|
|
253
332
|
</ContextMenuItem>
|
|
254
333
|
<ContextMenuItem onClick={() => handleTabClick(tab.id)}>
|
|
@@ -7,8 +7,10 @@ import {
|
|
|
7
7
|
useReducer,
|
|
8
8
|
useCallback,
|
|
9
9
|
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
Suspense,
|
|
10
12
|
} from "react";
|
|
11
|
-
import { usePathname, useRouter } from "next/navigation";
|
|
13
|
+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
12
14
|
import type { ReactNode } from "react";
|
|
13
15
|
|
|
14
16
|
import type { DynamicIconNameType, NavigationType } from "../../types";
|
|
@@ -43,6 +45,8 @@ type TabNavigationAction =
|
|
|
43
45
|
}
|
|
44
46
|
| { type: "REMOVE_TAB"; payload: { id: string } }
|
|
45
47
|
| { type: "SET_ACTIVE_TAB"; payload: { id: string } }
|
|
48
|
+
| { type: "UPDATE_TAB_PATH"; payload: { id: string; path: string } }
|
|
49
|
+
| { type: "UPDATE_TAB_TITLE"; payload: { id: string; title: string } }
|
|
46
50
|
| { type: "LOAD_STATE"; payload: TabNavigationState }
|
|
47
51
|
| { type: "CLEAR_TABS" }
|
|
48
52
|
| { type: "REMOVE_OTHER_TABS"; payload: { id: string } }
|
|
@@ -58,7 +62,9 @@ function tabNavigationReducer(
|
|
|
58
62
|
switch (action.type) {
|
|
59
63
|
case "ADD_TAB": {
|
|
60
64
|
const { path, title, iconName } = action.payload;
|
|
61
|
-
|
|
65
|
+
// path may carry a query string; the tab id is keyed by pathname only
|
|
66
|
+
// so the same page with different filters stays a single tab.
|
|
67
|
+
const normalizedPath = normalizePathname(path.split("?")[0]);
|
|
62
68
|
const tabId = normalizedPath;
|
|
63
69
|
|
|
64
70
|
// Check if tab already exists
|
|
@@ -119,6 +125,30 @@ function tabNavigationReducer(
|
|
|
119
125
|
};
|
|
120
126
|
}
|
|
121
127
|
|
|
128
|
+
case "UPDATE_TAB_PATH": {
|
|
129
|
+
// Tab id stays keyed by pathname; path stores the full URL (incl. query
|
|
130
|
+
// string) so re-activating a tab restores its filters/pagination.
|
|
131
|
+
const { id, path } = action.payload;
|
|
132
|
+
const tab = state.tabs.find((t) => t.id === id);
|
|
133
|
+
if (!tab || tab.path === path) return state;
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
...state,
|
|
137
|
+
tabs: state.tabs.map((t) => (t.id === id ? { ...t, path } : t)),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
case "UPDATE_TAB_TITLE": {
|
|
142
|
+
const { id, title } = action.payload;
|
|
143
|
+
const tab = state.tabs.find((t) => t.id === id);
|
|
144
|
+
if (!tab || !title || tab.title === title) return state;
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
...state,
|
|
148
|
+
tabs: state.tabs.map((t) => (t.id === id ? { ...t, title } : t)),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
122
152
|
case "LOAD_STATE": {
|
|
123
153
|
return action.payload;
|
|
124
154
|
}
|
|
@@ -181,6 +211,7 @@ interface TabNavigationContextValue {
|
|
|
181
211
|
addTab: (path: string, title?: string) => void;
|
|
182
212
|
removeTab: (id: string) => void;
|
|
183
213
|
setActiveTab: (id: string) => void;
|
|
214
|
+
setTabTitle: (pathname: string, title: string) => void;
|
|
184
215
|
clearTabs: () => void;
|
|
185
216
|
removeOtherTabs: (id: string) => void;
|
|
186
217
|
removeTabsToRight: (id: string) => void;
|
|
@@ -194,6 +225,25 @@ const TabNavigationContext = createContext<
|
|
|
194
225
|
TabNavigationContextValue | undefined
|
|
195
226
|
>(undefined);
|
|
196
227
|
|
|
228
|
+
// useSearchParams requires a Suspense boundary on statically rendered routes,
|
|
229
|
+
// so it lives in this leaf instead of the provider itself. It only signals
|
|
230
|
+
// "the query string changed" — the provider reads the actual value from
|
|
231
|
+
// window.location so both pathname- and search-triggered runs agree.
|
|
232
|
+
function SearchParamsChangeSignal({
|
|
233
|
+
onChange,
|
|
234
|
+
}: {
|
|
235
|
+
onChange: (search: string) => void;
|
|
236
|
+
}) {
|
|
237
|
+
const searchParams = useSearchParams();
|
|
238
|
+
const search = searchParams?.toString() ?? "";
|
|
239
|
+
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
onChange(search);
|
|
242
|
+
}, [search, onChange]);
|
|
243
|
+
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
197
247
|
export function TabNavigationProvider({
|
|
198
248
|
children,
|
|
199
249
|
navigations,
|
|
@@ -205,6 +255,7 @@ export function TabNavigationProvider({
|
|
|
205
255
|
const pathname = usePathname();
|
|
206
256
|
const router = useRouter();
|
|
207
257
|
const isInitialized = useRef(false);
|
|
258
|
+
const [searchSignal, setSearchSignal] = useState("");
|
|
208
259
|
|
|
209
260
|
// Load state from sessionStorage on mount
|
|
210
261
|
useEffect(() => {
|
|
@@ -233,7 +284,7 @@ export function TabNavigationProvider({
|
|
|
233
284
|
}
|
|
234
285
|
}, [state]);
|
|
235
286
|
|
|
236
|
-
// Auto-add tab when pathname changes
|
|
287
|
+
// Auto-add tab when pathname or query string changes
|
|
237
288
|
useEffect(() => {
|
|
238
289
|
if (!pathname || !isInitialized.current) return;
|
|
239
290
|
|
|
@@ -243,13 +294,38 @@ export function TabNavigationProvider({
|
|
|
243
294
|
}
|
|
244
295
|
|
|
245
296
|
// Get title and icon from navigation data or use pathname
|
|
246
|
-
const
|
|
297
|
+
const baseTitle = findRouteTitle(pathname, navigations) || "Page";
|
|
247
298
|
const iconName = findRouteIcon(pathname, navigations) || undefined;
|
|
248
299
|
const normalizedPath = normalizePathname(pathname);
|
|
249
300
|
|
|
301
|
+
// Trang chi tiết (segment cuối là ID) lấy tên module từ navigation nên
|
|
302
|
+
// mở 5 chi tiết là 5 tab trùng tên — đính đuôi ID ngắn để phân biệt ngay.
|
|
303
|
+
// Trang nào gắn useTabTitle sẽ thay bằng mã nghiệp vụ thật sau khi mount.
|
|
304
|
+
const lastSegment = normalizedPath.split("/").filter(Boolean).pop() ?? "";
|
|
305
|
+
const looksLikeId =
|
|
306
|
+
/^(c[a-z0-9]{20,}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|\d{4,})$/i.test(
|
|
307
|
+
lastSegment,
|
|
308
|
+
);
|
|
309
|
+
const title = looksLikeId
|
|
310
|
+
? `${baseTitle} · ${lastSegment.slice(-5)}`
|
|
311
|
+
: baseTitle;
|
|
312
|
+
|
|
313
|
+
// Full URL incl. query string — window.location is already updated by
|
|
314
|
+
// effect time, and reading it here keeps pathname-triggered and
|
|
315
|
+
// searchSignal-triggered runs consistent with each other.
|
|
316
|
+
const search = typeof window !== "undefined" ? window.location.search : "";
|
|
317
|
+
const fullPath = `${pathname}${search}`;
|
|
318
|
+
|
|
250
319
|
// Check if tab already exists
|
|
251
320
|
const existingTab = state.tabs.find((tab) => tab.id === normalizedPath);
|
|
252
321
|
if (existingTab) {
|
|
322
|
+
// Remember the latest filters/pagination for this tab
|
|
323
|
+
if (existingTab.path !== fullPath) {
|
|
324
|
+
dispatch({
|
|
325
|
+
type: "UPDATE_TAB_PATH",
|
|
326
|
+
payload: { id: normalizedPath, path: fullPath },
|
|
327
|
+
});
|
|
328
|
+
}
|
|
253
329
|
// Just set as active if not already active
|
|
254
330
|
if (state.activeTabId !== normalizedPath) {
|
|
255
331
|
dispatch({ type: "SET_ACTIVE_TAB", payload: { id: normalizedPath } });
|
|
@@ -258,23 +334,11 @@ export function TabNavigationProvider({
|
|
|
258
334
|
// Add new tab
|
|
259
335
|
dispatch({
|
|
260
336
|
type: "ADD_TAB",
|
|
261
|
-
payload: { path:
|
|
337
|
+
payload: { path: fullPath, title, iconName },
|
|
262
338
|
});
|
|
263
339
|
}
|
|
264
340
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
265
|
-
}, [pathname, navigations]);
|
|
266
|
-
|
|
267
|
-
// Prefetch all tab routes when tabs change
|
|
268
|
-
useEffect(() => {
|
|
269
|
-
if (!isInitialized.current) return;
|
|
270
|
-
|
|
271
|
-
// Prefetch all tab routes in background
|
|
272
|
-
state.tabs.forEach((tab) => {
|
|
273
|
-
if (tab.path && tab.id !== state.activeTabId) {
|
|
274
|
-
router.prefetch(tab.path);
|
|
275
|
-
}
|
|
276
|
-
});
|
|
277
|
-
}, [state.tabs, state.activeTabId, router]);
|
|
341
|
+
}, [pathname, searchSignal, navigations]);
|
|
278
342
|
|
|
279
343
|
const addTab = useCallback(
|
|
280
344
|
(path: string, title?: string) => {
|
|
@@ -325,6 +389,13 @@ export function TabNavigationProvider({
|
|
|
325
389
|
[state.tabs, router],
|
|
326
390
|
);
|
|
327
391
|
|
|
392
|
+
const setTabTitle = useCallback((tabPathname: string, title: string) => {
|
|
393
|
+
dispatch({
|
|
394
|
+
type: "UPDATE_TAB_TITLE",
|
|
395
|
+
payload: { id: normalizePathname(tabPathname), title },
|
|
396
|
+
});
|
|
397
|
+
}, []);
|
|
398
|
+
|
|
328
399
|
const clearTabs = useCallback(() => {
|
|
329
400
|
dispatch({ type: "CLEAR_TABS" });
|
|
330
401
|
}, []);
|
|
@@ -501,6 +572,7 @@ export function TabNavigationProvider({
|
|
|
501
572
|
addTab,
|
|
502
573
|
removeTab,
|
|
503
574
|
setActiveTab,
|
|
575
|
+
setTabTitle,
|
|
504
576
|
clearTabs,
|
|
505
577
|
removeOtherTabs,
|
|
506
578
|
removeTabsToRight,
|
|
@@ -510,6 +582,9 @@ export function TabNavigationProvider({
|
|
|
510
582
|
closeAndGoToParent,
|
|
511
583
|
}}
|
|
512
584
|
>
|
|
585
|
+
<Suspense fallback={null}>
|
|
586
|
+
<SearchParamsChangeSignal onChange={setSearchSignal} />
|
|
587
|
+
</Suspense>
|
|
513
588
|
{children}
|
|
514
589
|
</TabNavigationContext.Provider>
|
|
515
590
|
);
|
|
@@ -521,6 +596,7 @@ const noopTabNavigation = {
|
|
|
521
596
|
addTab: () => {},
|
|
522
597
|
removeTab: () => {},
|
|
523
598
|
setActiveTab: () => {},
|
|
599
|
+
setTabTitle: () => {},
|
|
524
600
|
clearTabs: () => {},
|
|
525
601
|
removeOtherTabs: () => {},
|
|
526
602
|
removeTabsToRight: () => {},
|
|
@@ -534,3 +610,48 @@ export function useTabNavigation() {
|
|
|
534
610
|
const context = useContext(TabNavigationContext);
|
|
535
611
|
return context ?? noopTabNavigation;
|
|
536
612
|
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Đặt tiêu đề tab của trang hiện tại theo dữ liệu nghiệp vụ — dùng ở trang
|
|
616
|
+
* chi tiết để 5 tab chi tiết không hiện cùng một tên chung chung.
|
|
617
|
+
* Truyền null/undefined khi dữ liệu chưa sẵn sàng (giữ tiêu đề mặc định).
|
|
618
|
+
*
|
|
619
|
+
* @example useTabTitle(order?.orderNumber)
|
|
620
|
+
*/
|
|
621
|
+
export function useTabTitle(title: string | null | undefined) {
|
|
622
|
+
const { setTabTitle, tabs } = useTabNavigation();
|
|
623
|
+
const pathname = usePathname();
|
|
624
|
+
|
|
625
|
+
// Effect con chạy TRƯỚC effect ADD_TAB của provider (React chạy effect từ
|
|
626
|
+
// dưới lên) — nên phải dep theo "tab đã tồn tại chưa" để set lại tiêu đề
|
|
627
|
+
// ngay sau khi provider thêm tab, thay vì dispatch vào khoảng không.
|
|
628
|
+
const tabExists =
|
|
629
|
+
!!pathname && tabs.some((t) => t.id === normalizePathname(pathname));
|
|
630
|
+
|
|
631
|
+
useEffect(() => {
|
|
632
|
+
if (title && pathname && tabExists) {
|
|
633
|
+
setTabTitle(pathname, title);
|
|
634
|
+
}
|
|
635
|
+
}, [title, pathname, tabExists, setTabTitle]);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Tên event nút "làm tươi" trên tab phát ra (detail = { path }). */
|
|
639
|
+
export const TAB_REFRESH_EVENT = "goerp:tab-refresh";
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Lắng nghe nút làm tươi trên tab đang active. Trang client-fetch (SWR) dùng
|
|
643
|
+
* hook này để revalidate dữ liệu của mình — router.refresh() chỉ làm mới RSC
|
|
644
|
+
* payload, không đụng được cache SWR.
|
|
645
|
+
*
|
|
646
|
+
* @example useTabRefreshListener(() => mutateList())
|
|
647
|
+
*/
|
|
648
|
+
export function useTabRefreshListener(handler: () => void) {
|
|
649
|
+
const handlerRef = useRef(handler);
|
|
650
|
+
handlerRef.current = handler;
|
|
651
|
+
|
|
652
|
+
useEffect(() => {
|
|
653
|
+
const listener = () => handlerRef.current();
|
|
654
|
+
window.addEventListener(TAB_REFRESH_EVENT, listener);
|
|
655
|
+
return () => window.removeEventListener(TAB_REFRESH_EVENT, listener);
|
|
656
|
+
}, []);
|
|
657
|
+
}
|
|
@@ -4,6 +4,7 @@ import { useParams } from "next/navigation";
|
|
|
4
4
|
import { Settings } from "lucide-react";
|
|
5
5
|
|
|
6
6
|
import type { DictionaryType } from "../../hooks";
|
|
7
|
+
import type { ReactNode } from "react";
|
|
7
8
|
import type { LocaleType } from "../../types";
|
|
8
9
|
|
|
9
10
|
import { Button } from "../index";
|
|
@@ -17,8 +18,10 @@ import { ToggleMobileSidebar } from "./toggle-mobile-sidebar";
|
|
|
17
18
|
|
|
18
19
|
export function VerticalLayoutHeader({
|
|
19
20
|
dictionary,
|
|
21
|
+
notificationSlot,
|
|
20
22
|
}: {
|
|
21
23
|
dictionary: DictionaryType;
|
|
24
|
+
notificationSlot?: ReactNode;
|
|
22
25
|
}) {
|
|
23
26
|
const params = useParams();
|
|
24
27
|
const locale = params.lang as LocaleType;
|
|
@@ -44,7 +47,7 @@ export function VerticalLayoutHeader({
|
|
|
44
47
|
{/* Right: user actions */}
|
|
45
48
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
46
49
|
<div className="hidden sm:flex items-center gap-2">
|
|
47
|
-
<NotificationDropdown dictionary={dictionary} />
|
|
50
|
+
{notificationSlot ?? <NotificationDropdown dictionary={dictionary} />}
|
|
48
51
|
<FullScreenToggle />
|
|
49
52
|
</div>
|
|
50
53
|
<Customizer
|
|
@@ -16,6 +16,7 @@ interface VerticalLayoutProps {
|
|
|
16
16
|
onGlobalSearch?: (query: string) => void;
|
|
17
17
|
searchResults?: any[];
|
|
18
18
|
searchLoading?: boolean;
|
|
19
|
+
notificationSlot?: ReactNode;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export function VerticalLayout({
|
|
@@ -25,6 +26,7 @@ export function VerticalLayout({
|
|
|
25
26
|
onGlobalSearch,
|
|
26
27
|
searchResults,
|
|
27
28
|
searchLoading,
|
|
29
|
+
notificationSlot,
|
|
28
30
|
}: VerticalLayoutProps) {
|
|
29
31
|
return (
|
|
30
32
|
<SidebarProvider>
|
|
@@ -36,7 +38,10 @@ export function VerticalLayout({
|
|
|
36
38
|
searchLoading={searchLoading}
|
|
37
39
|
/>
|
|
38
40
|
<SidebarInset className="w-full min-w-0 overflow-hidden h-screen flex flex-col">
|
|
39
|
-
<VerticalLayoutHeader
|
|
41
|
+
<VerticalLayoutHeader
|
|
42
|
+
dictionary={dictionary}
|
|
43
|
+
notificationSlot={notificationSlot}
|
|
44
|
+
/>
|
|
40
45
|
<main className="w-full mx-auto flex-1 min-h-0 px-4 md:px-8 py-4 overflow-auto">
|
|
41
46
|
{children}
|
|
42
47
|
</main>
|