@goplusvn/core 0.1.10 → 0.1.12
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 +9 -0
- package/package.json +1 -1
- package/src/crud/components/crud-card-view.tsx +44 -13
- package/src/crud/components/crud-detail-dialog.tsx +355 -0
- package/src/crud/components/crud-dialog.tsx +2 -2
- package/src/crud/components/crud-form.tsx +2 -2
- package/src/crud/components/crud-page.tsx +80 -0
- package/src/crud/components/crud-provider.tsx +3 -0
- package/src/crud/components/crud-sheet.tsx +2 -2
- package/src/crud/components/crud-table.tsx +12 -0
- package/src/crud/components/index.tsx +1 -0
- package/src/crud/lib/crud-utils.ts +81 -0
- package/src/print/print-styles.tsx +5 -0
- package/src/types/index.ts +14 -0
- package/src/ui/data-display/data-table/data-table.tsx +18 -1
- package/src/ui/feedback/index.tsx +2 -0
- package/src/ui/primitives/body-lock-guard.tsx +64 -0
- package/src/ui/primitives/body-lock.ts +68 -0
- package/src/ui/primitives/client.ts +2 -0
- package/src/ui/primitives/use-release-stuck-body-lock.ts +9 -16
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.11 — print: force light colours on the print surface
|
|
4
|
+
|
|
5
|
+
- **Fix faded print output in dark mode.** Print pages render under the app's
|
|
6
|
+
shared `<html class="dark">`, so the print surface inherited dark mode's light
|
|
7
|
+
`--foreground` and rendered as faded grey text on the white page (preview and
|
|
8
|
+
PDF alike). `PrintStyles` now pins `color: #000; color-scheme: light;` on
|
|
9
|
+
`.print-container, #print-content`, keeping every shared print template
|
|
10
|
+
black-on-white regardless of the active theme.
|
|
11
|
+
|
|
3
12
|
## 0.1.8 — branding wiring + auth/rbac unification + clean typecheck
|
|
4
13
|
|
|
5
14
|
- **Single source for permission checks (security fix).** `@goerp/core/rbac`
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
4
5
|
import {
|
|
5
6
|
getCoreRowModel,
|
|
6
7
|
getPaginationRowModel,
|
|
@@ -9,7 +10,11 @@ import {
|
|
|
9
10
|
|
|
10
11
|
import type { CrudPermissions, CrudResponse, EntityConfig } from "../../types";
|
|
11
12
|
|
|
12
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
getRowDisplay,
|
|
15
|
+
isFieldVisibleInTable,
|
|
16
|
+
sortFieldsByOrder,
|
|
17
|
+
} from "../lib/crud-utils";
|
|
13
18
|
import { formatFieldValue } from "../lib/field-formatter";
|
|
14
19
|
|
|
15
20
|
import { Badge } from "../../ui";
|
|
@@ -34,6 +39,7 @@ interface CrudCardViewProps<TData = Record<string, unknown>> {
|
|
|
34
39
|
rowId: string,
|
|
35
40
|
rowData: Record<string, unknown>,
|
|
36
41
|
) => void | Promise<void>;
|
|
42
|
+
onRowClick?: (rowId: string, rowData: Record<string, unknown>) => void;
|
|
37
43
|
onEmptyStateAction?: {
|
|
38
44
|
onCreate?: () => void;
|
|
39
45
|
onClearSearch?: () => void;
|
|
@@ -49,6 +55,7 @@ export function CrudCardView<TData extends Record<string, unknown>>({
|
|
|
49
55
|
onEdit,
|
|
50
56
|
onDelete,
|
|
51
57
|
onCustomAction,
|
|
58
|
+
onRowClick,
|
|
52
59
|
onEmptyStateAction,
|
|
53
60
|
}: CrudCardViewProps<TData>) {
|
|
54
61
|
// ✅ All hooks must be called before any early returns
|
|
@@ -91,9 +98,12 @@ export function CrudCardView<TData extends Record<string, unknown>>({
|
|
|
91
98
|
const primaryField = config.fields.find(
|
|
92
99
|
(f) => f.name === config.displayField,
|
|
93
100
|
);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
101
|
+
// Show EVERY table-visible field in the card (mirrors the table columns), not
|
|
102
|
+
// just a 3-field preview. The resolved title field is skipped per-row at render
|
|
103
|
+
// time to avoid duplicating it.
|
|
104
|
+
const secondaryFields = visibleFields.filter(
|
|
105
|
+
(f) => f.name !== config.displayField,
|
|
106
|
+
);
|
|
97
107
|
|
|
98
108
|
if (loading) {
|
|
99
109
|
return (
|
|
@@ -145,15 +155,37 @@ export function CrudCardView<TData extends Record<string, unknown>>({
|
|
|
145
155
|
{data.data.map((row) => {
|
|
146
156
|
const rowId = String(row[config.idField]);
|
|
147
157
|
const isSelected = selectedRows.has(rowId);
|
|
148
|
-
|
|
149
|
-
const
|
|
158
|
+
// Friendly title — falls back when displayField is the id field.
|
|
159
|
+
const display = getRowDisplay(config, row as Record<string, unknown>);
|
|
160
|
+
const primaryContent = display.field?.renderCell
|
|
161
|
+
? (display.field.renderCell(display.value, row) as ReactNode)
|
|
162
|
+
: display.field
|
|
163
|
+
? formatFieldValue(display.value, display.field) ||
|
|
164
|
+
String(display.value ?? "")
|
|
165
|
+
: String(display.value ?? "");
|
|
150
166
|
|
|
151
167
|
return (
|
|
152
168
|
<Card
|
|
153
169
|
key={rowId}
|
|
154
170
|
className={`transition-all duration-150 hover:shadow-md ${
|
|
155
171
|
isSelected ? "ring-2 ring-primary" : ""
|
|
156
|
-
}`}
|
|
172
|
+
} ${onRowClick ? "cursor-pointer" : ""}`}
|
|
173
|
+
onClick={
|
|
174
|
+
onRowClick
|
|
175
|
+
? (e) => {
|
|
176
|
+
// Skip clicks on interactive controls (checkbox, actions menu)
|
|
177
|
+
const target = e.target as HTMLElement;
|
|
178
|
+
if (
|
|
179
|
+
target.closest(
|
|
180
|
+
'button, a, input, label, [role="checkbox"], [role="menuitem"], [data-no-row-click]',
|
|
181
|
+
)
|
|
182
|
+
) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
onRowClick(rowId, row as Record<string, unknown>);
|
|
186
|
+
}
|
|
187
|
+
: undefined
|
|
188
|
+
}
|
|
157
189
|
>
|
|
158
190
|
<CardHeader className="pb-3">
|
|
159
191
|
<div className="flex items-start justify-between gap-2">
|
|
@@ -163,16 +195,12 @@ export function CrudCardView<TData extends Record<string, unknown>>({
|
|
|
163
195
|
checked={isSelected}
|
|
164
196
|
onCheckedChange={() => toggleRowSelection(rowId)}
|
|
165
197
|
className="mt-0.5 shrink-0"
|
|
166
|
-
aria-label={`Select ${
|
|
198
|
+
aria-label={`Select ${String(display.value ?? "")}`}
|
|
167
199
|
/>
|
|
168
200
|
)}
|
|
169
201
|
<div className="flex-1 min-w-0">
|
|
170
202
|
<h3 className="font-semibold text-sm truncate">
|
|
171
|
-
{
|
|
172
|
-
? (primaryField.renderCell(primaryValue, row) as any)
|
|
173
|
-
: primaryField
|
|
174
|
-
? formatFieldValue(primaryValue, primaryField)
|
|
175
|
-
: String(primaryValue ?? "")}
|
|
203
|
+
{primaryContent}
|
|
176
204
|
</h3>
|
|
177
205
|
{primaryField?.description && (
|
|
178
206
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
@@ -199,6 +227,9 @@ export function CrudCardView<TData extends Record<string, unknown>>({
|
|
|
199
227
|
<CardContent className="pt-0">
|
|
200
228
|
<div className="space-y-2">
|
|
201
229
|
{secondaryFields.map((field) => {
|
|
230
|
+
// Skip the field already used as the card title (avoid dupes,
|
|
231
|
+
// e.g. when displayField falls back to a name/code field).
|
|
232
|
+
if (field.name === display.field?.name) return null;
|
|
202
233
|
const value = row[field.name];
|
|
203
234
|
if (value === null || value === undefined || value === "") {
|
|
204
235
|
return null;
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { Pencil, Trash2 } from "lucide-react";
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
CrudPermissions,
|
|
9
|
+
DynamicIconNameType,
|
|
10
|
+
EntityConfig,
|
|
11
|
+
FieldConfig,
|
|
12
|
+
} from "../../types";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
Badge,
|
|
16
|
+
Button,
|
|
17
|
+
DialogClose,
|
|
18
|
+
Dialog,
|
|
19
|
+
DialogContent,
|
|
20
|
+
DialogDescription,
|
|
21
|
+
DialogFooter,
|
|
22
|
+
DialogHeader,
|
|
23
|
+
DialogTitle,
|
|
24
|
+
DynamicIcon,
|
|
25
|
+
ScrollArea,
|
|
26
|
+
StatusBadge,
|
|
27
|
+
} from "../../ui";
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
getRowDisplay,
|
|
31
|
+
isFieldVisibleInDetail,
|
|
32
|
+
sortFieldsByOrder,
|
|
33
|
+
} from "../lib/crud-utils";
|
|
34
|
+
import { formatFieldValue } from "../lib/field-formatter";
|
|
35
|
+
import { dataLoader } from "../lib/data-loader";
|
|
36
|
+
|
|
37
|
+
interface CrudDetailDialogTranslations {
|
|
38
|
+
detail?: string;
|
|
39
|
+
edit?: string;
|
|
40
|
+
delete?: string;
|
|
41
|
+
close?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface CrudDetailDialogProps {
|
|
45
|
+
open: boolean;
|
|
46
|
+
onOpenChange: (open: boolean) => void;
|
|
47
|
+
config: EntityConfig;
|
|
48
|
+
data?: Record<string, unknown>;
|
|
49
|
+
permissions: CrudPermissions;
|
|
50
|
+
translations?: CrudDetailDialogTranslations;
|
|
51
|
+
onEdit?: (rowId: string, rowData: Record<string, unknown>) => void;
|
|
52
|
+
onDelete?: (rowId: string, rowData: Record<string, unknown>) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a relation FK column's label from the relation object the server already
|
|
57
|
+
* included on the row (e.g. `branchId` → `row.branch.name`). Mirrors the same helper
|
|
58
|
+
* in `crud-table.tsx` so detail values match table cells exactly.
|
|
59
|
+
*/
|
|
60
|
+
function resolveIncludedRelationLabel(
|
|
61
|
+
field: { name?: string; dataSource?: { labelField?: string } },
|
|
62
|
+
row: Record<string, unknown> | undefined,
|
|
63
|
+
): string | undefined {
|
|
64
|
+
const name = field?.name;
|
|
65
|
+
if (!field?.dataSource || !row || !name || !name.endsWith("Id")) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
const relationKey = name.slice(0, -2);
|
|
69
|
+
if (!relationKey) return undefined;
|
|
70
|
+
const rel = row[relationKey];
|
|
71
|
+
if (!rel || typeof rel !== "object" || Array.isArray(rel)) return undefined;
|
|
72
|
+
const labelField = field.dataSource.labelField || "name";
|
|
73
|
+
const label = (rel as Record<string, unknown>)[labelField];
|
|
74
|
+
return label != null && label !== "" ? String(label) : undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type OptionsMap = Map<
|
|
78
|
+
string,
|
|
79
|
+
Array<{ label: string; value: string | number | boolean }>
|
|
80
|
+
>;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Render a single field's value read-only, mirroring the table cell logic so the
|
|
84
|
+
* detail view stays consistent with the list. Note `renderCell` is only available
|
|
85
|
+
* on the non-serialized (client-config) path; through `EntityCrudPage` it is
|
|
86
|
+
* stripped, so the dataSource / relation / formatter fallbacks carry the load.
|
|
87
|
+
*/
|
|
88
|
+
function renderFieldValue(
|
|
89
|
+
field: FieldConfig,
|
|
90
|
+
row: Record<string, unknown>,
|
|
91
|
+
options: OptionsMap,
|
|
92
|
+
): ReactNode {
|
|
93
|
+
const value = row[field.name];
|
|
94
|
+
|
|
95
|
+
if (field.renderCell) {
|
|
96
|
+
return field.renderCell(value, row) as ReactNode;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const isEmpty =
|
|
100
|
+
value === null ||
|
|
101
|
+
value === undefined ||
|
|
102
|
+
value === "" ||
|
|
103
|
+
(Array.isArray(value) && value.length === 0);
|
|
104
|
+
|
|
105
|
+
if (field.type === "switch" || field.type === "boolean") {
|
|
106
|
+
if (typeof value === "boolean" && !field.options) {
|
|
107
|
+
return (
|
|
108
|
+
<StatusBadge
|
|
109
|
+
status={value ? "active" : "inactive"}
|
|
110
|
+
label={value ? "Có" : "Không"}
|
|
111
|
+
/>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const option = field.options?.find((opt) => {
|
|
115
|
+
const optValue = typeof opt === "object" ? opt.value : opt;
|
|
116
|
+
return optValue === value;
|
|
117
|
+
});
|
|
118
|
+
const label =
|
|
119
|
+
(typeof option === "object" ? option.label : option) ?? String(value);
|
|
120
|
+
if (isEmpty) return <span className="text-muted-foreground">—</span>;
|
|
121
|
+
return <StatusBadge status={value} label={String(label)} />;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (isEmpty) {
|
|
125
|
+
return <span className="text-muted-foreground">—</span>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (
|
|
129
|
+
field.dataSource &&
|
|
130
|
+
(field.type === "select" || field.type === "multiselect")
|
|
131
|
+
) {
|
|
132
|
+
const loaded = options.get(field.name) || [];
|
|
133
|
+
if (field.type === "multiselect" && Array.isArray(value)) {
|
|
134
|
+
const labels = value.map((val) => {
|
|
135
|
+
const opt = loaded.find((o) => String(o.value) === String(val));
|
|
136
|
+
return opt ? opt.label : String(val);
|
|
137
|
+
});
|
|
138
|
+
return (
|
|
139
|
+
<div className="flex flex-wrap gap-1">
|
|
140
|
+
{labels.map((l, i) => (
|
|
141
|
+
<Badge key={`${l}-${i}`} variant="secondary" className="font-normal">
|
|
142
|
+
{l}
|
|
143
|
+
</Badge>
|
|
144
|
+
))}
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const opt = loaded.find((o) => String(o.value) === String(value));
|
|
149
|
+
return (
|
|
150
|
+
opt?.label ??
|
|
151
|
+
resolveIncludedRelationLabel(field, row) ??
|
|
152
|
+
formatFieldValue(value, field)
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (field.type === "image" && typeof value === "string") {
|
|
157
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
158
|
+
return (
|
|
159
|
+
<img
|
|
160
|
+
src={value}
|
|
161
|
+
alt={field.label}
|
|
162
|
+
className="h-16 w-16 rounded-md border border-border object-cover"
|
|
163
|
+
/>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (field.type === "url" && typeof value === "string") {
|
|
168
|
+
return (
|
|
169
|
+
<a
|
|
170
|
+
href={value}
|
|
171
|
+
target="_blank"
|
|
172
|
+
rel="noopener noreferrer"
|
|
173
|
+
className="text-primary underline-offset-2 hover:underline break-all"
|
|
174
|
+
>
|
|
175
|
+
{value}
|
|
176
|
+
</a>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (field.type === "email" && typeof value === "string") {
|
|
181
|
+
return (
|
|
182
|
+
<a
|
|
183
|
+
href={`mailto:${value}`}
|
|
184
|
+
className="text-primary underline-offset-2 hover:underline break-all"
|
|
185
|
+
>
|
|
186
|
+
{value}
|
|
187
|
+
</a>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (field.type === "textarea" || field.type === "json") {
|
|
192
|
+
const text =
|
|
193
|
+
field.type === "json" && typeof value === "object"
|
|
194
|
+
? JSON.stringify(value, null, 2)
|
|
195
|
+
: formatFieldValue(value, field) || String(value);
|
|
196
|
+
return (
|
|
197
|
+
<span className="whitespace-pre-wrap break-words">{text}</span>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return formatFieldValue(value, field) || String(value);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function CrudDetailDialog({
|
|
205
|
+
open,
|
|
206
|
+
onOpenChange,
|
|
207
|
+
config,
|
|
208
|
+
data,
|
|
209
|
+
permissions,
|
|
210
|
+
translations,
|
|
211
|
+
onEdit,
|
|
212
|
+
onDelete,
|
|
213
|
+
}: CrudDetailDialogProps) {
|
|
214
|
+
// Load options for select/multiselect fields (same source as the table) so
|
|
215
|
+
// relation/enum values resolve to labels instead of raw ids.
|
|
216
|
+
const [dataSourceOptions, setDataSourceOptions] = useState<OptionsMap>(
|
|
217
|
+
new Map(),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
if (!config || !open) return;
|
|
222
|
+
|
|
223
|
+
const fieldsWithDataSource = config.fields.filter(
|
|
224
|
+
(field) =>
|
|
225
|
+
field.dataSource &&
|
|
226
|
+
(field.type === "select" || field.type === "multiselect"),
|
|
227
|
+
);
|
|
228
|
+
if (fieldsWithDataSource.length === 0) return;
|
|
229
|
+
|
|
230
|
+
let cancelled = false;
|
|
231
|
+
(async () => {
|
|
232
|
+
const optionsMap: OptionsMap = new Map();
|
|
233
|
+
await Promise.all(
|
|
234
|
+
fieldsWithDataSource.map(async (field) => {
|
|
235
|
+
if (!field.dataSource) return;
|
|
236
|
+
try {
|
|
237
|
+
const opts = await dataLoader.loadOptions(field.dataSource);
|
|
238
|
+
optionsMap.set(field.name, opts);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
console.error(`Failed to load options for ${field.name}:`, error);
|
|
241
|
+
optionsMap.set(field.name, []);
|
|
242
|
+
}
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
if (!cancelled) setDataSourceOptions(optionsMap);
|
|
246
|
+
})();
|
|
247
|
+
|
|
248
|
+
return () => {
|
|
249
|
+
cancelled = true;
|
|
250
|
+
};
|
|
251
|
+
}, [config, open]);
|
|
252
|
+
|
|
253
|
+
const fields = useMemo(
|
|
254
|
+
() =>
|
|
255
|
+
sortFieldsByOrder(config.fields).filter((f) =>
|
|
256
|
+
isFieldVisibleInDetail(f, config),
|
|
257
|
+
),
|
|
258
|
+
[config],
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const rowId = data ? String(data[config.idField]) : "";
|
|
262
|
+
// Resolve a friendly title (falls back when displayField is the id field).
|
|
263
|
+
const display = data ? getRowDisplay(config, data) : undefined;
|
|
264
|
+
const title = display
|
|
265
|
+
? display.field
|
|
266
|
+
? formatFieldValue(display.value, display.field) || String(display.value)
|
|
267
|
+
: String(display.value)
|
|
268
|
+
: config.label;
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
<Dialog open={open} onOpenChange={onOpenChange} modal={true}>
|
|
272
|
+
<DialogContent className="max-w-2xl max-h-[90vh] sm:max-h-[90vh] h-[100vh] sm:h-auto rounded-none sm:rounded-lg overflow-hidden flex flex-col p-0 gap-0">
|
|
273
|
+
{/* Header */}
|
|
274
|
+
<DialogHeader className="bg-background border-b px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 text-left space-y-0 shrink-0">
|
|
275
|
+
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
|
276
|
+
{config.iconName && (
|
|
277
|
+
<div className="p-1.5 rounded-lg bg-primary/10 shrink-0">
|
|
278
|
+
<DynamicIcon
|
|
279
|
+
name={config.iconName as DynamicIconNameType}
|
|
280
|
+
className="h-4 w-4 sm:h-5 sm:w-5 text-primary"
|
|
281
|
+
/>
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
<div className="min-w-0 flex-1">
|
|
285
|
+
<DialogTitle className="text-lg sm:text-xl font-bold truncate">
|
|
286
|
+
{title}
|
|
287
|
+
</DialogTitle>
|
|
288
|
+
<DialogDescription className="mt-0.5 text-xs text-muted-foreground">
|
|
289
|
+
{translations?.detail || "Chi tiết"} · {config.label}
|
|
290
|
+
</DialogDescription>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
</DialogHeader>
|
|
294
|
+
|
|
295
|
+
{/* Body */}
|
|
296
|
+
<ScrollArea className="flex-1 min-h-0">
|
|
297
|
+
{data ? (
|
|
298
|
+
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4 px-4 sm:px-6 py-4">
|
|
299
|
+
{fields.map((field) => {
|
|
300
|
+
const spanFull =
|
|
301
|
+
field.fullWidth ||
|
|
302
|
+
field.type === "textarea" ||
|
|
303
|
+
field.type === "json" ||
|
|
304
|
+
field.type === "image";
|
|
305
|
+
return (
|
|
306
|
+
<div
|
|
307
|
+
key={field.name}
|
|
308
|
+
className={`min-w-0 ${spanFull ? "sm:col-span-2" : ""}`}
|
|
309
|
+
>
|
|
310
|
+
<dt className="text-xs font-medium text-muted-foreground mb-1">
|
|
311
|
+
{field.label}
|
|
312
|
+
</dt>
|
|
313
|
+
<dd className="text-sm text-foreground break-words">
|
|
314
|
+
{renderFieldValue(field, data, dataSourceOptions)}
|
|
315
|
+
</dd>
|
|
316
|
+
</div>
|
|
317
|
+
);
|
|
318
|
+
})}
|
|
319
|
+
</dl>
|
|
320
|
+
) : (
|
|
321
|
+
<div className="px-6 py-10 text-center text-sm text-muted-foreground">
|
|
322
|
+
—
|
|
323
|
+
</div>
|
|
324
|
+
)}
|
|
325
|
+
</ScrollArea>
|
|
326
|
+
|
|
327
|
+
{/* Footer actions */}
|
|
328
|
+
<DialogFooter className="border-t bg-background px-4 sm:px-6 py-3 shrink-0 flex-row justify-end gap-2 sm:gap-2">
|
|
329
|
+
{permissions.delete && data && onDelete && (
|
|
330
|
+
<Button
|
|
331
|
+
variant="outline"
|
|
332
|
+
size="sm"
|
|
333
|
+
className="text-destructive hover:text-destructive mr-auto"
|
|
334
|
+
onClick={() => onDelete(rowId, data)}
|
|
335
|
+
>
|
|
336
|
+
<Trash2 className="mr-1.5 h-4 w-4" />
|
|
337
|
+
{translations?.delete || "Xóa"}
|
|
338
|
+
</Button>
|
|
339
|
+
)}
|
|
340
|
+
<DialogClose asChild>
|
|
341
|
+
<Button variant="outline" size="sm">
|
|
342
|
+
{translations?.close || "Đóng"}
|
|
343
|
+
</Button>
|
|
344
|
+
</DialogClose>
|
|
345
|
+
{permissions.update && data && onEdit && (
|
|
346
|
+
<Button size="sm" onClick={() => onEdit(rowId, data)}>
|
|
347
|
+
<Pencil className="mr-1.5 h-4 w-4" />
|
|
348
|
+
{translations?.edit || "Sửa"}
|
|
349
|
+
</Button>
|
|
350
|
+
)}
|
|
351
|
+
</DialogFooter>
|
|
352
|
+
</DialogContent>
|
|
353
|
+
</Dialog>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
@@ -310,8 +310,8 @@ export function CrudDialog({
|
|
|
310
310
|
</div>
|
|
311
311
|
</DialogHeader>
|
|
312
312
|
|
|
313
|
-
{/* Scrollable Form Content */}
|
|
314
|
-
<div className="flex-1 overflow-y-auto px-4 sm:px-6
|
|
313
|
+
{/* Scrollable Form Content — no bottom padding so the form's footer sits flush */}
|
|
314
|
+
<div className="flex-1 overflow-y-auto px-4 sm:px-6 pt-4">
|
|
315
315
|
<CrudForm
|
|
316
316
|
key={`${config.name}-${mode}-${initialData?.id || "new"}`}
|
|
317
317
|
ref={formRef}
|
|
@@ -591,8 +591,8 @@ const CrudFormComponent = forwardRef<HTMLFormElement, CrudFormProps>(
|
|
|
591
591
|
</div>
|
|
592
592
|
)}
|
|
593
593
|
|
|
594
|
-
{/* Form Actions */}
|
|
595
|
-
<div className="flex items-center justify-between gap-2
|
|
594
|
+
{/* Form Actions — flush footer bar pinned to the bottom */}
|
|
595
|
+
<div className="flex items-center justify-between gap-2 border-t sticky bottom-0 bg-background -mx-4 sm:-mx-6 px-4 sm:px-6 py-3">
|
|
596
596
|
{/* Draft indicator on left */}
|
|
597
597
|
{hasDraft && mode === "create" && enableAutoSave && (
|
|
598
598
|
<Badge variant="secondary" className="text-xs">
|
|
@@ -101,6 +101,19 @@ const CrudImportDialog = dynamic(
|
|
|
101
101
|
},
|
|
102
102
|
);
|
|
103
103
|
|
|
104
|
+
const CrudDetailDialog = dynamic(
|
|
105
|
+
() =>
|
|
106
|
+
import("./crud-detail-dialog").then((m) => ({
|
|
107
|
+
default: m.CrudDetailDialog,
|
|
108
|
+
})),
|
|
109
|
+
{
|
|
110
|
+
ssr: false, // Detail dialog doesn't need SSR
|
|
111
|
+
// No loading fallback: the dialog renders nothing while closed, so a spinner
|
|
112
|
+
// placeholder would flash at the bottom of the page on first mount.
|
|
113
|
+
loading: () => null,
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
104
117
|
const CrudExportButton = dynamic(
|
|
105
118
|
() =>
|
|
106
119
|
import("./crud-export-button").then((m) => ({
|
|
@@ -256,6 +269,8 @@ function CrudPageContent({
|
|
|
256
269
|
const [tableLoading, setTableLoading] = useState(false); // Separate loading state for table only
|
|
257
270
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
258
271
|
const [editingRowId, setEditingRowId] = useState<string | null>(null);
|
|
272
|
+
const [detailOpen, setDetailOpen] = useState(false);
|
|
273
|
+
const [detailRowId, setDetailRowId] = useState<string | null>(null);
|
|
259
274
|
const [tableInstance, setTableInstance] = useState<any>(null);
|
|
260
275
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
261
276
|
const [deletingRowId, setDeletingRowId] = useState<string | null>(null);
|
|
@@ -503,6 +518,33 @@ function CrudPageContent({
|
|
|
503
518
|
setDeleteDialogOpen(true);
|
|
504
519
|
};
|
|
505
520
|
|
|
521
|
+
// Row click behaviour. Default: editors jump straight to the edit form, viewers
|
|
522
|
+
// get the read-only detail dialog. Set features.rowClickAction="detail" to always
|
|
523
|
+
// open the read-only detail (with an Edit button), or showDetailOnRowClick=false
|
|
524
|
+
// to disable row click entirely.
|
|
525
|
+
const showDetailOnRowClick = config.features?.showDetailOnRowClick !== false;
|
|
526
|
+
const rowClickAction = config.features?.rowClickAction ?? "edit";
|
|
527
|
+
const handleRowClick = (rowId: string) => {
|
|
528
|
+
if (rowClickAction === "edit" && permissions.update) {
|
|
529
|
+
handleEdit(rowId);
|
|
530
|
+
} else {
|
|
531
|
+
setDetailRowId(rowId);
|
|
532
|
+
setDetailOpen(true);
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
// Edit/Delete launched from inside the detail dialog: close detail first, then
|
|
537
|
+
// open the target dialog on the next tick so the two Radix modals don't fight
|
|
538
|
+
// over focus / scroll-lock during the same render.
|
|
539
|
+
const handleDetailEdit = (rowId: string) => {
|
|
540
|
+
setDetailOpen(false);
|
|
541
|
+
setTimeout(() => handleEdit(rowId), 0);
|
|
542
|
+
};
|
|
543
|
+
const handleDetailDelete = (rowId: string) => {
|
|
544
|
+
setDetailOpen(false);
|
|
545
|
+
setTimeout(() => handleDelete(rowId), 0);
|
|
546
|
+
};
|
|
547
|
+
|
|
506
548
|
const handleCustomAction = async (
|
|
507
549
|
action: string,
|
|
508
550
|
rowId: string,
|
|
@@ -732,6 +774,16 @@ function CrudPageContent({
|
|
|
732
774
|
);
|
|
733
775
|
}, [deletingRowId, data.data, config.idField]);
|
|
734
776
|
|
|
777
|
+
// ✅ Memoize detailData (row shown in the read-only detail dialog)
|
|
778
|
+
const detailData = useMemo(() => {
|
|
779
|
+
if (!detailRowId) return undefined;
|
|
780
|
+
return data.data.find(
|
|
781
|
+
(row) =>
|
|
782
|
+
String((row as Record<string, unknown>)[config.idField]) ===
|
|
783
|
+
detailRowId,
|
|
784
|
+
);
|
|
785
|
+
}, [detailRowId, data.data, config.idField]);
|
|
786
|
+
|
|
735
787
|
return (
|
|
736
788
|
<>
|
|
737
789
|
<div className="flex flex-col h-full gap-2">
|
|
@@ -861,6 +913,9 @@ function CrudPageContent({
|
|
|
861
913
|
onEdit={handleEdit}
|
|
862
914
|
onDelete={handleDelete}
|
|
863
915
|
onCustomAction={handleCustomAction}
|
|
916
|
+
onRowClick={
|
|
917
|
+
showDetailOnRowClick ? handleRowClick : undefined
|
|
918
|
+
}
|
|
864
919
|
onTableReady={setTableInstance}
|
|
865
920
|
onEmptyStateAction={{
|
|
866
921
|
onCreate: handleCreate,
|
|
@@ -879,6 +934,9 @@ function CrudPageContent({
|
|
|
879
934
|
onEdit={handleEdit}
|
|
880
935
|
onDelete={handleDelete}
|
|
881
936
|
onCustomAction={handleCustomAction}
|
|
937
|
+
onRowClick={
|
|
938
|
+
showDetailOnRowClick ? handleRowClick : undefined
|
|
939
|
+
}
|
|
882
940
|
onEmptyStateAction={{
|
|
883
941
|
onCreate: handleCreate,
|
|
884
942
|
onClearSearch: () => setSearch(""),
|
|
@@ -951,6 +1009,28 @@ function CrudPageContent({
|
|
|
951
1009
|
}}
|
|
952
1010
|
/>
|
|
953
1011
|
)}
|
|
1012
|
+
|
|
1013
|
+
{/* Read-only detail dialog (opens on row click) */}
|
|
1014
|
+
{showDetailOnRowClick && (
|
|
1015
|
+
<CrudDetailDialog
|
|
1016
|
+
open={detailOpen}
|
|
1017
|
+
onOpenChange={(open) => {
|
|
1018
|
+
setDetailOpen(open);
|
|
1019
|
+
if (!open) setDetailRowId(null);
|
|
1020
|
+
}}
|
|
1021
|
+
config={config}
|
|
1022
|
+
data={detailData as Record<string, unknown> | undefined}
|
|
1023
|
+
permissions={permissions}
|
|
1024
|
+
translations={{
|
|
1025
|
+
detail: t("crud.common.detail", "Chi tiết"),
|
|
1026
|
+
edit: translations.edit,
|
|
1027
|
+
delete: translations.delete,
|
|
1028
|
+
close: t("crud.common.close", "Đóng"),
|
|
1029
|
+
}}
|
|
1030
|
+
onEdit={permissions.update ? handleDetailEdit : undefined}
|
|
1031
|
+
onDelete={permissions.delete ? handleDetailDelete : undefined}
|
|
1032
|
+
/>
|
|
1033
|
+
)}
|
|
954
1034
|
</>
|
|
955
1035
|
);
|
|
956
1036
|
}
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
SortingState,
|
|
11
11
|
} from "../../types";
|
|
12
12
|
import type { ReactNode } from "react";
|
|
13
|
+
import { BodyLockGuard } from "../../ui/primitives/body-lock-guard";
|
|
13
14
|
import {
|
|
14
15
|
CrudConfigContext,
|
|
15
16
|
CrudSelectionContext,
|
|
@@ -256,6 +257,8 @@ export function CrudProvider({
|
|
|
256
257
|
<CrudConfigContext.Provider value={configValue}>
|
|
257
258
|
<CrudStateContext.Provider value={stateValue}>
|
|
258
259
|
<CrudSelectionContext.Provider value={selectionValue}>
|
|
260
|
+
{/* Lưới an toàn: tự gỡ khoá <body> nếu Radix để sót sau khi đóng dialog */}
|
|
261
|
+
<BodyLockGuard />
|
|
259
262
|
{children}
|
|
260
263
|
</CrudSelectionContext.Provider>
|
|
261
264
|
</CrudStateContext.Provider>
|
|
@@ -265,8 +265,8 @@ export function CrudSheet({
|
|
|
265
265
|
</div>
|
|
266
266
|
</SheetHeader>
|
|
267
267
|
|
|
268
|
-
{/* Form Content */}
|
|
269
|
-
<div className="flex-1 overflow-y-auto px-6
|
|
268
|
+
{/* Form Content — no bottom padding so the form's footer sits flush */}
|
|
269
|
+
<div className="flex-1 overflow-y-auto px-6 pt-4">
|
|
270
270
|
<CrudForm
|
|
271
271
|
key={`${config.name}-${mode}-${initialData?.id || "new"}`}
|
|
272
272
|
ref={formRef}
|
|
@@ -67,6 +67,7 @@ interface CrudTableProps<TData = Record<string, unknown>> {
|
|
|
67
67
|
rowId: string,
|
|
68
68
|
rowData: Record<string, unknown>,
|
|
69
69
|
) => void | Promise<void>;
|
|
70
|
+
onRowClick?: (rowId: string, rowData: Record<string, unknown>) => void;
|
|
70
71
|
onTableReady?: (table: Table<TData>) => void;
|
|
71
72
|
onEmptyStateAction?: {
|
|
72
73
|
onCreate?: () => void;
|
|
@@ -82,6 +83,7 @@ export function CrudTable<TData extends Record<string, unknown>>({
|
|
|
82
83
|
onEdit,
|
|
83
84
|
onDelete,
|
|
84
85
|
onCustomAction,
|
|
86
|
+
onRowClick,
|
|
85
87
|
onTableReady,
|
|
86
88
|
onEmptyStateAction,
|
|
87
89
|
getTranslation,
|
|
@@ -368,6 +370,16 @@ export function CrudTable<TData extends Record<string, unknown>>({
|
|
|
368
370
|
selectedRows={selectedRows}
|
|
369
371
|
onSelectionChange={handleSelectionChange}
|
|
370
372
|
getRowId={getRowId}
|
|
373
|
+
// Row click → detail dialog
|
|
374
|
+
onRowClick={
|
|
375
|
+
onRowClick
|
|
376
|
+
? (row) =>
|
|
377
|
+
onRowClick(
|
|
378
|
+
String((row as TData)[config.idField as keyof TData]),
|
|
379
|
+
row as Record<string, unknown>,
|
|
380
|
+
)
|
|
381
|
+
: undefined
|
|
382
|
+
}
|
|
371
383
|
// Row Number
|
|
372
384
|
enableRowNumber={config.features?.showRowNumber !== false}
|
|
373
385
|
// Empty State
|
|
@@ -7,6 +7,7 @@ export { CrudTableToolbar } from "./crud-table-toolbar";
|
|
|
7
7
|
export { CrudRowActions } from "./crud-row-actions";
|
|
8
8
|
export { CrudForm } from "./crud-form";
|
|
9
9
|
export { CrudDialog } from "./crud-dialog";
|
|
10
|
+
export { CrudDetailDialog } from "./crud-detail-dialog";
|
|
10
11
|
export { CrudSheet } from "./crud-sheet";
|
|
11
12
|
export { CrudSearch } from "./crud-search";
|
|
12
13
|
export { CrudBulkActions } from "./crud-bulk-actions";
|
|
@@ -184,6 +184,87 @@ export function isFieldVisibleInForm(field: FieldConfig): boolean {
|
|
|
184
184
|
return !field.hideInForm && !field.isDisplayOnly;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Check if a field is visible in the read-only detail dialog.
|
|
189
|
+
* Detail shows everything except fields explicitly hidden from it and the id field
|
|
190
|
+
* (a raw UUID is noise — the title already identifies the record).
|
|
191
|
+
*/
|
|
192
|
+
export function isFieldVisibleInDetail(
|
|
193
|
+
field: FieldConfig,
|
|
194
|
+
config?: EntityConfig,
|
|
195
|
+
): boolean {
|
|
196
|
+
if (field.hideInDetail) return false;
|
|
197
|
+
if (config && (field.name === config.idField || field.name === "id")) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Resolve a human-friendly "title" value for a row (card header / detail dialog
|
|
205
|
+
* header). Normally this is `config.displayField`, but several transactional
|
|
206
|
+
* entities set `displayField: "id"` (no natural name) — rendering a raw UUID is
|
|
207
|
+
* ugly, so we fall back to a name-like field, an included relation's label, or the
|
|
208
|
+
* first meaningful text field, and only show a shortened id as a last resort.
|
|
209
|
+
*
|
|
210
|
+
* Returns the matching field (so the caller can format dates/numbers) plus the
|
|
211
|
+
* value; in the relation/text fallbacks the value is already a display string.
|
|
212
|
+
*/
|
|
213
|
+
export function getRowDisplay(
|
|
214
|
+
config: EntityConfig,
|
|
215
|
+
row: Record<string, unknown>,
|
|
216
|
+
): { field?: FieldConfig; value: unknown } {
|
|
217
|
+
const fields = config.fields;
|
|
218
|
+
const find = (n: string) => fields.find((f) => f.name === n);
|
|
219
|
+
const present = (v: unknown) => v !== null && v !== undefined && v !== "";
|
|
220
|
+
|
|
221
|
+
// 1. Configured displayField — unless it is the id field itself, or empty.
|
|
222
|
+
const dfValue = row[config.displayField];
|
|
223
|
+
if (
|
|
224
|
+
config.displayField &&
|
|
225
|
+
config.displayField !== config.idField &&
|
|
226
|
+
config.displayField !== "id" &&
|
|
227
|
+
present(dfValue)
|
|
228
|
+
) {
|
|
229
|
+
return { field: find(config.displayField), value: dfValue };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 2. A conventional name-like field.
|
|
233
|
+
const nameLike = ["name", "fullName", "displayName", "title", "label", "code"];
|
|
234
|
+
for (const key of nameLike) {
|
|
235
|
+
const v = row[key];
|
|
236
|
+
if (present(v)) return { field: find(key), value: v };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 3. An included relation's label (e.g. supplierId → row.supplier.name).
|
|
240
|
+
for (const f of fields) {
|
|
241
|
+
if (!f.name.endsWith("Id") || f.name === config.idField) continue;
|
|
242
|
+
const rel = row[f.name.slice(0, -2)];
|
|
243
|
+
if (rel && typeof rel === "object" && !Array.isArray(rel)) {
|
|
244
|
+
const r = rel as Record<string, unknown>;
|
|
245
|
+
const labelField = f.dataSource?.labelField || "name";
|
|
246
|
+
const label = r[labelField] ?? r.name ?? r.label ?? r.code ?? r.title;
|
|
247
|
+
if (present(label)) return { field: f, value: label };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 4. First visible, non-id, text-ish field with a value.
|
|
252
|
+
for (const f of fields) {
|
|
253
|
+
if (f.name === config.idField || f.hideInTable) continue;
|
|
254
|
+
if (["text", "textarea", "email", "select"].includes(f.type)) {
|
|
255
|
+
const v = row[f.name];
|
|
256
|
+
if (present(v)) return { field: f, value: v };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 5. Last resort: a shortened id, or the entity label.
|
|
261
|
+
const id = row[config.idField];
|
|
262
|
+
return {
|
|
263
|
+
field: undefined,
|
|
264
|
+
value: id ? `#${String(id).slice(0, 8)}` : config.label,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
187
268
|
/**
|
|
188
269
|
* Filter out display-only (DTO) fields from form data before submitting to API
|
|
189
270
|
* Display-only fields are computed/transformed fields, not actual entity fields
|
|
@@ -71,6 +71,11 @@ export function PrintStyles({ pageSize = "A4" }: PrintStylesProps) {
|
|
|
71
71
|
padding: 20px;
|
|
72
72
|
font-family: "Times New Roman", serif;
|
|
73
73
|
background: white;
|
|
74
|
+
/* Force light document colours so the page never inherits the app's
|
|
75
|
+
dark-mode foreground (which would render as faded grey text on the
|
|
76
|
+
white print surface). Both preview and @media print stay black-on-white. */
|
|
77
|
+
color: #000;
|
|
78
|
+
color-scheme: light;
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
/* Base layout */
|
package/src/types/index.ts
CHANGED
|
@@ -389,6 +389,8 @@ export interface FieldConfig {
|
|
|
389
389
|
validation?: z.ZodTypeAny;
|
|
390
390
|
hideInTable?: boolean;
|
|
391
391
|
hideInForm?: boolean;
|
|
392
|
+
/** Hide this field from the read-only detail dialog (row-click view). */
|
|
393
|
+
hideInDetail?: boolean;
|
|
392
394
|
showInImport?: boolean;
|
|
393
395
|
width?: number | string;
|
|
394
396
|
minWidth?: number | string;
|
|
@@ -637,6 +639,18 @@ export interface CrudFeatures {
|
|
|
637
639
|
import?: boolean;
|
|
638
640
|
showRowNumber?: boolean;
|
|
639
641
|
showRowSelection?: boolean;
|
|
642
|
+
/**
|
|
643
|
+
* Open something when a table/card row is clicked.
|
|
644
|
+
* Enabled by default — set to `false` to disable row click for an entity.
|
|
645
|
+
*/
|
|
646
|
+
showDetailOnRowClick?: boolean;
|
|
647
|
+
/**
|
|
648
|
+
* What a row click opens (when `showDetailOnRowClick` is not false):
|
|
649
|
+
* - `"edit"` (default): open the edit form if the user has update permission,
|
|
650
|
+
* otherwise the read-only detail dialog.
|
|
651
|
+
* - `"detail"`: always open the read-only detail dialog (with an Edit button).
|
|
652
|
+
*/
|
|
653
|
+
rowClickAction?: "edit" | "detail";
|
|
640
654
|
}
|
|
641
655
|
|
|
642
656
|
// ============================================================================
|
|
@@ -516,7 +516,24 @@ const MemoizedTableRow = memo(
|
|
|
516
516
|
className={`transition-colors duration-100 even:bg-muted/30 hover:bg-accent/50 dark:hover:bg-slate-800/40 data-[state=selected]:bg-primary/5 border-b border-border/40 dark:border-slate-800 relative hover:border-l-[3px] hover:border-l-primary ${
|
|
517
517
|
onRowClick ? "cursor-pointer" : ""
|
|
518
518
|
}`}
|
|
519
|
-
onClick={
|
|
519
|
+
onClick={
|
|
520
|
+
onRowClick
|
|
521
|
+
? (e) => {
|
|
522
|
+
// Don't trigger row click for interactive controls inside the row
|
|
523
|
+
// (selection checkbox, action menu trigger, links). The actions
|
|
524
|
+
// dropdown content is portaled, so only its trigger lives here.
|
|
525
|
+
const target = e.target as HTMLElement;
|
|
526
|
+
if (
|
|
527
|
+
target.closest(
|
|
528
|
+
'button, a, input, label, [role="checkbox"], [role="menuitem"], [data-no-row-click]',
|
|
529
|
+
)
|
|
530
|
+
) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
onRowClick(row.original);
|
|
534
|
+
}
|
|
535
|
+
: undefined
|
|
536
|
+
}
|
|
520
537
|
>
|
|
521
538
|
{row.getVisibleCells().map((cell) => (
|
|
522
539
|
<TableCell
|
|
@@ -24,6 +24,7 @@ import type { ComponentProps } from "react";
|
|
|
24
24
|
|
|
25
25
|
import { cn } from "../../utils";
|
|
26
26
|
import { buttonVariants } from "../primitives";
|
|
27
|
+
import { useReleaseStuckBodyLock } from "../primitives/use-release-stuck-body-lock";
|
|
27
28
|
|
|
28
29
|
export * from "./progress";
|
|
29
30
|
export * from "./sheet";
|
|
@@ -81,6 +82,7 @@ export function AlertDialogContent({
|
|
|
81
82
|
className,
|
|
82
83
|
...props
|
|
83
84
|
}: ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
|
85
|
+
useReleaseStuckBodyLock();
|
|
84
86
|
return (
|
|
85
87
|
<AlertDialogPortal>
|
|
86
88
|
<AlertDialogOverlay />
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { isBodyLocked, releaseStuckBodyLock } from "./body-lock";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Lưới an toàn TOÀN CỤC cho "lỗi conflict event kinh điển" của Radix: sau khi mở rồi
|
|
9
|
+
* đóng dialog (đặc biệt khi mở từ DropdownMenu của hàng), `<body>` đôi khi còn sót
|
|
10
|
+
* `pointer-events: none` khiến cả trang không click được.
|
|
11
|
+
*
|
|
12
|
+
* Hai lớp phòng vệ, không phụ thuộc vào việc primitive nào quên gỡ khoá:
|
|
13
|
+
*
|
|
14
|
+
* 1. MutationObserver theo dõi `style` + `data-scroll-locked` trên `<body>`. Khi modal
|
|
15
|
+
* unmount, RemoveScroll gỡ `data-scroll-locked` → tạo ra một mutation → ta kiểm tra
|
|
16
|
+
* lại và gỡ nốt `pointer-events` còn sót. Nhờ vậy trang tự hồi phục ngay sau animation
|
|
17
|
+
* đóng, KHÔNG cần người dùng click.
|
|
18
|
+
*
|
|
19
|
+
* 2. Bắt sự kiện ở pha capture trên `window` (pointerdown / keydown / focusin). Kể cả khi
|
|
20
|
+
* `<body>` đang `pointer-events: none`, sự kiện vẫn tới được `<html>`/window ở pha
|
|
21
|
+
* capture, nên lần tương tác kế tiếp luôn gỡ được khoá kẹt — không bao giờ kẹt vĩnh viễn.
|
|
22
|
+
*
|
|
23
|
+
* Mount MỘT lần ở tầng cao (vd trong CrudProvider) là đủ cho mọi trang CRUD.
|
|
24
|
+
*/
|
|
25
|
+
export function BodyLockGuard() {
|
|
26
|
+
React.useEffect(() => {
|
|
27
|
+
if (typeof document === "undefined") return;
|
|
28
|
+
|
|
29
|
+
let raf = 0;
|
|
30
|
+
const scheduleCheck = () => {
|
|
31
|
+
if (raf) cancelAnimationFrame(raf);
|
|
32
|
+
// rAF + macrotask: để Radix hoàn tất các thao tác đồng bộ của nó trước khi ta kiểm tra.
|
|
33
|
+
raf = requestAnimationFrame(() => {
|
|
34
|
+
raf = 0;
|
|
35
|
+
setTimeout(releaseStuckBodyLock, 0);
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// (1) Quan sát thay đổi thuộc tính khoá trên <body>.
|
|
40
|
+
const observer = new MutationObserver(scheduleCheck);
|
|
41
|
+
observer.observe(document.body, {
|
|
42
|
+
attributes: true,
|
|
43
|
+
attributeFilter: ["style", "data-scroll-locked"],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// (2) Tự hồi phục ở lần tương tác kế tiếp (chỉ chạy khi đang thực sự bị khoá).
|
|
47
|
+
const onInteract = () => {
|
|
48
|
+
if (isBodyLocked()) releaseStuckBodyLock();
|
|
49
|
+
};
|
|
50
|
+
window.addEventListener("pointerdown", onInteract, true);
|
|
51
|
+
window.addEventListener("keydown", onInteract, true);
|
|
52
|
+
window.addEventListener("focusin", onInteract, true);
|
|
53
|
+
|
|
54
|
+
return () => {
|
|
55
|
+
if (raf) cancelAnimationFrame(raf);
|
|
56
|
+
observer.disconnect();
|
|
57
|
+
window.removeEventListener("pointerdown", onInteract, true);
|
|
58
|
+
window.removeEventListener("keydown", onInteract, true);
|
|
59
|
+
window.removeEventListener("focusin", onInteract, true);
|
|
60
|
+
};
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* "Lỗi conflict event kinh điển": Radix (Dialog / AlertDialog / Sheet / DropdownMenu
|
|
5
|
+
* / Select / Popover) khoá `<body>` bằng `pointer-events: none` + `data-scroll-locked`
|
|
6
|
+
* khi mở, và gỡ khi đóng. Khi hai lớp chồng nhau (vd: bấm "Sửa" trong DropdownMenu để
|
|
7
|
+
* mở Dialog), bước gỡ đôi khi bị "đè", để sót khoá lại trên `<body>` → CẢ TRANG không
|
|
8
|
+
* click được sau khi đóng dialog.
|
|
9
|
+
*
|
|
10
|
+
* Helper này gỡ khoá MỘT CÁCH AN TOÀN: chỉ gỡ khi thực sự không còn modal nào đang mở.
|
|
11
|
+
* Tooltip (role="tooltip") cố tình bị loại trừ — tooltip không hề khoá body, nên một
|
|
12
|
+
* tooltip đang hiện KHÔNG được phép giữ khoá cũ tồn tại (đây là điểm yếu của bản cũ chỉ
|
|
13
|
+
* kiểm tra `[data-radix-popper-content-wrapper]`).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Modal đang THỰC SỰ mở (đang chặn tương tác). Loại trừ tooltip/hovercard.
|
|
17
|
+
const OPEN_MODAL_SELECTOR =
|
|
18
|
+
'[role="dialog"][data-state="open"],' +
|
|
19
|
+
'[role="alertdialog"][data-state="open"],' +
|
|
20
|
+
'[role="menu"][data-state="open"],' +
|
|
21
|
+
'[role="listbox"][data-state="open"]';
|
|
22
|
+
|
|
23
|
+
// Bất kỳ nội dung modal nào còn gắn trong DOM (kể cả đang chạy animation đóng,
|
|
24
|
+
// data-state="closed"). RemoveScroll giữ `data-scroll-locked` cho tới khi unmount,
|
|
25
|
+
// nên chỉ gỡ scroll-lock khi KHÔNG còn nội dung modal nào — tránh giật cuộn lúc đóng.
|
|
26
|
+
const MOUNTED_MODAL_SELECTOR =
|
|
27
|
+
'[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"]';
|
|
28
|
+
|
|
29
|
+
/** `<body>` có đang bị khoá (pointer-events hoặc scroll-lock) không. */
|
|
30
|
+
export function isBodyLocked(): boolean {
|
|
31
|
+
if (typeof document === "undefined") return false;
|
|
32
|
+
const body = document.body;
|
|
33
|
+
return (
|
|
34
|
+
body.style.pointerEvents === "none" ||
|
|
35
|
+
body.hasAttribute("data-scroll-locked")
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Có modal nào đang thực sự mở không (để biết khoá hiện tại có hợp lệ). */
|
|
40
|
+
export function hasOpenModal(): boolean {
|
|
41
|
+
if (typeof document === "undefined") return false;
|
|
42
|
+
return document.querySelector(OPEN_MODAL_SELECTOR) !== null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Gỡ khoá `<body>` nếu nó đang bị kẹt (không còn modal mở). An toàn để gọi nhiều lần
|
|
47
|
+
* và ở bất kỳ thời điểm nào: nếu còn modal mở thì không làm gì.
|
|
48
|
+
*/
|
|
49
|
+
export function releaseStuckBodyLock(): void {
|
|
50
|
+
if (typeof document === "undefined") return;
|
|
51
|
+
const body = document.body;
|
|
52
|
+
|
|
53
|
+
if (!isBodyLocked()) return;
|
|
54
|
+
// Còn modal đang mở → khoá là hợp lệ, đừng đụng vào.
|
|
55
|
+
if (hasOpenModal()) return;
|
|
56
|
+
|
|
57
|
+
// pointer-events luôn an toàn để khôi phục một khi không còn gì đang mở.
|
|
58
|
+
if (body.style.pointerEvents === "none") {
|
|
59
|
+
body.style.pointerEvents = "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Scroll-lock: chỉ gỡ khi không còn nội dung modal nào trong DOM, để không phá
|
|
63
|
+
// dialog đang chạy animation đóng.
|
|
64
|
+
if (!document.querySelector(MOUNTED_MODAL_SELECTOR)) {
|
|
65
|
+
if (body.style.overflow === "hidden") body.style.overflow = "";
|
|
66
|
+
body.removeAttribute("data-scroll-locked");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -21,6 +21,8 @@ export * from "./sticky-layout";
|
|
|
21
21
|
export * from "./prefetch-link";
|
|
22
22
|
export * from "./dynamic-icon";
|
|
23
23
|
export * from "./input-number";
|
|
24
|
+
export * from "./body-lock-guard";
|
|
25
|
+
export * from "./body-lock";
|
|
24
26
|
|
|
25
27
|
// Feedback Client Components
|
|
26
28
|
export * from "../feedback/sheet";
|
|
@@ -2,28 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
|
|
5
|
+
import { releaseStuckBodyLock } from "./body-lock";
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* Gọi trong *Content của mỗi primitive overlay (Dialog/Sheet/AlertDialog…). Content chỉ
|
|
9
|
+
* mount khi overlay mở; khi nó unmount (đã đóng) ta gỡ khoá body NẾU còn sót — nhưng chỉ
|
|
10
|
+
* khi không còn modal nào đang mở (xem {@link releaseStuckBodyLock}).
|
|
9
11
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* dialog/popover nào đang mở, để không phá modal khác đang chồng lên.
|
|
12
|
+
* Đây là lớp gỡ khoá TỨC THÌ tại thời điểm đóng; {@link BodyLockGuard} là lưới an toàn
|
|
13
|
+
* toàn cục bao quát các trường hợp còn lại.
|
|
13
14
|
*/
|
|
14
15
|
export function useReleaseStuckBodyLock() {
|
|
15
16
|
React.useEffect(() => {
|
|
16
17
|
return () => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
'[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"], [data-radix-popper-content-wrapper]',
|
|
20
|
-
);
|
|
21
|
-
if (!hasOpenLayer && document.body.style.pointerEvents === "none") {
|
|
22
|
-
document.body.style.pointerEvents = "";
|
|
23
|
-
document.body.style.overflow = "";
|
|
24
|
-
document.body.removeAttribute("data-scroll-locked");
|
|
25
|
-
}
|
|
26
|
-
}, 0);
|
|
18
|
+
// Đợi một nhịp để Radix chạy xong phần dọn dẹp đồng bộ của nó trước.
|
|
19
|
+
setTimeout(releaseStuckBodyLock, 0);
|
|
27
20
|
};
|
|
28
21
|
}, []);
|
|
29
22
|
}
|