@asteby/metacore-runtime-react 34.1.0 → 35.1.0
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 +7 -86
- package/dist/action-modal-dispatcher.d.ts +4 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +102 -23
- package/dist/addon-loader.d.ts +1 -1
- package/dist/addon-loader.d.ts.map +1 -1
- package/dist/addon-loader.js +8 -1
- package/dist/color-picker-field.d.ts +13 -0
- package/dist/color-picker-field.d.ts.map +1 -0
- package/dist/color-picker-field.js +181 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +99 -61
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +23 -8
- package/dist/dynamic-form.d.ts +1 -0
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +3 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +6 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/permissions-manager.d.ts +5 -0
- package/dist/permissions-manager.d.ts.map +1 -1
- package/dist/permissions-manager.js +67 -23
- package/dist/use-print-document.d.ts +10 -1
- package/dist/use-print-document.d.ts.map +1 -1
- package/dist/use-print-document.js +28 -1
- package/package.json +1 -1
- package/src/__tests__/color-picker-field.test.tsx +18 -0
- package/src/__tests__/filename-from-disposition.test.ts +30 -0
- package/src/__tests__/prefill-scalar-from-record.test.ts +37 -0
- package/src/__tests__/record-detail-display.test.tsx +30 -0
- package/src/action-modal-dispatcher.tsx +110 -22
- package/src/addon-loader.tsx +7 -1
- package/src/color-picker-field.tsx +296 -0
- package/src/dialogs/dynamic-record.tsx +139 -65
- package/src/dynamic-columns.tsx +28 -12
- package/src/dynamic-form.tsx +9 -1
- package/src/dynamic-select-field.tsx +7 -3
- package/src/index.ts +2 -0
- package/src/permissions-manager.tsx +98 -39
- package/src/use-print-document.ts +38 -2
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// ColorPickerField — modern popover color control (SV plane + hue + hex).
|
|
2
|
+
// Used by PermissionsManager role dialog and DynamicForm `type: "color"`.
|
|
3
|
+
// Value is always a #rrggbb string (empty → falls back to DEFAULT_HEX visually).
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
5
|
+
import { Pipette } from 'lucide-react'
|
|
6
|
+
import { cn } from '@asteby/metacore-ui/lib'
|
|
7
|
+
import {
|
|
8
|
+
Button,
|
|
9
|
+
Input,
|
|
10
|
+
Popover,
|
|
11
|
+
PopoverContent,
|
|
12
|
+
PopoverTrigger,
|
|
13
|
+
} from '@asteby/metacore-ui/primitives'
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_ROLE_COLOR = '#3b82f6'
|
|
16
|
+
|
|
17
|
+
export interface ColorPickerFieldProps {
|
|
18
|
+
value?: string
|
|
19
|
+
onChange: (hex: string) => void
|
|
20
|
+
/** Optional label for a11y on the trigger. */
|
|
21
|
+
'aria-label'?: string
|
|
22
|
+
className?: string
|
|
23
|
+
disabled?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type HSV = { h: number; s: number; v: number }
|
|
27
|
+
|
|
28
|
+
function clamp(n: number, min: number, max: number) {
|
|
29
|
+
return Math.min(max, Math.max(min, n))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Normalize to #rrggbb or '' if invalid. */
|
|
33
|
+
export function normalizeHex(raw: unknown): string {
|
|
34
|
+
if (typeof raw !== 'string') return ''
|
|
35
|
+
let s = raw.trim()
|
|
36
|
+
if (!s) return ''
|
|
37
|
+
if (s[0] !== '#') s = `#${s}`
|
|
38
|
+
if (/^#[0-9a-fA-F]{3}$/.test(s)) {
|
|
39
|
+
const r = s[1],
|
|
40
|
+
g = s[2],
|
|
41
|
+
b = s[3]
|
|
42
|
+
s = `#${r}${r}${g}${g}${b}${b}`
|
|
43
|
+
}
|
|
44
|
+
if (!/^#[0-9a-fA-F]{6}$/.test(s)) return ''
|
|
45
|
+
return s.toLowerCase()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
|
49
|
+
const n = normalizeHex(hex)
|
|
50
|
+
if (!n) return null
|
|
51
|
+
return {
|
|
52
|
+
r: parseInt(n.slice(1, 3), 16),
|
|
53
|
+
g: parseInt(n.slice(3, 5), 16),
|
|
54
|
+
b: parseInt(n.slice(5, 7), 16),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function rgbToHex(r: number, g: number, b: number): string {
|
|
59
|
+
const h = (n: number) =>
|
|
60
|
+
clamp(Math.round(n), 0, 255).toString(16).padStart(2, '0')
|
|
61
|
+
return `#${h(r)}${h(g)}${h(b)}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function rgbToHsv(r: number, g: number, b: number): HSV {
|
|
65
|
+
r /= 255
|
|
66
|
+
g /= 255
|
|
67
|
+
b /= 255
|
|
68
|
+
const max = Math.max(r, g, b)
|
|
69
|
+
const min = Math.min(r, g, b)
|
|
70
|
+
const d = max - min
|
|
71
|
+
let h = 0
|
|
72
|
+
if (d !== 0) {
|
|
73
|
+
if (max === r) h = ((g - b) / d) % 6
|
|
74
|
+
else if (max === g) h = (b - r) / d + 2
|
|
75
|
+
else h = (r - g) / d + 4
|
|
76
|
+
h *= 60
|
|
77
|
+
if (h < 0) h += 360
|
|
78
|
+
}
|
|
79
|
+
const s = max === 0 ? 0 : d / max
|
|
80
|
+
return { h, s, v: max }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function hsvToRgb(h: number, s: number, v: number): { r: number; g: number; b: number } {
|
|
84
|
+
const c = v * s
|
|
85
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
|
|
86
|
+
const m = v - c
|
|
87
|
+
let rp = 0,
|
|
88
|
+
gp = 0,
|
|
89
|
+
bp = 0
|
|
90
|
+
if (h < 60) [rp, gp, bp] = [c, x, 0]
|
|
91
|
+
else if (h < 120) [rp, gp, bp] = [x, c, 0]
|
|
92
|
+
else if (h < 180) [rp, gp, bp] = [0, c, x]
|
|
93
|
+
else if (h < 240) [rp, gp, bp] = [0, x, c]
|
|
94
|
+
else if (h < 300) [rp, gp, bp] = [x, 0, c]
|
|
95
|
+
else [rp, gp, bp] = [c, 0, x]
|
|
96
|
+
return {
|
|
97
|
+
r: (rp + m) * 255,
|
|
98
|
+
g: (gp + m) * 255,
|
|
99
|
+
b: (bp + m) * 255,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function hexToHsv(hex: string): HSV {
|
|
104
|
+
const rgb = hexToRgb(hex) || hexToRgb(DEFAULT_ROLE_COLOR)!
|
|
105
|
+
return rgbToHsv(rgb.r, rgb.g, rgb.b)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function hsvToHex(hsv: HSV): string {
|
|
109
|
+
const { r, g, b } = hsvToRgb(hsv.h, hsv.s, hsv.v)
|
|
110
|
+
return rgbToHex(r, g, b)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function hueCss(h: number): string {
|
|
114
|
+
const { r, g, b } = hsvToRgb(h, 1, 1)
|
|
115
|
+
return rgbToHex(r, g, b)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function ColorPickerField({
|
|
119
|
+
value,
|
|
120
|
+
onChange,
|
|
121
|
+
'aria-label': ariaLabel = 'Color',
|
|
122
|
+
className,
|
|
123
|
+
disabled,
|
|
124
|
+
}: ColorPickerFieldProps) {
|
|
125
|
+
const hex = normalizeHex(value) || DEFAULT_ROLE_COLOR
|
|
126
|
+
const [open, setOpen] = useState(false)
|
|
127
|
+
const [hsv, setHsv] = useState<HSV>(() => hexToHsv(hex))
|
|
128
|
+
const [hexDraft, setHexDraft] = useState(hex)
|
|
129
|
+
const svRef = useRef<HTMLDivElement>(null)
|
|
130
|
+
const dragging = useRef(false)
|
|
131
|
+
|
|
132
|
+
// Sync from external value when popover closed (or first open).
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (dragging.current) return
|
|
135
|
+
const next = normalizeHex(value) || DEFAULT_ROLE_COLOR
|
|
136
|
+
setHsv(hexToHsv(next))
|
|
137
|
+
setHexDraft(next)
|
|
138
|
+
}, [value, open])
|
|
139
|
+
|
|
140
|
+
const commit = useCallback(
|
|
141
|
+
(next: HSV) => {
|
|
142
|
+
setHsv(next)
|
|
143
|
+
const out = hsvToHex(next)
|
|
144
|
+
setHexDraft(out)
|
|
145
|
+
onChange(out)
|
|
146
|
+
},
|
|
147
|
+
[onChange],
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
const setFromPointer = useCallback(
|
|
151
|
+
(clientX: number, clientY: number) => {
|
|
152
|
+
const el = svRef.current
|
|
153
|
+
if (!el) return
|
|
154
|
+
const rect = el.getBoundingClientRect()
|
|
155
|
+
const s = clamp((clientX - rect.left) / rect.width, 0, 1)
|
|
156
|
+
const v = clamp(1 - (clientY - rect.top) / rect.height, 0, 1)
|
|
157
|
+
commit({ ...hsv, s, v })
|
|
158
|
+
},
|
|
159
|
+
[commit, hsv],
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!open) return
|
|
164
|
+
const onMove = (e: PointerEvent) => {
|
|
165
|
+
if (!dragging.current) return
|
|
166
|
+
setFromPointer(e.clientX, e.clientY)
|
|
167
|
+
}
|
|
168
|
+
const onUp = () => {
|
|
169
|
+
dragging.current = false
|
|
170
|
+
}
|
|
171
|
+
window.addEventListener('pointermove', onMove)
|
|
172
|
+
window.addEventListener('pointerup', onUp)
|
|
173
|
+
return () => {
|
|
174
|
+
window.removeEventListener('pointermove', onMove)
|
|
175
|
+
window.removeEventListener('pointerup', onUp)
|
|
176
|
+
}
|
|
177
|
+
}, [open, setFromPointer])
|
|
178
|
+
|
|
179
|
+
const svStyle = useMemo(
|
|
180
|
+
() => ({
|
|
181
|
+
background: `
|
|
182
|
+
linear-gradient(to top, #000, transparent),
|
|
183
|
+
linear-gradient(to right, #fff, ${hueCss(hsv.h)})
|
|
184
|
+
`,
|
|
185
|
+
}),
|
|
186
|
+
[hsv.h],
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
const applyHexDraft = () => {
|
|
190
|
+
const n = normalizeHex(hexDraft)
|
|
191
|
+
if (!n) {
|
|
192
|
+
setHexDraft(hsvToHex(hsv))
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
setHsv(hexToHsv(n))
|
|
196
|
+
setHexDraft(n)
|
|
197
|
+
onChange(n)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
202
|
+
<PopoverTrigger asChild>
|
|
203
|
+
<Button
|
|
204
|
+
type="button"
|
|
205
|
+
variant="outline"
|
|
206
|
+
disabled={disabled}
|
|
207
|
+
aria-label={ariaLabel}
|
|
208
|
+
className={cn(
|
|
209
|
+
'h-10 w-full justify-start gap-3 px-2.5 font-normal',
|
|
210
|
+
className,
|
|
211
|
+
)}
|
|
212
|
+
>
|
|
213
|
+
<span
|
|
214
|
+
className="h-6 w-6 shrink-0 rounded-md border border-black/10 shadow-sm ring-1 ring-black/5 dark:border-white/10"
|
|
215
|
+
style={{ background: hex }}
|
|
216
|
+
aria-hidden
|
|
217
|
+
/>
|
|
218
|
+
<span className="font-mono text-sm uppercase tracking-wide text-foreground">
|
|
219
|
+
{hex}
|
|
220
|
+
</span>
|
|
221
|
+
<Pipette className="ml-auto h-3.5 w-3.5 text-muted-foreground" />
|
|
222
|
+
</Button>
|
|
223
|
+
</PopoverTrigger>
|
|
224
|
+
<PopoverContent className="w-[260px] p-3" align="start">
|
|
225
|
+
<div className="flex flex-col gap-3">
|
|
226
|
+
<div
|
|
227
|
+
ref={svRef}
|
|
228
|
+
role="slider"
|
|
229
|
+
aria-label="Saturación y brillo"
|
|
230
|
+
tabIndex={0}
|
|
231
|
+
className="relative h-36 w-full cursor-crosshair touch-none overflow-hidden rounded-lg border border-border/60"
|
|
232
|
+
style={svStyle}
|
|
233
|
+
onPointerDown={(e) => {
|
|
234
|
+
dragging.current = true
|
|
235
|
+
;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)
|
|
236
|
+
setFromPointer(e.clientX, e.clientY)
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
<span
|
|
240
|
+
className="pointer-events-none absolute h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-md ring-1 ring-black/30"
|
|
241
|
+
style={{
|
|
242
|
+
left: `${hsv.s * 100}%`,
|
|
243
|
+
top: `${(1 - hsv.v) * 100}%`,
|
|
244
|
+
background: hsvToHex(hsv),
|
|
245
|
+
}}
|
|
246
|
+
/>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<div className="flex flex-col gap-1.5">
|
|
250
|
+
<label className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
251
|
+
Tono
|
|
252
|
+
</label>
|
|
253
|
+
<input
|
|
254
|
+
type="range"
|
|
255
|
+
min={0}
|
|
256
|
+
max={360}
|
|
257
|
+
step={1}
|
|
258
|
+
value={Math.round(hsv.h)}
|
|
259
|
+
aria-label="Tono"
|
|
260
|
+
onChange={(e) =>
|
|
261
|
+
commit({ ...hsv, h: Number(e.target.value) })
|
|
262
|
+
}
|
|
263
|
+
className="h-3 w-full cursor-pointer appearance-none rounded-full"
|
|
264
|
+
style={{
|
|
265
|
+
background:
|
|
266
|
+
'linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)',
|
|
267
|
+
}}
|
|
268
|
+
/>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<div className="flex items-center gap-2">
|
|
272
|
+
<span
|
|
273
|
+
className="h-9 w-9 shrink-0 rounded-md border border-black/10 shadow-inner ring-1 ring-black/5"
|
|
274
|
+
style={{ background: hsvToHex(hsv) }}
|
|
275
|
+
aria-hidden
|
|
276
|
+
/>
|
|
277
|
+
<Input
|
|
278
|
+
value={hexDraft}
|
|
279
|
+
spellCheck={false}
|
|
280
|
+
aria-label="Hexadecimal"
|
|
281
|
+
className="h-9 font-mono uppercase"
|
|
282
|
+
onChange={(e) => setHexDraft(e.target.value)}
|
|
283
|
+
onBlur={applyHexDraft}
|
|
284
|
+
onKeyDown={(e) => {
|
|
285
|
+
if (e.key === 'Enter') {
|
|
286
|
+
e.preventDefault()
|
|
287
|
+
applyHexDraft()
|
|
288
|
+
}
|
|
289
|
+
}}
|
|
290
|
+
/>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
</PopoverContent>
|
|
294
|
+
</Popover>
|
|
295
|
+
)
|
|
296
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// flows through <ApiProvider> from runtime-react. Host-specific runtime values —
|
|
10
10
|
// the image-url resolver and the org IANA timezone — are passed as props so the
|
|
11
11
|
// SDK stays transport- and host-agnostic.
|
|
12
|
-
import { createContext, useCallback, useContext, useEffect,
|
|
12
|
+
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
|
13
13
|
import { useTranslation } from 'react-i18next'
|
|
14
14
|
import type { ModelSchema } from './types'
|
|
15
15
|
|
|
@@ -53,9 +53,7 @@ import { es } from 'date-fns/locale'
|
|
|
53
53
|
import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon, ScanLine } from 'lucide-react'
|
|
54
54
|
import { BarcodeScanner } from '../barcode-scanner'
|
|
55
55
|
import { useApi } from '../api-context'
|
|
56
|
-
import { toastServerError, extractFieldErrors,
|
|
57
|
-
import { validateValues, bagHasErrors } from '../validator'
|
|
58
|
-
import { validationCatalog } from '../validation-catalog'
|
|
56
|
+
import { toastServerError, extractFieldErrors, localizeFieldIssue } from '../server-error'
|
|
59
57
|
import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field'
|
|
60
58
|
import { DynamicRelations } from '../dynamic-relations'
|
|
61
59
|
import { useOptionsResolver, type ResolvedOption } from '../use-options-resolver'
|
|
@@ -421,6 +419,24 @@ function isRelationField(field: FieldDef): boolean {
|
|
|
421
419
|
)
|
|
422
420
|
}
|
|
423
421
|
|
|
422
|
+
// looksLikeForeignKey — true only for real FKs. A bare `*_id` suffix is NOT
|
|
423
|
+
// enough: columns like `external_id`, `trace_id`, or `invoice_uid` are plain
|
|
424
|
+
// text identifiers from a PAC/provider, not belongs_to relations. Treating them
|
|
425
|
+
// as relations rendered an InitialsAvatar ("6" chip next to "6a8c…") and made
|
|
426
|
+
// fiscal detail modals look broken.
|
|
427
|
+
function looksLikeForeignKey(field: FieldDef): boolean {
|
|
428
|
+
if (isRelationField(field)) return true
|
|
429
|
+
if (typeof field.key !== 'string' || !field.key.endsWith('_id')) return false
|
|
430
|
+
const t = String(field.type || '').toLowerCase()
|
|
431
|
+
return (
|
|
432
|
+
t === 'uuid' ||
|
|
433
|
+
t === 'search' ||
|
|
434
|
+
t === 'relation' ||
|
|
435
|
+
t === 'dynamic_select' ||
|
|
436
|
+
t === 'belongs_to'
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
|
|
424
440
|
function formatDisplayValue(rawValue: any, field: FieldDef): string {
|
|
425
441
|
// Unset nullable FK serialized as the nil UUID renders as empty, not zeros.
|
|
426
442
|
const value = normalizeNilUuid(rawValue)
|
|
@@ -544,12 +560,6 @@ export function stripHiddenFieldValues(
|
|
|
544
560
|
return out
|
|
545
561
|
}
|
|
546
562
|
|
|
547
|
-
function toastValidationFailed(t: Translate, lang: string, localized: Record<string, string>) {
|
|
548
|
-
toast.error(t('validation.failed', { defaultValue: validationCatalog(lang).failed }), {
|
|
549
|
-
description: Object.values(localized).filter(Boolean).join('\n'),
|
|
550
|
-
})
|
|
551
|
-
}
|
|
552
|
-
|
|
553
563
|
export function DynamicRecordDialog({
|
|
554
564
|
open,
|
|
555
565
|
onOpenChange,
|
|
@@ -573,12 +583,7 @@ export function DynamicRecordDialog({
|
|
|
573
583
|
onChange,
|
|
574
584
|
}: DynamicRecordDialogProps) {
|
|
575
585
|
const api = useApi()
|
|
576
|
-
const { t
|
|
577
|
-
// Unique per dialog instance. The footer submit lives OUTSIDE <form>, so
|
|
578
|
-
// it binds via `form={id}`. A hardcoded id made nested create (product +
|
|
579
|
-
// "Crear categoría") submit the PARENT form — toast "Revisa los campos
|
|
580
|
-
// marcados" with no marks on the inner modal.
|
|
581
|
-
const formId = useId()
|
|
586
|
+
const { t } = useTranslation()
|
|
582
587
|
const [modalMeta, setModalMeta] = useState<ModalMetadata | null>(
|
|
583
588
|
schema ? (schema as ModalMetadata) : null,
|
|
584
589
|
)
|
|
@@ -779,26 +784,25 @@ export function DynamicRecordDialog({
|
|
|
779
784
|
// with no matching form field).
|
|
780
785
|
const labelForKey = (key: string): string => {
|
|
781
786
|
const f = (modalMeta?.fields ?? []).find(x => x.key === key)
|
|
782
|
-
if (f?.label) return
|
|
787
|
+
if (f?.label) return f.label
|
|
783
788
|
return key.replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
|
784
789
|
}
|
|
785
790
|
|
|
786
|
-
const lang = i18n.language
|
|
787
|
-
|
|
788
791
|
// Turn a failed submit (422 `errors` map, or a bare `{errors}` body) into
|
|
789
792
|
// inline field errors + a summary toast. When there is no field map, fall
|
|
790
793
|
// back to the existing single cause-carrying toast.
|
|
791
794
|
const handleSubmitError = (err: unknown) => {
|
|
792
795
|
const map = extractFieldErrors(err)
|
|
793
796
|
if (map) {
|
|
794
|
-
const
|
|
795
|
-
for (const
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
797
|
+
const next: Record<string, string> = {}
|
|
798
|
+
for (const [key, issues] of Object.entries(map)) {
|
|
799
|
+
next[key] = localizeFieldIssue(issues[0], labelForKey(key), t)
|
|
800
|
+
}
|
|
801
|
+
setFieldErrors(next)
|
|
802
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
|
|
799
803
|
return
|
|
800
804
|
}
|
|
801
|
-
toastServerError(err, { t,
|
|
805
|
+
toastServerError(err, { t, fallback: t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }) })
|
|
802
806
|
}
|
|
803
807
|
|
|
804
808
|
const handleSubmit = async (e?: React.FormEvent) => {
|
|
@@ -811,14 +815,15 @@ export function DynamicRecordDialog({
|
|
|
811
815
|
// fields are gated: a field hidden by its `visible_when` predicate
|
|
812
816
|
// must not block submit even when it is declared required (matching
|
|
813
817
|
// the render, which drops it via the same filter).
|
|
814
|
-
const
|
|
815
|
-
const
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
818
|
+
const missing: Record<string, string> = {}
|
|
819
|
+
for (const field of filterVisibleFields(modalMeta.fields, mode, formValues)) {
|
|
820
|
+
if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
|
|
821
|
+
missing[field.key] = localizeFieldIssue({ code: 'required' }, field.label, t)
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
if (Object.keys(missing).length) {
|
|
825
|
+
setFieldErrors(missing)
|
|
826
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
|
|
822
827
|
return
|
|
823
828
|
}
|
|
824
829
|
}
|
|
@@ -960,13 +965,15 @@ export function DynamicRecordDialog({
|
|
|
960
965
|
// then advance. Mirrors handleSubmit's required check but scoped to the step.
|
|
961
966
|
const goNextStep = () => {
|
|
962
967
|
const step = groups[clampedStep]
|
|
963
|
-
const
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
968
|
+
const missing: Record<string, string> = {}
|
|
969
|
+
for (const field of step?.fields ?? []) {
|
|
970
|
+
if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
|
|
971
|
+
missing[field.key] = localizeFieldIssue({ code: 'required' }, field.label, t)
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
if (Object.keys(missing).length) {
|
|
975
|
+
setFieldErrors(missing)
|
|
976
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
|
|
970
977
|
return
|
|
971
978
|
}
|
|
972
979
|
setFieldErrors({})
|
|
@@ -999,7 +1006,7 @@ export function DynamicRecordDialog({
|
|
|
999
1006
|
cell `min-w-0` so a long select/input value can't
|
|
1000
1007
|
blow the two columns past the dialog width. */}
|
|
1001
1008
|
<form
|
|
1002
|
-
id=
|
|
1009
|
+
id="dynamic-record-form"
|
|
1003
1010
|
onSubmit={handleSubmit}
|
|
1004
1011
|
className="grid gap-y-4"
|
|
1005
1012
|
>
|
|
@@ -1109,7 +1116,7 @@ export function DynamicRecordDialog({
|
|
|
1109
1116
|
{isEditable && (!isSteps || isLastStep) && (
|
|
1110
1117
|
<Button
|
|
1111
1118
|
type="submit"
|
|
1112
|
-
form=
|
|
1119
|
+
form="dynamic-record-form"
|
|
1113
1120
|
disabled={saving || loading}
|
|
1114
1121
|
>
|
|
1115
1122
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
@@ -1213,9 +1220,7 @@ export function ReadonlyEditField({ field, value }: { field: FieldDef; value: an
|
|
|
1213
1220
|
// ReadonlyRelationField — a locked/readonly FK field (customer_id, category_id…)
|
|
1214
1221
|
// resolves the record's label instead of showing the raw id, mirroring
|
|
1215
1222
|
// RelationViewValue's lookup but rendered as a disabled input to match the rest
|
|
1216
|
-
// of ReadonlyEditField.
|
|
1217
|
-
// seeding a POS-selected customer into a vehicle create modal) would show a bare
|
|
1218
|
-
// UUID — the exact readability bug this dialog otherwise avoids elsewhere.
|
|
1223
|
+
// of ReadonlyEditField.
|
|
1219
1224
|
function ReadonlyRelationField({
|
|
1220
1225
|
field,
|
|
1221
1226
|
value,
|
|
@@ -1244,7 +1249,19 @@ function ReadonlyRelationField({
|
|
|
1244
1249
|
// RelationViewValue — read-only FK lead. Resolves the relation's label + image
|
|
1245
1250
|
// from (1) the sibling object the table served, then (2) the canonical options
|
|
1246
1251
|
// endpoint, and renders an OptionLead (thumbnail / icon / color dot) + label.
|
|
1247
|
-
|
|
1252
|
+
// When `stack` is true (`display: "image_stack"`), the landscape mark sits ON
|
|
1253
|
+
// TOP of the label — wide logos (brand marks) fit without cropping.
|
|
1254
|
+
function RelationViewValue({
|
|
1255
|
+
field,
|
|
1256
|
+
value,
|
|
1257
|
+
record,
|
|
1258
|
+
stack = false,
|
|
1259
|
+
}: {
|
|
1260
|
+
field: FieldDef
|
|
1261
|
+
value: any
|
|
1262
|
+
record: any
|
|
1263
|
+
stack?: boolean
|
|
1264
|
+
}) {
|
|
1248
1265
|
const getImageUrl = useContext(ImageUrlContext)
|
|
1249
1266
|
const sib = relationSiblingValue(field, record)
|
|
1250
1267
|
const sibLabel = typeof sib === 'string' ? sib : objectLabel(sib)
|
|
@@ -1382,18 +1399,16 @@ export function ViewValue({
|
|
|
1382
1399
|
|
|
1383
1400
|
const value = normalizeNilUuid(rawValue)
|
|
1384
1401
|
|
|
1385
|
-
// Landscape stack on a relation FK (brand marks, product cards)
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
) {
|
|
1402
|
+
// Landscape stack on a relation FK (brand marks, product cards): image ON
|
|
1403
|
+
// TOP, label UNDERNEATH. Checked before the default relation lead so a
|
|
1404
|
+
// `display: "image_stack"` FK does not fall through to the side-by-side chip.
|
|
1405
|
+
if (renderAs === 'image_stack' && looksLikeForeignKey(field)) {
|
|
1390
1406
|
return <RelationViewValue field={field} value={value} record={record} stack />
|
|
1391
1407
|
}
|
|
1392
1408
|
|
|
1393
|
-
// Relation (search / dynamic_select / ref /
|
|
1394
|
-
// label.
|
|
1395
|
-
|
|
1396
|
-
if (isRelationField(field) || (typeof field.key === 'string' && field.key.endsWith('_id'))) {
|
|
1409
|
+
// Relation (search / dynamic_select / ref / uuid *_id FK) → resolved
|
|
1410
|
+
// thumbnail + label. Plain text `*_id` columns (external_id, …) stay text.
|
|
1411
|
+
if (looksLikeForeignKey(field)) {
|
|
1397
1412
|
return <RelationViewValue field={field} value={value} record={record} />
|
|
1398
1413
|
}
|
|
1399
1414
|
|
|
@@ -1426,27 +1441,36 @@ export function ViewValue({
|
|
|
1426
1441
|
)
|
|
1427
1442
|
}
|
|
1428
1443
|
|
|
1429
|
-
// Landscape stack for image/logo URL columns
|
|
1444
|
+
// Landscape stack for image/logo URL columns (and `type: image` with
|
|
1445
|
+
// `cellStyle: image_stack`). Wide marks sit above an optional caption.
|
|
1430
1446
|
if (renderAs === 'image_stack') {
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1447
|
+
if (value && isLucideIconName(value)) {
|
|
1448
|
+
return <IconNameViewValue name={value} />
|
|
1449
|
+
}
|
|
1450
|
+
const labelField =
|
|
1451
|
+
(field.styleConfig &&
|
|
1452
|
+
(field.styleConfig.label_field as string | undefined)) ||
|
|
1453
|
+
(field.styleConfig && (field.styleConfig.labelField as string | undefined))
|
|
1454
|
+
let caption: string | undefined
|
|
1455
|
+
if (labelField && record && typeof record === 'object') {
|
|
1456
|
+
const raw = (record as Record<string, unknown>)[labelField]
|
|
1457
|
+
if (raw != null && String(raw) !== '') caption = String(raw)
|
|
1458
|
+
}
|
|
1459
|
+
return value || caption ? (
|
|
1438
1460
|
<div className="py-1">
|
|
1439
1461
|
<ImageStack
|
|
1440
1462
|
src={value ? String(value) : undefined}
|
|
1441
|
-
label={caption
|
|
1463
|
+
label={caption}
|
|
1442
1464
|
getImageUrl={getImageUrl}
|
|
1443
1465
|
size="lg"
|
|
1444
1466
|
/>
|
|
1445
1467
|
</div>
|
|
1468
|
+
) : (
|
|
1469
|
+
<p className="text-sm py-1 text-muted-foreground">Sin imagen</p>
|
|
1446
1470
|
)
|
|
1447
1471
|
}
|
|
1448
1472
|
|
|
1449
|
-
if (field.type === 'image') {
|
|
1473
|
+
if (field.type === 'image' || renderAs === 'image') {
|
|
1450
1474
|
if (isLucideIconName(value)) {
|
|
1451
1475
|
return <IconNameViewValue name={value} />
|
|
1452
1476
|
}
|
|
@@ -1740,6 +1764,19 @@ function StructuredViewValue({
|
|
|
1740
1764
|
if (isEmpty) {
|
|
1741
1765
|
return <p className="text-sm py-1 text-muted-foreground">—</p>
|
|
1742
1766
|
}
|
|
1767
|
+
// Line-items arrays with a declared itemFields schema → mini-table.
|
|
1768
|
+
// Plain objects (PAC provider_data, fiscal_data bags) → readable key/value
|
|
1769
|
+
// list; nested objects/arrays render as pretty JSON instead of `key: {…}`
|
|
1770
|
+
// stubs that looked broken in fiscal detail modals.
|
|
1771
|
+
const hasItemFields = !!(field?.itemFields ?? field?.item_fields)
|
|
1772
|
+
if (
|
|
1773
|
+
!hasItemFields &&
|
|
1774
|
+
value !== null &&
|
|
1775
|
+
typeof value === 'object' &&
|
|
1776
|
+
!Array.isArray(value)
|
|
1777
|
+
) {
|
|
1778
|
+
return <JsonObjectViewValue value={value as Record<string, unknown>} />
|
|
1779
|
+
}
|
|
1743
1780
|
return (
|
|
1744
1781
|
<div className="text-sm py-1">
|
|
1745
1782
|
<CollectionCell
|
|
@@ -1754,6 +1791,43 @@ function StructuredViewValue({
|
|
|
1754
1791
|
)
|
|
1755
1792
|
}
|
|
1756
1793
|
|
|
1794
|
+
/** Flatten a jsonb/object bag into labeled rows; nest as pretty JSON. */
|
|
1795
|
+
function JsonObjectViewValue({ value }: { value: Record<string, unknown> }) {
|
|
1796
|
+
const entries = Object.entries(value).filter(([, v]) => v !== undefined)
|
|
1797
|
+
if (entries.length === 0) {
|
|
1798
|
+
return <p className="text-sm py-1 text-muted-foreground">—</p>
|
|
1799
|
+
}
|
|
1800
|
+
return (
|
|
1801
|
+
<dl className="grid gap-2 py-1 text-sm">
|
|
1802
|
+
{entries.map(([key, raw]) => {
|
|
1803
|
+
const label = humanizeToken(key)
|
|
1804
|
+
const isNest =
|
|
1805
|
+
raw !== null &&
|
|
1806
|
+
typeof raw === 'object' &&
|
|
1807
|
+
!(raw instanceof Date)
|
|
1808
|
+
return (
|
|
1809
|
+
<div key={key} className="min-w-0">
|
|
1810
|
+
<dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
1811
|
+
{label}
|
|
1812
|
+
</dt>
|
|
1813
|
+
<dd className="mt-0.5 break-words text-foreground">
|
|
1814
|
+
{isNest ? (
|
|
1815
|
+
<pre className="max-h-48 overflow-auto rounded-md bg-muted/40 p-2 text-[11px] leading-relaxed whitespace-pre-wrap">
|
|
1816
|
+
{JSON.stringify(raw, null, 2)}
|
|
1817
|
+
</pre>
|
|
1818
|
+
) : raw === null || raw === '' ? (
|
|
1819
|
+
<span className="text-muted-foreground">—</span>
|
|
1820
|
+
) : (
|
|
1821
|
+
String(raw)
|
|
1822
|
+
)}
|
|
1823
|
+
</dd>
|
|
1824
|
+
</div>
|
|
1825
|
+
)
|
|
1826
|
+
})}
|
|
1827
|
+
</dl>
|
|
1828
|
+
)
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1757
1831
|
export function EditField({ field, value, onChange, record }: {
|
|
1758
1832
|
field: FieldDef
|
|
1759
1833
|
value: any
|