@goplusvn/core 0.1.33 → 0.1.35
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 +23 -0
- package/package.json +1 -1
- package/src/approval/__tests__/approval-engine.test.ts +84 -0
- package/src/approval/approval-engine.ts +80 -0
- package/src/approval/index.ts +9 -0
- package/src/approval/types.ts +60 -0
- package/src/ui/layout/page-tabs.tsx +96 -14
- package/src/ui/layout/tab-navigation-provider.tsx +80 -1
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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
|
|
3
|
+
import { planApproval, planRejection, resolveEffectiveSteps } from "../approval-engine"
|
|
4
|
+
import { ApprovalError, type ApprovalFlow } from "../types"
|
|
5
|
+
|
|
6
|
+
const FLOW: ApprovalFlow = {
|
|
7
|
+
entityType: "test-doc",
|
|
8
|
+
steps: [
|
|
9
|
+
{ level: 1, name: "Phê duyệt Lần 1", permission: { resource: "doc", action: "approve_l1" } },
|
|
10
|
+
{ level: 2, name: "Phê duyệt Lần 2", permission: { resource: "doc", action: "approve_l2" } },
|
|
11
|
+
{
|
|
12
|
+
level: 3,
|
|
13
|
+
name: "Phê duyệt Giám đốc",
|
|
14
|
+
permission: { resource: "doc", action: "approve_l3" },
|
|
15
|
+
minAmount: 5_000_000,
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const allow = () => true
|
|
21
|
+
const denyAll = () => false
|
|
22
|
+
|
|
23
|
+
describe("approval-engine", () => {
|
|
24
|
+
it("duyệt tuần tự n cấp: bước kế + isFinal đúng", async () => {
|
|
25
|
+
const p1 = await planApproval(FLOW, { amount: 10_000_000, completedLevel: 0, terminal: false }, allow)
|
|
26
|
+
expect(p1.step.level).toBe(1)
|
|
27
|
+
expect(p1.isFinal).toBe(false)
|
|
28
|
+
|
|
29
|
+
const p3 = await planApproval(FLOW, { amount: 10_000_000, completedLevel: 2, terminal: false }, allow)
|
|
30
|
+
expect(p3.step.level).toBe(3)
|
|
31
|
+
expect(p3.isFinal).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("điều kiện tiền: dưới ngưỡng bỏ qua cấp có minAmount", async () => {
|
|
35
|
+
const steps = resolveEffectiveSteps(FLOW, 1_000_000)
|
|
36
|
+
expect(steps.map((s) => s.level)).toEqual([1, 2])
|
|
37
|
+
|
|
38
|
+
const p2 = await planApproval(FLOW, { amount: 1_000_000, completedLevel: 1, terminal: false }, allow)
|
|
39
|
+
expect(p2.step.level).toBe(2)
|
|
40
|
+
expect(p2.isFinal).toBe(true) // L2 là bước cuối vì L3 bị lọc
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("amount null = áp dụng đủ mọi cấp", () => {
|
|
44
|
+
expect(resolveEffectiveSteps(FLOW, null)).toHaveLength(3)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("thiếu quyền của ĐÚNG bước hiện tại → 403 kèm tên bước", async () => {
|
|
48
|
+
const canOnlyL1 = (_r: string, a: string) => a === "approve_l1"
|
|
49
|
+
await expect(
|
|
50
|
+
planApproval(FLOW, { amount: null, completedLevel: 1, terminal: false }, canOnlyL1)
|
|
51
|
+
).rejects.toThrowError(/Phê duyệt Lần 2/)
|
|
52
|
+
await expect(
|
|
53
|
+
planApproval(FLOW, { amount: null, completedLevel: 1, terminal: false }, canOnlyL1)
|
|
54
|
+
).rejects.toMatchObject({ status: 403 })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("chứng từ terminal hoặc đã đủ cấp → ApprovalError", async () => {
|
|
58
|
+
await expect(
|
|
59
|
+
planApproval(FLOW, { amount: null, completedLevel: 0, terminal: true }, allow)
|
|
60
|
+
).rejects.toThrowError(/đã được xử lý/)
|
|
61
|
+
await expect(
|
|
62
|
+
planApproval(FLOW, { amount: null, completedLevel: 3, terminal: false }, allow)
|
|
63
|
+
).rejects.toThrowError(/đủ cấp/)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("planRejection: chặn terminal + thiếu quyền", async () => {
|
|
67
|
+
await expect(
|
|
68
|
+
planRejection(
|
|
69
|
+
{ amount: null, completedLevel: 1, terminal: true },
|
|
70
|
+
allow,
|
|
71
|
+
{ resource: "doc", action: "reject" },
|
|
72
|
+
"Bạn không có quyền Từ chối"
|
|
73
|
+
)
|
|
74
|
+
).rejects.toThrowError(/đã được xử lý/)
|
|
75
|
+
await expect(
|
|
76
|
+
planRejection(
|
|
77
|
+
{ amount: null, completedLevel: 1, terminal: false },
|
|
78
|
+
denyAll,
|
|
79
|
+
{ resource: "doc", action: "reject" },
|
|
80
|
+
"Bạn không có quyền Từ chối"
|
|
81
|
+
)
|
|
82
|
+
).rejects.toThrowError(/không có quyền Từ chối/)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ApprovalError,
|
|
3
|
+
type ApprovalFlow,
|
|
4
|
+
type ApprovalPlan,
|
|
5
|
+
type ApprovalState,
|
|
6
|
+
type ApprovalStep,
|
|
7
|
+
} from "./types"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* APPROVAL ENGINE — máy trạng thái duyệt n cấp dùng chung (Phase 6).
|
|
11
|
+
*
|
|
12
|
+
* Engine chỉ TÍNH TOÁN: bước nào đến lượt, ai được duyệt, có phải bước cuối
|
|
13
|
+
* không. Persistence (đổi status, ghi history, side-effect như tạo phiếu chi)
|
|
14
|
+
* do adapter của từng module thực hiện trong transaction của nó — engine
|
|
15
|
+
* không import Prisma nên promote lên core không kéo theo schema.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Lọc các bước áp dụng thực tế theo điều kiện số tiền. */
|
|
19
|
+
export function resolveEffectiveSteps(
|
|
20
|
+
flow: ApprovalFlow,
|
|
21
|
+
amount: number | null
|
|
22
|
+
): ApprovalStep[] {
|
|
23
|
+
const steps = [...flow.steps].sort((a, b) => a.level - b.level)
|
|
24
|
+
if (amount == null) return steps
|
|
25
|
+
return steps.filter((s) => s.minAmount == null || amount >= s.minAmount)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Xác định bước duyệt kế tiếp + kiểm tra quyền. Ném ApprovalError nếu chứng
|
|
30
|
+
* từ đã kết thúc, đã duyệt đủ cấp, hoặc người duyệt thiếu quyền của bước.
|
|
31
|
+
* `can` được phép async (quyền qua ủy quyền phải tra DB).
|
|
32
|
+
*/
|
|
33
|
+
export async function planApproval(
|
|
34
|
+
flow: ApprovalFlow,
|
|
35
|
+
state: ApprovalState,
|
|
36
|
+
can: (resource: string, action: string) => boolean | Promise<boolean>
|
|
37
|
+
): Promise<ApprovalPlan> {
|
|
38
|
+
if (state.terminal) {
|
|
39
|
+
throw new ApprovalError(
|
|
40
|
+
"Chứng từ này đã được xử lý (Đã duyệt hoặc Bị từ chối)"
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
const effectiveSteps = resolveEffectiveSteps(flow, state.amount)
|
|
44
|
+
const step = effectiveSteps[state.completedLevel]
|
|
45
|
+
if (!step) {
|
|
46
|
+
throw new ApprovalError("Chứng từ đã duyệt đủ cấp — không còn bước nào")
|
|
47
|
+
}
|
|
48
|
+
if (!(await can(step.permission.resource, step.permission.action))) {
|
|
49
|
+
throw new ApprovalError(
|
|
50
|
+
step.denyMessage ?? `Bạn không có quyền ${step.name}`,
|
|
51
|
+
403
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
step,
|
|
56
|
+
isFinal: state.completedLevel === effectiveSteps.length - 1,
|
|
57
|
+
effectiveSteps,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Kiểm tra điều kiện từ chối: chứng từ chưa kết thúc + có quyền. Trả về
|
|
63
|
+
* void — từ chối được phép ở BẤT KỲ cấp nào đang chờ (khớp hành vi 2 flow
|
|
64
|
+
* hiện có).
|
|
65
|
+
*/
|
|
66
|
+
export async function planRejection(
|
|
67
|
+
state: ApprovalState,
|
|
68
|
+
can: (resource: string, action: string) => boolean | Promise<boolean>,
|
|
69
|
+
permission: { resource: string; action: string },
|
|
70
|
+
denyMessage: string
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
if (state.terminal) {
|
|
73
|
+
throw new ApprovalError(
|
|
74
|
+
"Chứng từ này đã được xử lý (Đã duyệt hoặc Bị từ chối)"
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
if (!(await can(permission.resource, permission.action))) {
|
|
78
|
+
throw new ApprovalError(denyMessage, 403)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* APPROVAL ENGINE — máy trạng thái duyệt n cấp dùng chung (@goerp/core/approval).
|
|
3
|
+
*
|
|
4
|
+
* Thuần logic, Prisma-agnostic: consumer tự cung cấp `can(resource, action)`
|
|
5
|
+
* (đồng bộ/bất đồng bộ) + flow. Persistence + delegation + DB-config do app lo.
|
|
6
|
+
* Promote từ vinhhoa (Phase 6, KE_HOACH_ERP_THUC_THU.md).
|
|
7
|
+
*/
|
|
8
|
+
export * from "./types"
|
|
9
|
+
export * from "./approval-engine"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* APPROVAL ENGINE — kiểu dữ liệu (Phase 6, KE_HOACH_ERP_THUC_THU.md).
|
|
3
|
+
*
|
|
4
|
+
* Trừu tượng hóa 2 bản duyệt cài riêng (PO + đề nghị thanh toán) thành một
|
|
5
|
+
* máy trạng thái n cấp dùng chung. Engine THUẦN LOGIC — không import Prisma,
|
|
6
|
+
* không side-effect: người gọi (adapter trong module) tự lo persistence
|
|
7
|
+
* trong transaction của mình. Viết kiểu DI để promote lên @goerp/core (M3).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface ApprovalStep {
|
|
11
|
+
/** Cấp duyệt, 1-based, tăng dần. */
|
|
12
|
+
level: number
|
|
13
|
+
/** Tên hiển thị của cấp ("Kế toán", "Giám đốc", "Phê duyệt Lần 1"...). */
|
|
14
|
+
name: string
|
|
15
|
+
/** Quyền RBAC yêu cầu để duyệt cấp này. */
|
|
16
|
+
permission: { resource: string; action: string }
|
|
17
|
+
/**
|
|
18
|
+
* Điều kiện theo số tiền: bước CHỈ áp dụng khi amount >= minAmount.
|
|
19
|
+
* Bỏ trống = luôn áp dụng. (Ví dụ: dưới 5tr bỏ qua cấp Giám đốc.)
|
|
20
|
+
*/
|
|
21
|
+
minAmount?: number
|
|
22
|
+
/** Message khi thiếu quyền — mặc định "Bạn không có quyền <name>". */
|
|
23
|
+
denyMessage?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ApprovalFlow {
|
|
27
|
+
/** Loại chứng từ ("payment-request", "purchase-order"...). */
|
|
28
|
+
entityType: string
|
|
29
|
+
steps: ApprovalStep[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Trạng thái duyệt hiện tại của chứng từ — adapter map từ model riêng. */
|
|
33
|
+
export interface ApprovalState {
|
|
34
|
+
/** Số tiền chứng từ (null = không có điều kiện tiền). */
|
|
35
|
+
amount: number | null
|
|
36
|
+
/** Số CẤP đã duyệt xong (0 = chưa cấp nào; tính theo effective steps). */
|
|
37
|
+
completedLevel: number
|
|
38
|
+
/** Đã kết thúc (approved/rejected) — mọi thao tác duyệt tiếp bị chặn. */
|
|
39
|
+
terminal: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ApprovalPlan {
|
|
43
|
+
/** Bước cần duyệt bây giờ. */
|
|
44
|
+
step: ApprovalStep
|
|
45
|
+
/** true nếu đây là bước cuối → sau bước này chứng từ approved. */
|
|
46
|
+
isFinal: boolean
|
|
47
|
+
/** Danh sách bước áp dụng thực tế sau khi lọc điều kiện tiền. */
|
|
48
|
+
effectiveSteps: ApprovalStep[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Lỗi nghiệp vụ của engine — message hiển thị thẳng cho user (VN). */
|
|
52
|
+
export class ApprovalError extends Error {
|
|
53
|
+
readonly status: number
|
|
54
|
+
|
|
55
|
+
constructor(message: string, status = 400) {
|
|
56
|
+
super(message)
|
|
57
|
+
this.name = "ApprovalError"
|
|
58
|
+
this.status = status
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -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}
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
+
}
|