@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 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,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.13",
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">