@asteby/metacore-runtime-react 29.2.4 → 29.2.6
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 +26 -0
- package/dist/barcode-scanner.d.ts +2 -0
- package/dist/barcode-scanner.d.ts.map +1 -1
- package/dist/barcode-scanner.js +58 -2
- package/dist/dashboard-empty-mockup.d.ts +5 -8
- package/dist/dashboard-empty-mockup.d.ts.map +1 -1
- package/dist/dashboard-empty-mockup.js +11 -20
- package/dist/dashboard-grid.d.ts.map +1 -1
- package/dist/dashboard-grid.js +4 -15
- package/dist/dialogs/dynamic-record.js +5 -3
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +8 -5
- package/package.json +1 -1
- package/src/__tests__/barcode-scanner.test.tsx +19 -1
- package/src/__tests__/dashboard-grid.test.tsx +4 -4
- package/src/barcode-scanner.tsx +92 -12
- package/src/dashboard-empty-mockup.tsx +18 -42
- package/src/dashboard-grid.tsx +5 -16
- package/src/dialogs/dynamic-record.tsx +8 -5
- package/src/dynamic-select-field.tsx +8 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 29.2.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b1a9703: feat(barcode-scanner): botón de linterna (torch) cuando el dispositivo lo soporta
|
|
8
|
+
|
|
9
|
+
En ambientes oscuros se puede encender/apagar el flash de la cámara trasera
|
|
10
|
+
desde la barra del overlay. Si el hardware no expone `torch`, el botón no
|
|
11
|
+
aparece. Aplica a POS y formularios (mismo primitivo).
|
|
12
|
+
|
|
13
|
+
## 29.2.5
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 428f5b0: fix(dashboard): skeleton vacío = 3 cards estáticas a ancho completo
|
|
18
|
+
|
|
19
|
+
Sin animación de reflow. El stack ya no usa `mc-demock-tile` (absolute), así
|
|
20
|
+
que no se colapsa en una franja chica en móvil / con sidebar.
|
|
21
|
+
|
|
22
|
+
- 48cf4e4: El botón de escaneo también aparece siempre (sin `isCameraScanSupported()`) en
|
|
23
|
+
los campos `dynamic_select` (`dynamic-select-field`) y en el modal de
|
|
24
|
+
crear/editar registro (`dialogs/dynamic-record`), no sólo en el input de texto.
|
|
25
|
+
El renglón de una orden de compra usa `dynamic_select`, así que ahí el icono de
|
|
26
|
+
cámara no aparecía en escritorio pese a declarar `scan`. Ahora los tres
|
|
27
|
+
renderers son consistentes con el POS.
|
|
28
|
+
|
|
3
29
|
## 29.2.4
|
|
4
30
|
|
|
5
31
|
### Patch Changes
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
+
/** `true` si el track de video expone linterna (ImageCapture / torch constraint). */
|
|
3
|
+
export declare function trackSupportsTorch(track: MediaStreamTrack | null | undefined): boolean;
|
|
2
4
|
/** Simbolismos de retail que intentamos detectar. */
|
|
3
5
|
export declare const RETAIL_BARCODE_FORMATS: string[];
|
|
4
6
|
/** `true` si este navegador puede escanear con la cámara (detector + getUserMedia). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"barcode-scanner.d.ts","sourceRoot":"","sources":["../src/barcode-scanner.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"barcode-scanner.d.ts","sourceRoot":"","sources":["../src/barcode-scanner.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAQ9B,qFAAqF;AACrF,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,gBAAgB,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAOtF;AAoDD,qDAAqD;AACrD,eAAO,MAAM,sBAAsB,UASlC,CAAA;AAQD,uFAAuF;AACvF,wBAAgB,qBAAqB,IAAI,OAAO,CAM/C;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,eAyB1B;AAED,MAAM,WAAW,mBAAmB;IAChC,IAAI,EAAE,OAAO,CAAA;IACb,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,8EAA8E;IAC9E,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,uDAAuD;IACvD,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,wCAAwC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,qEAAqE;IACrE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb;;;;OAIG;IACH,QAAQ,CAAC,EAAE,UAAU,GAAG,OAAO,CAAA;CAClC;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,EAC3B,IAAI,EACJ,OAAO,EACP,UAAU,EACV,UAAiB,EACjB,UAAiB,EACjB,KAAyB,EACzB,IAAmC,EACnC,QAAqB,GACxB,EAAE,mBAAmB,4BAgMrB;AAED,eAAe,cAAc,CAAA"}
|
package/dist/barcode-scanner.js
CHANGED
|
@@ -1,7 +1,36 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import * as React from 'react';
|
|
3
|
-
import { Camera, ScanLine, X } from 'lucide-react';
|
|
3
|
+
import { Camera, Flashlight, FlashlightOff, ScanLine, X } from 'lucide-react';
|
|
4
4
|
import { Button } from '@asteby/metacore-ui';
|
|
5
|
+
/** `true` si el track de video expone linterna (ImageCapture / torch constraint). */
|
|
6
|
+
export function trackSupportsTorch(track) {
|
|
7
|
+
if (!track || typeof track.getCapabilities !== 'function')
|
|
8
|
+
return false;
|
|
9
|
+
try {
|
|
10
|
+
return !!track.getCapabilities().torch;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
async function setTrackTorch(track, on) {
|
|
17
|
+
try {
|
|
18
|
+
await track.applyConstraints({
|
|
19
|
+
advanced: [{ torch: on }],
|
|
20
|
+
});
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Algunos WebViews fallan con advanced[] — reintento plano.
|
|
25
|
+
try {
|
|
26
|
+
await track.applyConstraints({ torch: on });
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
5
34
|
/** Simbolismos de retail que intentamos detectar. */
|
|
6
35
|
export const RETAIL_BARCODE_FORMATS = [
|
|
7
36
|
'ean_13',
|
|
@@ -71,19 +100,38 @@ export function BarcodeScanner({ open, onClose, onDetected, continuous = true, b
|
|
|
71
100
|
const lastHitRef = React.useRef({ code: '', at: 0 });
|
|
72
101
|
const [error, setError] = React.useState(null);
|
|
73
102
|
const [ready, setReady] = React.useState(false);
|
|
103
|
+
const [torchAvailable, setTorchAvailable] = React.useState(false);
|
|
104
|
+
const [torchOn, setTorchOn] = React.useState(false);
|
|
74
105
|
const beep = useScanBeep();
|
|
75
106
|
const stop = React.useCallback(() => {
|
|
76
107
|
if (rafRef.current != null)
|
|
77
108
|
cancelAnimationFrame(rafRef.current);
|
|
78
109
|
rafRef.current = null;
|
|
110
|
+
const track = streamRef.current?.getVideoTracks()[0];
|
|
111
|
+
if (track && trackSupportsTorch(track)) {
|
|
112
|
+
void setTrackTorch(track, false);
|
|
113
|
+
}
|
|
79
114
|
streamRef.current?.getTracks().forEach((t) => t.stop());
|
|
80
115
|
streamRef.current = null;
|
|
81
116
|
setReady(false);
|
|
117
|
+
setTorchAvailable(false);
|
|
118
|
+
setTorchOn(false);
|
|
82
119
|
}, []);
|
|
120
|
+
const toggleTorch = React.useCallback(async () => {
|
|
121
|
+
const track = streamRef.current?.getVideoTracks()[0];
|
|
122
|
+
if (!track || !trackSupportsTorch(track))
|
|
123
|
+
return;
|
|
124
|
+
const next = !torchOn;
|
|
125
|
+
const ok = await setTrackTorch(track, next);
|
|
126
|
+
if (ok)
|
|
127
|
+
setTorchOn(next);
|
|
128
|
+
}, [torchOn]);
|
|
83
129
|
React.useEffect(() => {
|
|
84
130
|
if (!open)
|
|
85
131
|
return;
|
|
86
132
|
setError(null);
|
|
133
|
+
setTorchAvailable(false);
|
|
134
|
+
setTorchOn(false);
|
|
87
135
|
const Ctor = getDetectorCtor();
|
|
88
136
|
if (!navigator.mediaDevices?.getUserMedia || !Ctor) {
|
|
89
137
|
setError('Tu navegador no soporta escaneo por cámara. Usá un lector físico (escribe en el campo) o escribí el código a mano.');
|
|
@@ -132,6 +180,8 @@ export function BarcodeScanner({ open, onClose, onDetected, continuous = true, b
|
|
|
132
180
|
return;
|
|
133
181
|
}
|
|
134
182
|
streamRef.current = stream;
|
|
183
|
+
const videoTrack = stream.getVideoTracks()[0];
|
|
184
|
+
setTorchAvailable(trackSupportsTorch(videoTrack));
|
|
135
185
|
const video = videoRef.current;
|
|
136
186
|
if (video) {
|
|
137
187
|
video.srcObject = stream;
|
|
@@ -155,6 +205,12 @@ export function BarcodeScanner({ open, onClose, onDetected, continuous = true, b
|
|
|
155
205
|
if (!open)
|
|
156
206
|
return null;
|
|
157
207
|
const posClass = position === 'fixed' ? 'fixed' : 'absolute';
|
|
158
|
-
return (_jsxs("div", { className: `${posClass} inset-0 z-[100] flex flex-col bg-black`, children: [_jsxs("div", { className: "flex items-center justify-between px-4 py-3 text-white", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx(ScanLine, { className: "size-5" }), title] }),
|
|
208
|
+
return (_jsxs("div", { className: `${posClass} inset-0 z-[100] flex flex-col bg-black`, children: [_jsxs("div", { className: "flex items-center justify-between px-4 py-3 text-white", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx(ScanLine, { className: "size-5" }), title] }), _jsxs("div", { className: "flex items-center gap-1", children: [torchAvailable && (_jsx(Button, { type: "button", variant: "ghost", size: "icon", className: torchOn
|
|
209
|
+
? 'bg-amber-400/20 text-amber-300 hover:bg-amber-400/30 hover:text-amber-200'
|
|
210
|
+
: 'text-white hover:bg-white/10 hover:text-white', onClick: () => void toggleTorch(), "aria-label": torchOn ? 'Apagar linterna' : 'Encender linterna', "aria-pressed": torchOn, title: torchOn ? 'Apagar linterna' : 'Encender linterna', children: torchOn ? (_jsx(Flashlight, { className: "size-5" })) : (_jsx(FlashlightOff, { className: "size-5" })) })), _jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "text-white hover:bg-white/10 hover:text-white", onClick: onClose, "aria-label": "Cerrar esc\u00E1ner", children: _jsx(X, { className: "size-5" }) })] })] }), _jsx("div", { className: "relative flex flex-1 items-center justify-center overflow-hidden", children: error ? (_jsxs("div", { className: "flex max-w-sm flex-col items-center gap-3 px-6 text-center text-white", children: [_jsx(Camera, { className: "size-10 opacity-70" }), _jsx("p", { className: "text-sm leading-relaxed", children: error }), _jsx(Button, { variant: "secondary", onClick: onClose, children: "Entendido" })] })) : (_jsxs(_Fragment, { children: [_jsx("video", { ref: videoRef, className: "h-full w-full object-cover", muted: true, playsInline: true }), _jsx("div", { className: "pointer-events-none absolute inset-0 flex items-center justify-center", children: _jsx("div", { className: "relative h-40 w-72 max-w-[80%] rounded-xl border-2 border-white/80 shadow-[0_0_0_9999px_rgba(0,0,0,0.35)]", children: _jsx("span", { className: "bg-primary absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 animate-pulse" }) }) }), _jsx("p", { className: "absolute bottom-8 left-0 right-0 px-4 text-center text-sm text-white/90", children: !ready
|
|
211
|
+
? 'Abriendo cámara…'
|
|
212
|
+
: torchAvailable
|
|
213
|
+
? `${hint} · tocá la linterna si está oscuro`
|
|
214
|
+
: hint })] })) })] }));
|
|
159
215
|
}
|
|
160
216
|
export default BarcodeScanner;
|
|
@@ -24,15 +24,12 @@ export declare function computeLayoutRects(layout: LayoutSpec, gap?: number): Mo
|
|
|
24
24
|
/** The precomputed rects for all four layouts (exported for the overlap test). */
|
|
25
25
|
export declare function mockupLayoutRects(gap?: number): MockupRect[][];
|
|
26
26
|
/**
|
|
27
|
-
* Full-bleed skeleton dashboard
|
|
28
|
-
*
|
|
29
|
-
* decorative — always `aria-hidden`, no text.
|
|
27
|
+
* Full-bleed skeleton dashboard: 3 static cards stacked full-width.
|
|
28
|
+
* No reflow animation — size and layout stability matter more than motion.
|
|
29
|
+
* Purely decorative — always `aria-hidden`, no text.
|
|
30
30
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* A parent with only `min-height` leaves the box auto-height and the tiles
|
|
34
|
-
* collapse into a strip. The dashboard empty state mounts it inside a
|
|
35
|
-
* definite-height box for exactly this reason.
|
|
31
|
+
* The layout-math helpers above (`computeLayoutRects`, etc.) stay exported for
|
|
32
|
+
* the overlap unit test / future reuse; the visible UI is the stack only.
|
|
36
33
|
*/
|
|
37
34
|
export declare function DashboardEmptyMockup({ className }: {
|
|
38
35
|
className?: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dashboard-empty-mockup.d.ts","sourceRoot":"","sources":["../src/dashboard-empty-mockup.tsx"],"names":[],"mappings":"AA8BA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAK9B,wEAAwE;AACxE,MAAM,WAAW,UAAU;IACvB,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;CACZ;AAKD,UAAU,UAAU;IAChB,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;CACnC;AAID,eAAO,MAAM,cAAc,EAAE,UAAU,EAKtC,CAAA;AAED,yDAAyD;AACzD,eAAO,MAAM,UAAU,IAAI,CAAA;AAE3B;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,UAAU,EAAE,GAAG,SAAa,GAAG,UAAU,EAAE,CAerF;AAED,kFAAkF;AAClF,wBAAgB,iBAAiB,CAAC,GAAG,SAAa,GAAG,UAAU,EAAE,EAAE,CAElE;
|
|
1
|
+
{"version":3,"file":"dashboard-empty-mockup.d.ts","sourceRoot":"","sources":["../src/dashboard-empty-mockup.tsx"],"names":[],"mappings":"AA8BA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAK9B,wEAAwE;AACxE,MAAM,WAAW,UAAU;IACvB,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;CACZ;AAKD,UAAU,UAAU;IAChB,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;CACnC;AAID,eAAO,MAAM,cAAc,EAAE,UAAU,EAKtC,CAAA;AAED,yDAAyD;AACzD,eAAO,MAAM,UAAU,IAAI,CAAA;AAE3B;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,UAAU,EAAE,GAAG,SAAa,GAAG,UAAU,EAAE,CAerF;AAED,kFAAkF;AAClF,wBAAgB,iBAAiB,CAAC,GAAG,SAAa,GAAG,UAAU,EAAE,EAAE,CAElE;AA0HD;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,qBAuBzE"}
|
|
@@ -112,7 +112,9 @@ function useMockupStyles() {
|
|
|
112
112
|
document.head.appendChild(el);
|
|
113
113
|
}, []);
|
|
114
114
|
}
|
|
115
|
-
|
|
115
|
+
// Static stack cards — never `mc-demock-tile` (that class is position:absolute
|
|
116
|
+
// for the old animated grid and collapsed cards into a tiny top-left strip).
|
|
117
|
+
const stackTileBase = 'relative w-full rounded-xl border border-border/60 bg-card mc-demock-shimmer text-foreground';
|
|
116
118
|
// Header shared by every card kind — icon chip + title bar + subtitle bar,
|
|
117
119
|
// mirroring the real WidgetCard chrome (widgets/widget-card.tsx).
|
|
118
120
|
function SkeletonHeader() {
|
|
@@ -122,28 +124,17 @@ function SkeletonHeader() {
|
|
|
122
124
|
function TileGlyph({ kind }) {
|
|
123
125
|
return (_jsxs("div", { className: "flex h-full flex-col gap-3 overflow-hidden p-3", children: [_jsx(SkeletonHeader, {}), _jsxs("div", { className: "min-h-0 flex-1", children: [kind === 'stat' && (_jsxs("div", { className: "flex h-full flex-col justify-center gap-2", children: [_jsx("div", { className: "h-6 w-1/2 rounded-md bg-foreground/15" }), _jsx("div", { className: "h-4 w-14 rounded-full bg-foreground/10" })] })), kind === 'chart' && (_jsx("div", { className: "flex h-full items-end gap-1.5", children: [46, 72, 38, 84, 58, 92, 50].map((h, i) => (_jsx("div", { className: "w-full rounded-sm bg-foreground/10", style: { height: `${h}%` } }, i))) })), kind === 'list' && (_jsx("div", { className: "flex h-full flex-col justify-center gap-2.5", children: [28, 40, 34].map((w, i) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "size-5 shrink-0 rounded-full bg-foreground/10" }), _jsx("div", { className: "h-2 flex-1 rounded-full bg-foreground/10" }), _jsx("div", { className: "h-2 shrink-0 rounded-full bg-foreground/12", style: { width: w } })] }, i))) })), kind === 'bar' && (_jsxs("div", { className: "flex h-full flex-col justify-center gap-3", children: [_jsx("div", { className: "h-2.5 w-full rounded-full bg-foreground/10", children: _jsx("div", { className: "h-full w-3/5 rounded-full bg-foreground/20" }) }), _jsx("div", { className: "h-2.5 w-full rounded-full bg-foreground/10", children: _jsx("div", { className: "h-full w-2/5 rounded-full bg-foreground/20" }) })] }))] })] }));
|
|
124
126
|
}
|
|
125
|
-
// Kind per tile, in the [c0-top, c0-bottom, c1-top, c1-bottom, c2-top, c2-bottom]
|
|
126
|
-
// order used by computeLayoutRects.
|
|
127
|
-
const TILE_KINDS = ['stat', 'chart', 'chart', 'list', 'stat', 'bar'];
|
|
128
127
|
/**
|
|
129
|
-
* Full-bleed skeleton dashboard
|
|
130
|
-
*
|
|
131
|
-
* decorative — always `aria-hidden`, no text.
|
|
128
|
+
* Full-bleed skeleton dashboard: 3 static cards stacked full-width.
|
|
129
|
+
* No reflow animation — size and layout stability matter more than motion.
|
|
130
|
+
* Purely decorative — always `aria-hidden`, no text.
|
|
132
131
|
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* A parent with only `min-height` leaves the box auto-height and the tiles
|
|
136
|
-
* collapse into a strip. The dashboard empty state mounts it inside a
|
|
137
|
-
* definite-height box for exactly this reason.
|
|
132
|
+
* The layout-math helpers above (`computeLayoutRects`, etc.) stay exported for
|
|
133
|
+
* the overlap unit test / future reuse; the visible UI is the stack only.
|
|
138
134
|
*/
|
|
139
135
|
export function DashboardEmptyMockup({ className }) {
|
|
140
136
|
useMockupStyles();
|
|
141
|
-
|
|
142
|
-
return (_jsxs("div", { "aria-hidden": "true", "data-testid": "dashboard-empty-mockup", className: cn('pointer-events-none select-none', className), children: [_jsx("div", { className: "flex min-h-full flex-col gap-3 sm:hidden", children: MOBILE_TILE_KINDS.map((kind, i) => (_jsx("div", { className: cn(tileBase, 'mc-demock-shimmer relative min-h-[132px] flex-1'), children: _jsx(TileGlyph, { kind: kind }) }, i))) }), _jsx("div", { className: "mc-demock hidden h-full sm:block", children: layoutA.map((r, i) => (_jsx("div", { "data-mockup-tile": "tile", className: cn(tileBase, `mc-demock-t${i}`),
|
|
143
|
-
// Base position = layout A, so reduced-motion (animation off)
|
|
144
|
-
// shows the full static composition.
|
|
145
|
-
style: { left: `${r.x}%`, top: `${r.y}%`, width: `${r.w}%`, height: `${r.h}%` }, children: _jsx(TileGlyph, { kind: TILE_KINDS[i] }) }, i))) })] }));
|
|
137
|
+
return (_jsx("div", { "aria-hidden": "true", "data-testid": "dashboard-empty-mockup", className: cn('pointer-events-none w-full select-none', className), children: _jsx("div", { className: "flex w-full flex-col gap-4", "data-testid": "dashboard-empty-mockup-stack", children: STACK_TILE_KINDS.map((kind, i) => (_jsx("div", { className: cn(stackTileBase, 'min-h-[200px]'), children: _jsx(TileGlyph, { kind: kind }) }, i))) }) }));
|
|
146
138
|
}
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
const MOBILE_TILE_KINDS = ['stat', 'chart', 'list'];
|
|
139
|
+
// 3 cards a ancho completo — stat + chart + list. Sin animación de reflow.
|
|
140
|
+
const STACK_TILE_KINDS = ['stat', 'chart', 'list'];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dashboard-grid.d.ts","sourceRoot":"","sources":["../src/dashboard-grid.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAI9B,OAAO,KAAK,EACR,kBAAkB,EAElB,oBAAoB,EACpB,mBAAmB,EACtB,MAAM,mBAAmB,CAAA;AAY1B,uEAAuE;AACvE,wBAAgB,eAAe,CAC3B,MAAM,CAAC,EAAE,oBAAoB,EAAE,EAC/B,OAAO,CAAC,EAAE,mBAAmB,EAAE,GAChC,oBAAoB,EAAE,CAgBxB;AASD,wBAAgB,aAAa,CAAC,EAC1B,MAAM,EACN,OAAO,EACP,QAAQ,EACR,OAAO,EACP,MAAM,EACN,QAAQ,EACR,SAAS,EACT,OAAO,GACV,EAAE,kBAAkB,
|
|
1
|
+
{"version":3,"file":"dashboard-grid.d.ts","sourceRoot":"","sources":["../src/dashboard-grid.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAI9B,OAAO,KAAK,EACR,kBAAkB,EAElB,oBAAoB,EACpB,mBAAmB,EACtB,MAAM,mBAAmB,CAAA;AAY1B,uEAAuE;AACvE,wBAAgB,eAAe,CAC3B,MAAM,CAAC,EAAE,oBAAoB,EAAE,EAC/B,OAAO,CAAC,EAAE,mBAAmB,EAAE,GAChC,oBAAoB,EAAE,CAgBxB;AASD,wBAAgB,aAAa,CAAC,EAC1B,MAAM,EACN,OAAO,EACP,QAAQ,EACR,OAAO,EACP,MAAM,EACN,QAAQ,EACR,SAAS,EACT,OAAO,GACV,EAAE,kBAAkB,qBA4IpB"}
|
package/dist/dashboard-grid.js
CHANGED
|
@@ -121,21 +121,10 @@ export function DashboardGrid({ groups, widgets, loadData, isAdmin, locale, curr
|
|
|
121
121
|
// Global empty state (no widgets at all / none visible after gating).
|
|
122
122
|
if (visibleGroups.length === 0) {
|
|
123
123
|
return (_jsx("div", { "data-testid": "dashboard-empty", className: cn(
|
|
124
|
-
// Full-bleed
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
// DEFINITE height (clamp), not min-height: the mockup positions
|
|
129
|
-
// its tiles with percentage heights, which only resolve against a
|
|
130
|
-
// parent whose height is definite. A min-height alone leaves the
|
|
131
|
-
// box auto-height and the tiles collapse into a strip — so we own
|
|
132
|
-
// the height here instead of hoping the dashboard layout supplies
|
|
133
|
-
// one.
|
|
134
|
-
// No dashed border / padding wrapper: the skeleton tiles ARE
|
|
135
|
-
// cards (same anatomy as the real widgets), so the mockup
|
|
136
|
-
// should float exactly like the loaded dashboard, not sit
|
|
137
|
-
// inside a boxed placeholder. Keep only the definite height.
|
|
138
|
-
'h-[clamp(420px,60vh,720px)] w-full', className), children: _jsx(DashboardEmptyMockup, { className: "h-full" }) }));
|
|
124
|
+
// Full-bleed stack of static skeleton cards (no dashed box,
|
|
125
|
+
// no caption). Height follows the 3 tall cards — no absolute
|
|
126
|
+
// %-height grid that needed a clamp wrapper.
|
|
127
|
+
'w-full', className), children: _jsx(DashboardEmptyMockup, {}) }));
|
|
139
128
|
}
|
|
140
129
|
return (_jsx("div", { "data-testid": "dashboard-grid", className: cn(
|
|
141
130
|
// Masonry: balanced CSS columns. Cards take their natural height
|
|
@@ -18,7 +18,7 @@ import { toast } from 'sonner';
|
|
|
18
18
|
import { format, parseISO } from 'date-fns';
|
|
19
19
|
import { es } from 'date-fns/locale';
|
|
20
20
|
import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon, ScanLine } from 'lucide-react';
|
|
21
|
-
import { BarcodeScanner
|
|
21
|
+
import { BarcodeScanner } from '../barcode-scanner';
|
|
22
22
|
import { useApi } from '../api-context';
|
|
23
23
|
import { toastServerError, extractFieldErrors, localizeFieldIssue } from '../server-error';
|
|
24
24
|
import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field';
|
|
@@ -1026,9 +1026,11 @@ export function EditField({ field, value, onChange, record }) {
|
|
|
1026
1026
|
*/
|
|
1027
1027
|
function ScannableRecordInput({ field, value, onChange, inputType, }) {
|
|
1028
1028
|
const [scanOpen, setScanOpen] = useState(false);
|
|
1029
|
+
// El botón de escaneo aparece siempre que el campo declara `scan` (como el
|
|
1030
|
+
// POS), sin esconderse por `isCameraScanSupported()`. El BarcodeScanner ya
|
|
1031
|
+
// muestra un mensaje amable cuando el navegador no tiene cámara.
|
|
1029
1032
|
const scanEnabled = !!(field.scan ??
|
|
1030
|
-
field.scannable)
|
|
1031
|
-
isCameraScanSupported();
|
|
1033
|
+
field.scannable);
|
|
1032
1034
|
const input = (_jsx(Input, { type: inputType, value: value ?? '', onChange: (e) => onChange(inputType === 'number' ? (e.target.value === '' ? '' : Number(e.target.value)) : e.target.value), placeholder: field.placeholder }));
|
|
1033
1035
|
if (!scanEnabled)
|
|
1034
1036
|
return input;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-select-field.d.ts","sourceRoot":"","sources":["../src/dynamic-select-field.tsx"],"names":[],"mappings":"AAyCA,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAEhF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAE7C;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,gDAAgD,CAAA;AAEjF;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,EACxB,KAAK,EACL,IAAI,EACJ,IAAS,GACZ,EAAE;IACC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,+BA6BA;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,EACvB,MAAM,EACN,IAAS,GACZ,EAAE;IACC,MAAM,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC,GAAG,IAAI,CAAA;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,sCA4BA;AAWD,MAAM,WAAW,uBAAuB;IACpC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;IAC1B;;;;;OAKG;IACH,UAAU,CAAC,EAAE,cAAc,GAAG,IAAI,CAAA;IAClC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,wBAAgB,kBAAkB,CAAC,EAC/B,KAAK,EACL,KAAK,EACL,QAAQ,EACR,UAAU,EACV,YAAY,EACZ,WAAW,EACX,QAAgB,GACnB,EAAE,uBAAuB,+
|
|
1
|
+
{"version":3,"file":"dynamic-select-field.d.ts","sourceRoot":"","sources":["../src/dynamic-select-field.tsx"],"names":[],"mappings":"AAyCA,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAEhF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAE7C;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,gDAAgD,CAAA;AAEjF;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,EACxB,KAAK,EACL,IAAI,EACJ,IAAS,GACZ,EAAE;IACC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,+BA6BA;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,EACvB,MAAM,EACN,IAAS,GACZ,EAAE;IACC,MAAM,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC,GAAG,IAAI,CAAA;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,sCA4BA;AAWD,MAAM,WAAW,uBAAuB;IACpC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;IAC1B;;;;;OAKG;IACH,UAAU,CAAC,EAAE,cAAc,GAAG,IAAI,CAAA;IAClC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,wBAAgB,kBAAkB,CAAC,EAC/B,KAAK,EACL,KAAK,EACL,QAAQ,EACR,UAAU,EACV,YAAY,EACZ,WAAW,EACX,QAAgB,GACnB,EAAE,uBAAuB,+BAyQzB;AAED,eAAe,kBAAkB,CAAA"}
|
|
@@ -26,7 +26,7 @@ import { useEffect, useRef, useState } from 'react';
|
|
|
26
26
|
import { Button, Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, Popover, PopoverContent, PopoverTrigger, InitialsAvatar, } from '@asteby/metacore-ui/primitives';
|
|
27
27
|
import { Check, ChevronsUpDown, Loader2, Plus, ScanLine } from 'lucide-react';
|
|
28
28
|
import { resolveColorCss } from '@asteby/metacore-ui/lib';
|
|
29
|
-
import { BarcodeScanner
|
|
29
|
+
import { BarcodeScanner } from './barcode-scanner';
|
|
30
30
|
import { DynamicIcon, isLucideIconName } from './dynamic-icon';
|
|
31
31
|
import { useOptionsResolver } from './use-options-resolver';
|
|
32
32
|
import { getDependsOn, getFieldRef, resolveOptionsSource } from './dynamic-form-schema';
|
|
@@ -98,10 +98,13 @@ export function DynamicSelectField({ field, value, onChange, seedOption, depends
|
|
|
98
98
|
const [search, setSearch] = useState('');
|
|
99
99
|
const [scanOpen, setScanOpen] = useState(false);
|
|
100
100
|
const debounced = useDebounced(search, 250);
|
|
101
|
-
// Escaneo por cámara para "llenar rápido": opt-in por campo (`scan`)
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
101
|
+
// Escaneo por cámara para "llenar rápido": opt-in por campo (`scan`). El
|
|
102
|
+
// botón aparece SIEMPRE que el campo lo declara (igual que el POS), sin
|
|
103
|
+
// esconderse por `isCameraScanSupported()` — en escritorio el botón no salía
|
|
104
|
+
// aunque el campo lo pidiera. El BarcodeScanner ya degrada con un mensaje
|
|
105
|
+
// cuando no hay cámara. Un código escaneado alimenta la búsqueda y abre el
|
|
106
|
+
// picker para elegir la referencia sin tipear el UUID.
|
|
107
|
+
const scanEnabled = !!(field.scan ?? field.scannable);
|
|
105
108
|
const handleScanDetected = (code) => {
|
|
106
109
|
setSearch(code);
|
|
107
110
|
setOpen(true);
|
package/package.json
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { describe, it, expect, afterEach } from 'vitest'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
isCameraScanSupported,
|
|
5
|
+
RETAIL_BARCODE_FORMATS,
|
|
6
|
+
trackSupportsTorch,
|
|
7
|
+
} from '../barcode-scanner'
|
|
4
8
|
|
|
5
9
|
describe('barcode-scanner primitive', () => {
|
|
6
10
|
afterEach(() => {
|
|
@@ -25,4 +29,18 @@ describe('barcode-scanner primitive', () => {
|
|
|
25
29
|
const hasGUM = !!navigator.mediaDevices?.getUserMedia
|
|
26
30
|
expect(isCameraScanSupported()).toBe(hasGUM)
|
|
27
31
|
})
|
|
32
|
+
|
|
33
|
+
it('trackSupportsTorch lee capabilities.torch del track', () => {
|
|
34
|
+
expect(trackSupportsTorch(null)).toBe(false)
|
|
35
|
+
expect(
|
|
36
|
+
trackSupportsTorch({
|
|
37
|
+
getCapabilities: () => ({}),
|
|
38
|
+
} as MediaStreamTrack),
|
|
39
|
+
).toBe(false)
|
|
40
|
+
expect(
|
|
41
|
+
trackSupportsTorch({
|
|
42
|
+
getCapabilities: () => ({ torch: true }),
|
|
43
|
+
} as unknown as MediaStreamTrack),
|
|
44
|
+
).toBe(true)
|
|
45
|
+
})
|
|
28
46
|
})
|
|
@@ -156,16 +156,16 @@ describe('DashboardGrid render', () => {
|
|
|
156
156
|
expect(screen.getByTestId('dashboard-empty')).toBeTruthy()
|
|
157
157
|
})
|
|
158
158
|
|
|
159
|
-
it('renders the
|
|
159
|
+
it('renders the static full-width skeleton stack (decorative) in the empty state', () => {
|
|
160
160
|
render(<DashboardGrid widgets={[]} loadData={loaderOf({})} />)
|
|
161
161
|
const mock = screen.getByTestId('dashboard-empty-mockup')
|
|
162
162
|
expect(mock).toBeTruthy()
|
|
163
|
+
expect(screen.getByTestId('dashboard-empty-mockup-stack')).toBeTruthy()
|
|
163
164
|
// Purely decorative → hidden from the a11y tree.
|
|
164
165
|
expect(mock.getAttribute('aria-hidden')).toBe('true')
|
|
165
|
-
//
|
|
166
|
+
// Shimmer styles still inject once (no reflow keyframes required).
|
|
166
167
|
expect(document.getElementById('mc-dashboard-empty-mockup-style')).toBeTruthy()
|
|
167
|
-
// No caption text —
|
|
168
|
-
// shipping un-localized copy from the package).
|
|
168
|
+
// No caption text — avoids shipping un-localized copy from the package.
|
|
169
169
|
expect(screen.queryByText(/dashboard/i)).toBeNull()
|
|
170
170
|
})
|
|
171
171
|
|
package/src/barcode-scanner.tsx
CHANGED
|
@@ -1,7 +1,38 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
|
-
import { Camera, ScanLine, X } from 'lucide-react'
|
|
2
|
+
import { Camera, Flashlight, FlashlightOff, ScanLine, X } from 'lucide-react'
|
|
3
3
|
import { Button } from '@asteby/metacore-ui'
|
|
4
4
|
|
|
5
|
+
/** `torch` aún no está en todas las libs de TS — extendemos el contrato mínimo. */
|
|
6
|
+
type TorchCapabilities = MediaTrackCapabilities & { torch?: boolean }
|
|
7
|
+
type TorchConstraintSet = MediaTrackConstraintSet & { torch?: boolean }
|
|
8
|
+
|
|
9
|
+
/** `true` si el track de video expone linterna (ImageCapture / torch constraint). */
|
|
10
|
+
export function trackSupportsTorch(track: MediaStreamTrack | null | undefined): boolean {
|
|
11
|
+
if (!track || typeof track.getCapabilities !== 'function') return false
|
|
12
|
+
try {
|
|
13
|
+
return !!(track.getCapabilities() as TorchCapabilities).torch
|
|
14
|
+
} catch {
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function setTrackTorch(track: MediaStreamTrack, on: boolean): Promise<boolean> {
|
|
20
|
+
try {
|
|
21
|
+
await track.applyConstraints({
|
|
22
|
+
advanced: [{ torch: on } as TorchConstraintSet],
|
|
23
|
+
})
|
|
24
|
+
return true
|
|
25
|
+
} catch {
|
|
26
|
+
// Algunos WebViews fallan con advanced[] — reintento plano.
|
|
27
|
+
try {
|
|
28
|
+
await track.applyConstraints({ torch: on } as MediaTrackConstraints)
|
|
29
|
+
return true
|
|
30
|
+
} catch {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
5
36
|
/**
|
|
6
37
|
* BarcodeScanner — primitivo REUSABLE de escaneo por cámara para todo el
|
|
7
38
|
* ecosistema (POS, formularios declarativos, cualquier addon). Sin dependencias
|
|
@@ -141,19 +172,37 @@ export function BarcodeScanner({
|
|
|
141
172
|
const lastHitRef = React.useRef<{ code: string; at: number }>({ code: '', at: 0 })
|
|
142
173
|
const [error, setError] = React.useState<string | null>(null)
|
|
143
174
|
const [ready, setReady] = React.useState(false)
|
|
175
|
+
const [torchAvailable, setTorchAvailable] = React.useState(false)
|
|
176
|
+
const [torchOn, setTorchOn] = React.useState(false)
|
|
144
177
|
const beep = useScanBeep()
|
|
145
178
|
|
|
146
179
|
const stop = React.useCallback(() => {
|
|
147
180
|
if (rafRef.current != null) cancelAnimationFrame(rafRef.current)
|
|
148
181
|
rafRef.current = null
|
|
182
|
+
const track = streamRef.current?.getVideoTracks()[0]
|
|
183
|
+
if (track && trackSupportsTorch(track)) {
|
|
184
|
+
void setTrackTorch(track, false)
|
|
185
|
+
}
|
|
149
186
|
streamRef.current?.getTracks().forEach((t) => t.stop())
|
|
150
187
|
streamRef.current = null
|
|
151
188
|
setReady(false)
|
|
189
|
+
setTorchAvailable(false)
|
|
190
|
+
setTorchOn(false)
|
|
152
191
|
}, [])
|
|
153
192
|
|
|
193
|
+
const toggleTorch = React.useCallback(async () => {
|
|
194
|
+
const track = streamRef.current?.getVideoTracks()[0]
|
|
195
|
+
if (!track || !trackSupportsTorch(track)) return
|
|
196
|
+
const next = !torchOn
|
|
197
|
+
const ok = await setTrackTorch(track, next)
|
|
198
|
+
if (ok) setTorchOn(next)
|
|
199
|
+
}, [torchOn])
|
|
200
|
+
|
|
154
201
|
React.useEffect(() => {
|
|
155
202
|
if (!open) return
|
|
156
203
|
setError(null)
|
|
204
|
+
setTorchAvailable(false)
|
|
205
|
+
setTorchOn(false)
|
|
157
206
|
|
|
158
207
|
const Ctor = getDetectorCtor()
|
|
159
208
|
if (!navigator.mediaDevices?.getUserMedia || !Ctor) {
|
|
@@ -205,6 +254,8 @@ export function BarcodeScanner({
|
|
|
205
254
|
return
|
|
206
255
|
}
|
|
207
256
|
streamRef.current = stream
|
|
257
|
+
const videoTrack = stream.getVideoTracks()[0]
|
|
258
|
+
setTorchAvailable(trackSupportsTorch(videoTrack))
|
|
208
259
|
const video = videoRef.current
|
|
209
260
|
if (video) {
|
|
210
261
|
video.srcObject = stream
|
|
@@ -239,15 +290,40 @@ export function BarcodeScanner({
|
|
|
239
290
|
<ScanLine className="size-5" />
|
|
240
291
|
{title}
|
|
241
292
|
</div>
|
|
242
|
-
<
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
293
|
+
<div className="flex items-center gap-1">
|
|
294
|
+
{torchAvailable && (
|
|
295
|
+
<Button
|
|
296
|
+
type="button"
|
|
297
|
+
variant="ghost"
|
|
298
|
+
size="icon"
|
|
299
|
+
className={
|
|
300
|
+
torchOn
|
|
301
|
+
? 'bg-amber-400/20 text-amber-300 hover:bg-amber-400/30 hover:text-amber-200'
|
|
302
|
+
: 'text-white hover:bg-white/10 hover:text-white'
|
|
303
|
+
}
|
|
304
|
+
onClick={() => void toggleTorch()}
|
|
305
|
+
aria-label={torchOn ? 'Apagar linterna' : 'Encender linterna'}
|
|
306
|
+
aria-pressed={torchOn}
|
|
307
|
+
title={torchOn ? 'Apagar linterna' : 'Encender linterna'}
|
|
308
|
+
>
|
|
309
|
+
{torchOn ? (
|
|
310
|
+
<Flashlight className="size-5" />
|
|
311
|
+
) : (
|
|
312
|
+
<FlashlightOff className="size-5" />
|
|
313
|
+
)}
|
|
314
|
+
</Button>
|
|
315
|
+
)}
|
|
316
|
+
<Button
|
|
317
|
+
type="button"
|
|
318
|
+
variant="ghost"
|
|
319
|
+
size="icon"
|
|
320
|
+
className="text-white hover:bg-white/10 hover:text-white"
|
|
321
|
+
onClick={onClose}
|
|
322
|
+
aria-label="Cerrar escáner"
|
|
323
|
+
>
|
|
324
|
+
<X className="size-5" />
|
|
325
|
+
</Button>
|
|
326
|
+
</div>
|
|
251
327
|
</div>
|
|
252
328
|
|
|
253
329
|
{/* Área de cámara */}
|
|
@@ -269,8 +345,12 @@ export function BarcodeScanner({
|
|
|
269
345
|
<span className="bg-primary absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 animate-pulse" />
|
|
270
346
|
</div>
|
|
271
347
|
</div>
|
|
272
|
-
<p className="absolute bottom-8 left-0 right-0 text-center text-sm text-white/90">
|
|
273
|
-
{ready
|
|
348
|
+
<p className="absolute bottom-8 left-0 right-0 px-4 text-center text-sm text-white/90">
|
|
349
|
+
{!ready
|
|
350
|
+
? 'Abriendo cámara…'
|
|
351
|
+
: torchAvailable
|
|
352
|
+
? `${hint} · tocá la linterna si está oscuro`
|
|
353
|
+
: hint}
|
|
274
354
|
</p>
|
|
275
355
|
</>
|
|
276
356
|
)}
|
|
@@ -137,8 +137,10 @@ function useMockupStyles() {
|
|
|
137
137
|
}, [])
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
|
|
141
|
-
|
|
140
|
+
// Static stack cards — never `mc-demock-tile` (that class is position:absolute
|
|
141
|
+
// for the old animated grid and collapsed cards into a tiny top-left strip).
|
|
142
|
+
const stackTileBase =
|
|
143
|
+
'relative w-full rounded-xl border border-border/60 bg-card mc-demock-shimmer text-foreground'
|
|
142
144
|
|
|
143
145
|
type Glyph = 'chart' | 'stat' | 'list' | 'bar'
|
|
144
146
|
|
|
@@ -208,64 +210,38 @@ function TileGlyph({ kind }: { kind: Glyph }) {
|
|
|
208
210
|
)
|
|
209
211
|
}
|
|
210
212
|
|
|
211
|
-
// Kind per tile, in the [c0-top, c0-bottom, c1-top, c1-bottom, c2-top, c2-bottom]
|
|
212
|
-
// order used by computeLayoutRects.
|
|
213
|
-
const TILE_KINDS: Glyph[] = ['stat', 'chart', 'chart', 'list', 'stat', 'bar']
|
|
214
|
-
|
|
215
213
|
/**
|
|
216
|
-
* Full-bleed skeleton dashboard
|
|
217
|
-
*
|
|
218
|
-
* decorative — always `aria-hidden`, no text.
|
|
214
|
+
* Full-bleed skeleton dashboard: 3 static cards stacked full-width.
|
|
215
|
+
* No reflow animation — size and layout stability matter more than motion.
|
|
216
|
+
* Purely decorative — always `aria-hidden`, no text.
|
|
219
217
|
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
* A parent with only `min-height` leaves the box auto-height and the tiles
|
|
223
|
-
* collapse into a strip. The dashboard empty state mounts it inside a
|
|
224
|
-
* definite-height box for exactly this reason.
|
|
218
|
+
* The layout-math helpers above (`computeLayoutRects`, etc.) stay exported for
|
|
219
|
+
* the overlap unit test / future reuse; the visible UI is the stack only.
|
|
225
220
|
*/
|
|
226
221
|
export function DashboardEmptyMockup({ className }: { className?: string }) {
|
|
227
222
|
useMockupStyles()
|
|
228
|
-
const layoutA = React.useMemo(() => computeLayoutRects(MOCKUP_LAYOUTS[0]), [])
|
|
229
223
|
return (
|
|
230
224
|
<div
|
|
231
225
|
aria-hidden="true"
|
|
232
226
|
data-testid="dashboard-empty-mockup"
|
|
233
|
-
className={cn('pointer-events-none select-none', className)}
|
|
227
|
+
className={cn('pointer-events-none w-full select-none', className)}
|
|
234
228
|
>
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
{MOBILE_TILE_KINDS.map((kind, i) => (
|
|
229
|
+
<div
|
|
230
|
+
className="flex w-full flex-col gap-4"
|
|
231
|
+
data-testid="dashboard-empty-mockup-stack"
|
|
232
|
+
>
|
|
233
|
+
{STACK_TILE_KINDS.map((kind, i) => (
|
|
241
234
|
<div
|
|
242
235
|
key={i}
|
|
243
|
-
className={cn(
|
|
236
|
+
className={cn(stackTileBase, 'min-h-[200px]')}
|
|
244
237
|
>
|
|
245
238
|
<TileGlyph kind={kind} />
|
|
246
239
|
</div>
|
|
247
240
|
))}
|
|
248
241
|
</div>
|
|
249
|
-
|
|
250
|
-
{/* Tablet/escritorio: el mockup de 3 columnas que reorganiza en bucle. */}
|
|
251
|
-
<div className="mc-demock hidden h-full sm:block">
|
|
252
|
-
{layoutA.map((r, i) => (
|
|
253
|
-
<div
|
|
254
|
-
key={i}
|
|
255
|
-
data-mockup-tile="tile"
|
|
256
|
-
className={cn(tileBase, `mc-demock-t${i}`)}
|
|
257
|
-
// Base position = layout A, so reduced-motion (animation off)
|
|
258
|
-
// shows the full static composition.
|
|
259
|
-
style={{ left: `${r.x}%`, top: `${r.y}%`, width: `${r.w}%`, height: `${r.h}%` }}
|
|
260
|
-
>
|
|
261
|
-
<TileGlyph kind={TILE_KINDS[i]} />
|
|
262
|
-
</div>
|
|
263
|
-
))}
|
|
264
|
-
</div>
|
|
265
242
|
</div>
|
|
266
243
|
)
|
|
267
244
|
}
|
|
268
245
|
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
const MOBILE_TILE_KINDS: Glyph[] = ['stat', 'chart', 'list']
|
|
246
|
+
// 3 cards a ancho completo — stat + chart + list. Sin animación de reflow.
|
|
247
|
+
const STACK_TILE_KINDS: Glyph[] = ['stat', 'chart', 'list']
|
package/src/dashboard-grid.tsx
CHANGED
|
@@ -154,25 +154,14 @@ export function DashboardGrid({
|
|
|
154
154
|
<div
|
|
155
155
|
data-testid="dashboard-empty"
|
|
156
156
|
className={cn(
|
|
157
|
-
// Full-bleed
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
// DEFINITE height (clamp), not min-height: the mockup positions
|
|
162
|
-
// its tiles with percentage heights, which only resolve against a
|
|
163
|
-
// parent whose height is definite. A min-height alone leaves the
|
|
164
|
-
// box auto-height and the tiles collapse into a strip — so we own
|
|
165
|
-
// the height here instead of hoping the dashboard layout supplies
|
|
166
|
-
// one.
|
|
167
|
-
// No dashed border / padding wrapper: the skeleton tiles ARE
|
|
168
|
-
// cards (same anatomy as the real widgets), so the mockup
|
|
169
|
-
// should float exactly like the loaded dashboard, not sit
|
|
170
|
-
// inside a boxed placeholder. Keep only the definite height.
|
|
171
|
-
'h-[clamp(420px,60vh,720px)] w-full',
|
|
157
|
+
// Full-bleed stack of static skeleton cards (no dashed box,
|
|
158
|
+
// no caption). Height follows the 3 tall cards — no absolute
|
|
159
|
+
// %-height grid that needed a clamp wrapper.
|
|
160
|
+
'w-full',
|
|
172
161
|
className,
|
|
173
162
|
)}
|
|
174
163
|
>
|
|
175
|
-
<DashboardEmptyMockup
|
|
164
|
+
<DashboardEmptyMockup />
|
|
176
165
|
</div>
|
|
177
166
|
)
|
|
178
167
|
}
|
|
@@ -47,7 +47,7 @@ import { toast } from 'sonner'
|
|
|
47
47
|
import { format, parseISO } from 'date-fns'
|
|
48
48
|
import { es } from 'date-fns/locale'
|
|
49
49
|
import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon, ScanLine } from 'lucide-react'
|
|
50
|
-
import { BarcodeScanner
|
|
50
|
+
import { BarcodeScanner } from '../barcode-scanner'
|
|
51
51
|
import { useApi } from '../api-context'
|
|
52
52
|
import { toastServerError, extractFieldErrors, localizeFieldIssue } from '../server-error'
|
|
53
53
|
import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field'
|
|
@@ -1848,10 +1848,13 @@ function ScannableRecordInput({
|
|
|
1848
1848
|
inputType: string
|
|
1849
1849
|
}) {
|
|
1850
1850
|
const [scanOpen, setScanOpen] = useState(false)
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1851
|
+
// El botón de escaneo aparece siempre que el campo declara `scan` (como el
|
|
1852
|
+
// POS), sin esconderse por `isCameraScanSupported()`. El BarcodeScanner ya
|
|
1853
|
+
// muestra un mensaje amable cuando el navegador no tiene cámara.
|
|
1854
|
+
const scanEnabled = !!(
|
|
1855
|
+
(field as { scan?: boolean; scannable?: boolean }).scan ??
|
|
1856
|
+
(field as { scan?: boolean; scannable?: boolean }).scannable
|
|
1857
|
+
)
|
|
1855
1858
|
const input = (
|
|
1856
1859
|
<Input
|
|
1857
1860
|
type={inputType}
|
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
} from '@asteby/metacore-ui/primitives'
|
|
38
38
|
import { Check, ChevronsUpDown, Loader2, Plus, ScanLine } from 'lucide-react'
|
|
39
39
|
import { resolveColorCss } from '@asteby/metacore-ui/lib'
|
|
40
|
-
import { BarcodeScanner
|
|
40
|
+
import { BarcodeScanner } from './barcode-scanner'
|
|
41
41
|
import { DynamicIcon, isLucideIconName } from './dynamic-icon'
|
|
42
42
|
import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
|
|
43
43
|
import { getDependsOn, getFieldRef, resolveOptionsSource } from './dynamic-form-schema'
|
|
@@ -193,10 +193,13 @@ export function DynamicSelectField({
|
|
|
193
193
|
const [scanOpen, setScanOpen] = useState(false)
|
|
194
194
|
const debounced = useDebounced(search, 250)
|
|
195
195
|
|
|
196
|
-
// Escaneo por cámara para "llenar rápido": opt-in por campo (`scan`)
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
|
|
196
|
+
// Escaneo por cámara para "llenar rápido": opt-in por campo (`scan`). El
|
|
197
|
+
// botón aparece SIEMPRE que el campo lo declara (igual que el POS), sin
|
|
198
|
+
// esconderse por `isCameraScanSupported()` — en escritorio el botón no salía
|
|
199
|
+
// aunque el campo lo pidiera. El BarcodeScanner ya degrada con un mensaje
|
|
200
|
+
// cuando no hay cámara. Un código escaneado alimenta la búsqueda y abre el
|
|
201
|
+
// picker para elegir la referencia sin tipear el UUID.
|
|
202
|
+
const scanEnabled = !!(field.scan ?? field.scannable)
|
|
200
203
|
const handleScanDetected = (code: string) => {
|
|
201
204
|
setSearch(code)
|
|
202
205
|
setOpen(true)
|