@goplusvn/core 0.1.33 → 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 CHANGED
@@ -1,5 +1,28 @@
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
+
3
26
  ## 0.1.33 — Tab navigation: giữ bộ lọc + dữ liệu tươi; MainLayout `notificationSlot`
4
27
 
5
28
  **Tab navigation (PageTabs / TabNavigationProvider):**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.33",
4
+ "version": "0.1.34",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -1,7 +1,14 @@
1
1
  "use client";
2
2
 
3
3
  import { useState } from "react";
4
- import { X, MoreHorizontal, Loader2, Pin } from "lucide-react";
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 { useTabNavigation } from "./tab-navigation-provider";
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
- const handleReloadTab = (path: string) => {
61
- reloadTab(path);
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
- // Sort tabs: pinned first, then by creation time
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 && !b.isPinned) return -1;
77
- if (!a.isPinned && b.isPinned) return 1;
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 path (bộ 21 màu cố định) — bám thiết kế golden vinhhoa (icon/chữ trắng đọc
90
- // trên nền tối). KHÔNG dùng --primary để mỗi tab màu riêng, dễ phân biệt.
91
- const getTabBackgroundColor = (path: string, isActive: boolean) => {
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 normalizedPath = path.replace(/^\/[a-z]{2}(\/|$)/, "/");
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}
@@ -160,7 +206,9 @@ export function PageTabs({
160
206
  "border border-transparent",
161
207
  "cursor-pointer",
162
208
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
163
- getTabBackgroundColor(tab.path, isActive),
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",
164
212
  variant === "default" &&
165
213
  (isActive
166
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"
@@ -207,6 +255,16 @@ export function PageTabs({
207
255
  />
208
256
  ) : null}
209
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
+ )}
210
268
  <span
211
269
  className={cn(
212
270
  "truncate max-w-[150px]",
@@ -229,6 +287,30 @@ export function PageTabs({
229
287
  : tab.badge}
230
288
  </Badge>
231
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
+ )}
232
314
  <Button
233
315
  variant="ghost"
234
316
  size="icon"
@@ -245,7 +327,7 @@ export function PageTabs({
245
327
  </div>
246
328
  </ContextMenuTrigger>
247
329
  <ContextMenuContent>
248
- <ContextMenuItem onClick={() => handleReloadTab(tab.path)}>
330
+ <ContextMenuItem onClick={() => handleReloadTab(tab)}>
249
331
  Reload Tab
250
332
  </ContextMenuItem>
251
333
  <ContextMenuItem onClick={() => handleTabClick(tab.id)}>
@@ -46,6 +46,7 @@ type TabNavigationAction =
46
46
  | { type: "REMOVE_TAB"; payload: { id: string } }
47
47
  | { type: "SET_ACTIVE_TAB"; payload: { id: string } }
48
48
  | { type: "UPDATE_TAB_PATH"; payload: { id: string; path: string } }
49
+ | { type: "UPDATE_TAB_TITLE"; payload: { id: string; title: string } }
49
50
  | { type: "LOAD_STATE"; payload: TabNavigationState }
50
51
  | { type: "CLEAR_TABS" }
51
52
  | { type: "REMOVE_OTHER_TABS"; payload: { id: string } }
@@ -137,6 +138,17 @@ function tabNavigationReducer(
137
138
  };
138
139
  }
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
+
140
152
  case "LOAD_STATE": {
141
153
  return action.payload;
142
154
  }
@@ -199,6 +211,7 @@ interface TabNavigationContextValue {
199
211
  addTab: (path: string, title?: string) => void;
200
212
  removeTab: (id: string) => void;
201
213
  setActiveTab: (id: string) => void;
214
+ setTabTitle: (pathname: string, title: string) => void;
202
215
  clearTabs: () => void;
203
216
  removeOtherTabs: (id: string) => void;
204
217
  removeTabsToRight: (id: string) => void;
@@ -281,10 +294,22 @@ export function TabNavigationProvider({
281
294
  }
282
295
 
283
296
  // Get title and icon from navigation data or use pathname
284
- const title = findRouteTitle(pathname, navigations) || "Page";
297
+ const baseTitle = findRouteTitle(pathname, navigations) || "Page";
285
298
  const iconName = findRouteIcon(pathname, navigations) || undefined;
286
299
  const normalizedPath = normalizePathname(pathname);
287
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
+
288
313
  // Full URL incl. query string — window.location is already updated by
289
314
  // effect time, and reading it here keeps pathname-triggered and
290
315
  // searchSignal-triggered runs consistent with each other.
@@ -364,6 +389,13 @@ export function TabNavigationProvider({
364
389
  [state.tabs, router],
365
390
  );
366
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
+
367
399
  const clearTabs = useCallback(() => {
368
400
  dispatch({ type: "CLEAR_TABS" });
369
401
  }, []);
@@ -540,6 +572,7 @@ export function TabNavigationProvider({
540
572
  addTab,
541
573
  removeTab,
542
574
  setActiveTab,
575
+ setTabTitle,
543
576
  clearTabs,
544
577
  removeOtherTabs,
545
578
  removeTabsToRight,
@@ -563,6 +596,7 @@ const noopTabNavigation = {
563
596
  addTab: () => {},
564
597
  removeTab: () => {},
565
598
  setActiveTab: () => {},
599
+ setTabTitle: () => {},
566
600
  clearTabs: () => {},
567
601
  removeOtherTabs: () => {},
568
602
  removeTabsToRight: () => {},
@@ -576,3 +610,48 @@ export function useTabNavigation() {
576
610
  const context = useContext(TabNavigationContext);
577
611
  return context ?? noopTabNavigation;
578
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
+ }