@goplusvn/core 0.1.11 → 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/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.11",
4
+ "version": "0.1.12",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -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 { isFieldVisibleInTable, sortFieldsByOrder } from "../lib/crud-utils";
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
- const secondaryFields = visibleFields
95
- .filter((f) => f.name !== config.displayField)
96
- .slice(0, 3); // Show max 3 secondary fields
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
- const primaryValue = row[config.displayField];
149
- const primaryLabel = primaryField?.label || config.displayField;
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 ${primaryValue}`}
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
- {primaryField?.renderCell
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 py-4">
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 pt-4 border-t sticky bottom-0 bg-background -mx-4 sm:-mx-6 px-4 sm:px-6 pb-0">
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 py-4">
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
@@ -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={onRowClick ? () => onRowClick(row.original) : undefined}
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
- * Lưới an toàn cho "lỗi conflict event kinh điển": Radix (Dialog/AlertDialog/Sheet
7
- * + Select/Combobox/Popover lồng bên trong) đôi khi để sót `pointer-events: none`
8
- * / scroll-lock trên <body> sau khi đóng, khiến TOÀN trang không click được.
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
- * Gọi hook này trong *Content của mỗi primitive overlay. Content chỉ mount khi
11
- * overlay mở; khi unmount (đã đóng) ta gỡ khoá — nhưng CHỈ khi không còn lớp
12
- * dialog/popover nào đang mở, để không phá modal khác đang chồng lên.
12
+ * Đây 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
- setTimeout(() => {
18
- const hasOpenLayer = document.querySelector(
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
  }