@goplusvn/core 0.1.11 → 0.1.13
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 +61 -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/providers/index.tsx +19 -0
- package/src/styles/base.css +41 -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/layout/customizer.tsx +12 -42
- package/src/ui/layout/sidebar.tsx +7 -5
- 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/sidebar.tsx +25 -4
- package/src/ui/primitives/use-release-stuck-body-lock.ts +9 -16
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,66 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.13 — Customizer: make radius, density & inset/floating actually apply
|
|
4
|
+
|
|
5
|
+
The Customizer exposed controls that stored a value but changed nothing on
|
|
6
|
+
screen. Wired them up at the source so every app using core gets them working:
|
|
7
|
+
|
|
8
|
+
- **Radius ("Bo góc") now applies.** `SettingsProvider` drives the real
|
|
9
|
+
`--radius` token from `settings.radius`; Tailwind's `@theme inline` derives
|
|
10
|
+
`rounded-sm/md/lg/xl` from it, so the whole UI re-rounds live (0 → square,
|
|
11
|
+
1rem → fully rounded). Previously nothing read `settings.radius`.
|
|
12
|
+
- **Density ("Mật độ") now applies.** `SettingsProvider` sets
|
|
13
|
+
`data-density="compact"` on `<html>`; `base.css` tightens the global
|
|
14
|
+
`--spacing` scale (0.25rem → 0.215rem) so every spacing utility packs in
|
|
15
|
+
compact mode. Previously `useDensity()` was read nowhere.
|
|
16
|
+
- **Inset / floating sidebar no longer renders a harsh frame.** Core's default
|
|
17
|
+
sidebar is deep navy, and shadcn's inset/floating paints the page frame with
|
|
18
|
+
that sidebar colour — reading as a broken-looking block. The frame is now the
|
|
19
|
+
soft `--muted` neutral, so the content floats as a card on a calm page
|
|
20
|
+
(adapts to dark mode; the rounded-xl + shadow core already applies stay).
|
|
21
|
+
- **Inset / floating collapsed rail no longer looks squeezed.** In icon mode the
|
|
22
|
+
floating/inset content box is `--sidebar-width-icon + 8px` (56px) but had
|
|
23
|
+
`px-2.5` padding, leaving the pill only 36px — narrower than the default 48px
|
|
24
|
+
rail. Reduced to `px-1` so the pill is a comfortable 48px with a 4px floating
|
|
25
|
+
gutter.
|
|
26
|
+
- **Inset / floating sidebar corners are softened.** The floating panel was
|
|
27
|
+
`rounded-lg` and the inset panel had NO radius (a sharp navy block whose
|
|
28
|
+
top-right corner met the header as a hard 90°). Both now use `rounded-xl`
|
|
29
|
+
(matching the inset content card) so the sidebar reads as a soft floating
|
|
30
|
+
panel — the header/sidebar junction is a gentle curve, not a rough corner.
|
|
31
|
+
The floating content area also rounds both left corners
|
|
32
|
+
(`rounded-l-xl`, clipping the sticky header + footer via overflow-hidden) so
|
|
33
|
+
its whole left edge hugs the floating sidebar with soft curves; the right
|
|
34
|
+
edge stays flush to the viewport (inset already rounds via its `rounded-xl`
|
|
35
|
+
card). Finally, the header's own BOTTOM-left corner is rounded in both
|
|
36
|
+
variants (base.css: `[data-variant=floating|inset] ~ main > header`), so its
|
|
37
|
+
divider curves into the left edge instead of ending in a hard inner corner.
|
|
38
|
+
And the content no longer touches the sidebar: inset drops its `ml-0` (so the
|
|
39
|
+
uniform `m-2` leaves a left gap) and floating gains `ml-2`, giving a small
|
|
40
|
+
gap between the sidebar and the content/header.
|
|
41
|
+
- **"Ẩn ngoài" (`collapsible="offcanvas"`) can finally hide the sidebar.**
|
|
42
|
+
`AppSidebar` force-remapped `offcanvas → icon`, so the option collapsed to an
|
|
43
|
+
icon rail instead of hiding — identical to "Biểu tượng" and impossible to fully
|
|
44
|
+
hide the sidebar. The choice is now honoured as-is: offcanvas slides the
|
|
45
|
+
sidebar off-screen (reopen via the header toggle), icon → rail.
|
|
46
|
+
- **Dropped the "none" (Tắt) collapsible option from the Customizer.** Locking
|
|
47
|
+
the sidebar expanded made the header collapse-toggle a no-op — a
|
|
48
|
+
conflicting/dead control. The picker now offers only offcanvas + icon, both of
|
|
49
|
+
which the toggle actually drives. (`none` remains a valid type for any code
|
|
50
|
+
that sets it directly; it's just no longer offered in the UI.)
|
|
51
|
+
- **Dropped the "Bố cục" (layout: horizontal/vertical) control from the
|
|
52
|
+
Customizer.** The horizontal layout swaps the whole nav for a top menubar,
|
|
53
|
+
which turns every sidebar option ("Kiểu thanh bên", "Thu gọn thanh bên") into
|
|
54
|
+
a no-op — the same dead-control problem. The customizer now locks to the
|
|
55
|
+
vertical sidebar the apps are designed around (`defaultSettings.layout` stays
|
|
56
|
+
`vertical`; `HorizontalLayout` is untouched for code that sets it directly).
|
|
57
|
+
- **Non-collapsing sidebar (`collapsible="none"`) is now readable.** It rendered
|
|
58
|
+
`bg-sidebar text-sidebar-foreground` (the deep navy), but the group labels +
|
|
59
|
+
active item are `text-primary`, giving ~1.9:1 contrast (unreadable) on that
|
|
60
|
+
navy. A non-collapsing sidebar is permanently *expanded*, so it now uses the
|
|
61
|
+
expanded surface (`bg-background` / `text-foreground`), matching the rest of
|
|
62
|
+
the design (labels-on-light) — contrast jumps to ~9:1.
|
|
63
|
+
|
|
3
64
|
## 0.1.11 — print: force light colours on the print surface
|
|
4
65
|
|
|
5
66
|
- **Fix faded print output in dark mode.** Print pages render under the app's
|
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">
|