@ceedcv-maya/shared-dashboard-react 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maya-AQSS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # @ceedcv-maya/shared-dashboard-react
2
+
3
+ Dashboard primitives for React: configurable widget grid (react-grid-layout), widget registry, persistence helpers.
4
+
5
+ Part of the [ceedcv-maya/maya_platform](https://github.com/Maya-AQSS/maya_platform) mono-repo. Distributed independently for reuse outside the Maya ecosystem.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @ceedcv-maya/shared-dashboard-react react-grid-layout
11
+ ```
12
+
13
+ ```tsx
14
+ import { DashboardGrid, registerWidget } from '@ceedcv-maya/shared-dashboard-react'
15
+
16
+ registerWidget('weather', WeatherWidget)
17
+
18
+ export function Home() {
19
+ return <DashboardGrid layout={savedLayout} onChange={persist} />
20
+ }
21
+ ```
22
+
23
+
24
+ ## TypeScript / build notes
25
+ This package ships TypeScript source (`src/index.ts` as entry). Consumers using Vite or Webpack with `ts-loader` work out of the box. Next.js consumers must add this package to `transpilePackages` in `next.config.js`.
26
+
27
+ ## License
28
+
29
+ MIT — see [LICENSE](LICENSE).
30
+
31
+ ## Reporting issues
32
+
33
+ The canonical source lives in [Maya-AQSS/maya_platform](https://github.com/Maya-AQSS/maya_platform). File issues there; this read-only split repo is only the published artifact.
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@ceedcv-maya/shared-dashboard-react",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "peerDependencies": {
8
+ "react": "^18.0.0 || ^19.0.0",
9
+ "react-dom": "^18.0.0 || ^19.0.0",
10
+ "react-grid-layout": "^1.5.0",
11
+ "react-i18next": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
12
+ },
13
+ "devDependencies": {
14
+ "@types/react": "^19.0.0",
15
+ "@types/react-dom": "^19.0.0",
16
+ "react": "^19.0.0",
17
+ "react-dom": "^19.0.0",
18
+ "@types/react-grid-layout": "^1.3.0",
19
+ "react-grid-layout": "^1.5.0",
20
+ "react-i18next": "^17.0.0",
21
+ "i18next": "^26.0.0"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc --noEmit",
25
+ "test": "echo \"no tests yet\" && exit 0",
26
+ "typecheck": "tsc --noEmit",
27
+ "lint": "echo \"no linter configured\""
28
+ },
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/Maya-AQSS/maya_platform.git",
33
+ "directory": "packages/js/shared-dashboard-react"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "description": "Dashboard primitives for React: configurable widget grid (react-grid-layout), widget registry, persistence helpers.",
39
+ "keywords": [
40
+ "react",
41
+ "dashboard",
42
+ "widgets",
43
+ "grid-layout",
44
+ "ceedcv",
45
+ "maya"
46
+ ],
47
+ "author": {
48
+ "name": "CEEDCV",
49
+ "email": "info@ceedcv.es",
50
+ "homepage": "https://ceedcv.es"
51
+ },
52
+ "homepage": "https://github.com/Maya-AQSS/shared-dashboard-react#readme",
53
+ "bugs": {
54
+ "url": "https://github.com/Maya-AQSS/maya_platform/issues"
55
+ },
56
+ "sideEffects": false,
57
+ "files": [
58
+ "src",
59
+ "LICENSE",
60
+ "README.md",
61
+ "tsconfig.json"
62
+ ]
63
+ }
@@ -0,0 +1,58 @@
1
+ import { useTranslation } from 'react-i18next'
2
+
3
+ interface Props {
4
+ editable: boolean
5
+ onToggle: () => void
6
+ /** Texto del botón cuando NO está en modo edit. Si no se pasa, usa i18n. */
7
+ editLabel?: string
8
+ /** Texto del botón cuando SÍ está en modo edit. Si no se pasa, usa i18n. */
9
+ exitLabel?: string
10
+ }
11
+
12
+ /**
13
+ * Botón compartido para entrar/salir del modo edición de los paneles
14
+ * dashboard. Usa gradiente Maya, visible en ambos temas (claro/oscuro).
15
+ *
16
+ * Texto y estilo idéntico en las 4 apps Maya:
17
+ * - Modo lectura: gradiente púrpura + label de i18n
18
+ * - Modo edit: fondo verde + label de i18n
19
+ *
20
+ * En viewports < sm el texto se oculta y queda solo el icono (forma circular).
21
+ */
22
+ export function DashboardEditToggleButton({
23
+ editable,
24
+ onToggle,
25
+ editLabel,
26
+ exitLabel,
27
+ }: Props) {
28
+ const { t } = useTranslation('common')
29
+ const label = editable
30
+ ? (exitLabel ?? t('dashboard.exitEdit', { defaultValue: 'Done' }))
31
+ : (editLabel ?? t('actions.edit', { defaultValue: 'Edit' }))
32
+
33
+ return (
34
+ <button
35
+ type="button"
36
+ onClick={onToggle}
37
+ aria-pressed={editable}
38
+ aria-label={label}
39
+ title={label}
40
+ className={[
41
+ // Mismo radio (rounded-md) y altura que los botones primary del
42
+ // resto de la app (ver Button.tsx variant sm). El `mr-4` alinea
43
+ // el borde derecho del botón con el borde derecho del bloque
44
+ // de widgets (react-grid-layout aplica containerPadding=16px).
45
+ 'inline-flex items-center justify-center h-9 px-4 rounded-md mr-4',
46
+ 'text-sm font-semibold tracking-wide',
47
+ 'shadow-[0_2px_6px_-2px_rgba(113,75,103,0.45)] hover:shadow-[0_8px_18px_-6px_rgba(113,75,103,0.55)]',
48
+ 'transition-all motion-reduce:transition-none',
49
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-odoo-purple/40',
50
+ editable
51
+ ? 'bg-success hover:bg-success-dark text-text-inverse border border-success-dark dark:border-success'
52
+ : 'bg-gradient-primary bg-gradient-primary-hover text-text-inverse border border-odoo-purple-d dark:border-odoo-dark-purple motion-reduce:bg-odoo-purple motion-reduce:dark:bg-odoo-dark-purple',
53
+ ].join(' ')}
54
+ >
55
+ {label}
56
+ </button>
57
+ )
58
+ }
@@ -0,0 +1,131 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import type { LayoutItem, WidgetRegistry } from './types'
3
+
4
+ interface Props {
5
+ /** Layout actual (para saber qué widgets ya están añadidos). */
6
+ layout: LayoutItem[]
7
+ /** Catálogo completo de widgets disponibles. */
8
+ registry: WidgetRegistry
9
+ /** Función i18n del consumidor — resuelve `titleKey` de cada widget. */
10
+ t: (key: string) => string
11
+ onSave: () => void
12
+ onCancel: () => void
13
+ onReset: () => void
14
+ onAddWidget: (widgetId: string) => void
15
+ /** Etiquetas opcionales (default en español). */
16
+ labels?: {
17
+ save?: string
18
+ cancel?: string
19
+ reset?: string
20
+ addWidget?: string
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Submenú de edición compartido por las 4 apps Maya. Diseñado para
26
+ * renderizarse dentro del slot `actions` de `<PageTitle>`, reemplazando
27
+ * al `DashboardEditToggleButton` durante el modo edición.
28
+ *
29
+ * Botones con altura h-9 y forma rounded-full para coincidir con
30
+ * `DashboardEditToggleButton`.
31
+ */
32
+ export function DashboardEditToolbar({
33
+ layout,
34
+ registry,
35
+ t,
36
+ onSave,
37
+ onCancel,
38
+ onReset,
39
+ onAddWidget,
40
+ labels,
41
+ }: Props) {
42
+ const defaultLabels = {
43
+ save: t('actions.save'),
44
+ cancel: t('actions.cancel'),
45
+ reset: t('actions.reset'),
46
+ addWidget: t('dashboard.addWidget'),
47
+ }
48
+ const L = { ...defaultLabels, ...(labels ?? {}) }
49
+ const [dropdownOpen, setDropdownOpen] = useState(false)
50
+ const dropdownRef = useRef<HTMLDivElement | null>(null)
51
+
52
+ useEffect(() => {
53
+ if (!dropdownOpen) return
54
+ function handleClick(e: MouseEvent) {
55
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
56
+ setDropdownOpen(false)
57
+ }
58
+ }
59
+ document.addEventListener('mousedown', handleClick)
60
+ return () => document.removeEventListener('mousedown', handleClick)
61
+ }, [dropdownOpen])
62
+
63
+ const existingIds = new Set(layout.map((item) => item.i))
64
+ const availableToAdd = Object.values(registry).filter((def) => !existingIds.has(def.id))
65
+
66
+ const baseBtn = [
67
+ // rounded-md igual que el resto de botones de la app (Button.tsx).
68
+ 'inline-flex items-center justify-center h-9 px-4 rounded-md',
69
+ 'text-sm font-semibold tracking-wide',
70
+ 'shadow-[0_2px_6px_-2px_rgba(113,75,103,0.35)] hover:shadow-[0_6px_14px_-6px_rgba(113,75,103,0.45)]',
71
+ 'transition-all motion-reduce:transition-none',
72
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-odoo-purple/40',
73
+ ].join(' ')
74
+
75
+ // Verde sólido: acción positiva (guardar).
76
+ const primaryBtn = `${baseBtn} bg-success hover:bg-success-dark text-text-inverse border border-success-dark dark:border-success`
77
+ // Neutro ghost: cancelar (descartar sin perder datos).
78
+ const cancelBtn = `${baseBtn} bg-transparent border border-ui-border dark:border-ui-dark-border text-text-secondary dark:text-text-dark-secondary hover:bg-ui-body dark:hover:bg-ui-dark-bg hover:text-text-primary dark:hover:text-text-dark-primary`
79
+ // Outline warning: restablecer (acción destructiva del layout).
80
+ const resetBtn = `${baseBtn} bg-warning-light dark:bg-warning-dark/20 border border-warning/50 dark:border-warning/60 text-warning-dark dark:text-warning hover:bg-warning/20 dark:hover:bg-warning-dark/40`
81
+ // Purple primary: acento (añadir widget).
82
+ const accentBtn = `${baseBtn} bg-gradient-primary bg-gradient-primary-hover text-text-inverse border border-odoo-purple-d dark:border-odoo-dark-purple disabled:opacity-50 disabled:cursor-not-allowed`
83
+
84
+ return (
85
+ // `mr-4` alinea el grupo con el borde derecho del bloque de widgets
86
+ // (react-grid-layout aplica containerPadding = margin = 16px).
87
+ <div className="flex items-center gap-2 flex-wrap justify-end mr-4">
88
+ <button type="button" onClick={onSave} className={primaryBtn} title={L.save}>
89
+ {L.save}
90
+ </button>
91
+ <button type="button" onClick={onCancel} className={cancelBtn} title={L.cancel}>
92
+ {L.cancel}
93
+ </button>
94
+ <div className="relative" ref={dropdownRef}>
95
+ <button
96
+ type="button"
97
+ onClick={() => setDropdownOpen((v) => !v)}
98
+ disabled={availableToAdd.length === 0}
99
+ className={accentBtn}
100
+ title={L.addWidget}
101
+ >
102
+ + {L.addWidget}
103
+ </button>
104
+ {dropdownOpen && availableToAdd.length > 0 && (
105
+ <div
106
+ role="menu"
107
+ className="absolute right-0 top-full mt-1 z-[300] min-w-[200px] bg-ui-card dark:bg-ui-dark-card border border-ui-border dark:border-ui-dark-border rounded-xl shadow-lg overflow-hidden"
108
+ >
109
+ {availableToAdd.map((def) => (
110
+ <button
111
+ key={def.id}
112
+ type="button"
113
+ role="menuitem"
114
+ onClick={() => {
115
+ onAddWidget(def.id)
116
+ setDropdownOpen(false)
117
+ }}
118
+ className="w-full text-left px-4 py-2 text-sm text-text-primary dark:text-text-dark-primary hover:bg-ui-body dark:hover:bg-ui-dark-bg transition"
119
+ >
120
+ {t(def.titleKey)}
121
+ </button>
122
+ ))}
123
+ </div>
124
+ )}
125
+ </div>
126
+ <button type="button" onClick={onReset} className={resetBtn} title={L.reset}>
127
+ {L.reset}
128
+ </button>
129
+ </div>
130
+ )
131
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Skeleton placeholder mostrado mientras el layout del dashboard se carga
3
+ * desde localStorage / backend. Estructura un grid de 12 columnas con
4
+ * `blocks` configurables; sin props usa 2 bloques al 50% (col-span-6 / h-40).
5
+ *
6
+ * Para layouts no triviales pasa `blocks` con `colSpanClasses` + `heightClass`
7
+ * (cada string es una clase Tailwind completa, no se interpola).
8
+ *
9
+ * Ejemplos:
10
+ * <DashboardSkeleton /> ← 2 bloques 50/50 alto 40
11
+ * <DashboardSkeleton blockCount={3} /> ← 3 bloques 50/50/100 alto 40
12
+ * <DashboardSkeleton blocks={[ ← layout personalizado
13
+ * { colSpanClasses: 'col-span-12 sm:col-span-8', heightClass: 'h-64' },
14
+ * { colSpanClasses: 'col-span-12 sm:col-span-4', heightClass: 'h-32' },
15
+ * ]} />
16
+ *
17
+ * Tailwind: usa las variables del theme Maya (`ui-border-l`,
18
+ * `ui-dark-border`); asume que el consumidor monta el provider de tema.
19
+ */
20
+
21
+ export interface SkeletonBlock {
22
+ /** Clases Tailwind para columnas, e.g. `'col-span-12 sm:col-span-6'`. */
23
+ colSpanClasses: string
24
+ /** Clase Tailwind para altura, e.g. `'h-40'` o `'h-64'`. */
25
+ heightClass: string
26
+ }
27
+
28
+ export interface DashboardSkeletonProps {
29
+ /**
30
+ * Layout explícito de bloques. Tiene prioridad sobre `blockCount`.
31
+ */
32
+ blocks?: SkeletonBlock[]
33
+ /**
34
+ * Número de bloques placeholder a renderizar con tamaño default
35
+ * (col-span-12 sm:col-span-6 / h-40). Ignorado si `blocks` se provee.
36
+ * Default: 2.
37
+ */
38
+ blockCount?: number
39
+ }
40
+
41
+ const DEFAULT_BLOCK: SkeletonBlock = {
42
+ colSpanClasses: 'col-span-12 sm:col-span-6',
43
+ heightClass: 'h-40',
44
+ }
45
+
46
+ export function DashboardSkeleton({ blocks, blockCount = 2 }: DashboardSkeletonProps) {
47
+ const layout = blocks ?? Array.from({ length: blockCount }, () => DEFAULT_BLOCK)
48
+
49
+ return (
50
+ <div className="p-4 sm:p-6 grid grid-cols-12 gap-4 animate-pulse">
51
+ {layout.map((block, i) => (
52
+ <div
53
+ key={i}
54
+ className={`${block.colSpanClasses} ${block.heightClass} bg-ui-border-l dark:bg-ui-dark-border rounded-2xl`}
55
+ />
56
+ ))}
57
+ </div>
58
+ )
59
+ }
@@ -0,0 +1,122 @@
1
+ import type { ReactNode } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+
4
+ interface WidgetFrameProps {
5
+ title?: string
6
+ hideTitle?: boolean
7
+ editable?: boolean
8
+ onRemove?: () => void
9
+ removeAriaLabel?: string
10
+ /**
11
+ * Realza el widget con borde gradiente diagonal (purple → teal).
12
+ * Pensado para KPI hero del dashboard. Usar con moderación: 1-2 widgets
13
+ * destacados por pantalla, el resto queda con el frame estándar.
14
+ */
15
+ highlight?: boolean
16
+ /**
17
+ * Si true, el frame deja crecer su contenido fuera del card. Pensado para
18
+ * widgets con decoraciones flotantes (megáfono asomando, badges sobresalidos).
19
+ * Mantén siempre el card relativo: las decoraciones se posicionan absolute.
20
+ */
21
+ allowOverflow?: boolean
22
+ /** Si true, el contenedor de children no aplica padding interno. */
23
+ bleed?: boolean
24
+ children?: ReactNode
25
+ }
26
+
27
+ /**
28
+ * Marco visual común para los widgets del dashboard: card con bordes
29
+ * redondeados, opcional barra de título, opcional botón de eliminar
30
+ * (solo visible en modo edit).
31
+ *
32
+ * En modo edit:
33
+ * - Toda la card actúa como drag-handle (clase `widget-drag-handle`),
34
+ * excepto el botón de eliminar y el handle de resize de
35
+ * `react-grid-layout` (`.react-resizable-handle`).
36
+ * - El contenido del widget queda bloqueado a interacciones
37
+ * (`pointer-events-none`) para que el usuario solo pueda
38
+ * arrastrar/redimensionar.
39
+ */
40
+ export function WidgetFrame({
41
+ title,
42
+ hideTitle = false,
43
+ editable = false,
44
+ onRemove,
45
+ removeAriaLabel,
46
+ highlight = false,
47
+ allowOverflow = false,
48
+ bleed = false,
49
+ children,
50
+ }: WidgetFrameProps) {
51
+ const { t } = useTranslation('common')
52
+ const resolvedRemoveAriaLabel =
53
+ removeAriaLabel ?? t('dashboard.removeWidget', { defaultValue: 'Remove widget' })
54
+ const showRemove = editable && typeof onRemove === 'function'
55
+ // Surface premium: glassmorphism (con fallback automático via @media reduced-transparency)
56
+ // + sombra extendida con tinte morado + hover lift sutil en modo lectura.
57
+ // En modo edit no se aplica el lift para no competir con el drag.
58
+ // En modo highlight, sustituye el borde plano por gradiente diagonal.
59
+ const baseSurface = highlight
60
+ ? 'border-glow-glass shadow-card-glass'
61
+ : 'bg-card-glass shadow-card-glass border border-ui-border/70 dark:border-ui-dark-border/70'
62
+ const frameClasses = [
63
+ 'relative h-full flex flex-col rounded-2xl',
64
+ allowOverflow ? 'overflow-visible' : 'overflow-hidden',
65
+ baseSurface,
66
+ editable
67
+ ? 'widget-drag-handle cursor-grab active:cursor-grabbing'
68
+ : 'motion-safe:transition-all motion-safe:duration-200 ease-out motion-safe:hover:-translate-y-0.5 motion-safe:hover:shadow-card-md',
69
+ !editable && !highlight
70
+ ? 'hover:border-odoo-purple/30 dark:hover:border-odoo-dark-purple/40'
71
+ : '',
72
+ ]
73
+ .filter(Boolean)
74
+ .join(' ')
75
+ .trim()
76
+
77
+ return (
78
+ <div className={frameClasses}>
79
+ {!hideTitle && (
80
+ <div className="flex items-center justify-between px-4 py-2 border-b border-ui-border-l dark:border-ui-dark-border">
81
+ <span className="text-sm font-semibold text-text-primary dark:text-text-dark-primary truncate">
82
+ {title}
83
+ </span>
84
+ {showRemove && (
85
+ <button
86
+ type="button"
87
+ onClick={onRemove}
88
+ onMouseDown={(e) => e.stopPropagation()}
89
+ onTouchStart={(e) => e.stopPropagation()}
90
+ aria-label={resolvedRemoveAriaLabel}
91
+ className="ml-2 shrink-0 inline-flex items-center justify-center w-6 h-6 rounded-md text-text-secondary hover:text-danger hover:bg-danger/10 transition-colors text-lg leading-none cursor-pointer"
92
+ >
93
+ ×
94
+ </button>
95
+ )}
96
+ </div>
97
+ )}
98
+ {hideTitle && showRemove && (
99
+ <button
100
+ type="button"
101
+ onClick={onRemove}
102
+ onMouseDown={(e) => e.stopPropagation()}
103
+ onTouchStart={(e) => e.stopPropagation()}
104
+ aria-label={resolvedRemoveAriaLabel}
105
+ className="absolute top-1 right-1 z-20 inline-flex items-center justify-center w-6 h-6 rounded-md text-text-secondary hover:text-danger hover:bg-danger/10 transition-colors text-lg leading-none cursor-pointer bg-ui-card/80 dark:bg-ui-dark-card/80 backdrop-blur"
106
+ >
107
+ ×
108
+ </button>
109
+ )}
110
+ <div
111
+ className={[
112
+ 'flex-1',
113
+ allowOverflow ? 'overflow-visible' : 'overflow-auto',
114
+ bleed ? '' : 'p-3',
115
+ editable ? 'pointer-events-none select-none' : '',
116
+ ].filter(Boolean).join(' ')}
117
+ >
118
+ {children}
119
+ </div>
120
+ </div>
121
+ )
122
+ }
@@ -0,0 +1,113 @@
1
+ import { useCallback } from 'react'
2
+ import { Responsive, WidthProvider, type Layout } from 'react-grid-layout'
3
+ import { WidgetFrame } from './WidgetFrame'
4
+ import type { LayoutItem, WidgetRegistry } from './types'
5
+
6
+ const ResponsiveGridLayout = WidthProvider(Responsive)
7
+
8
+ const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }
9
+ const COLS = { lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }
10
+
11
+ interface WidgetGridProps {
12
+ /** Definiciones de widgets disponibles. */
13
+ registry: WidgetRegistry
14
+ /** Layout actual (posición/tamaño de cada widget). */
15
+ layout: LayoutItem[]
16
+ /** Callback al cambiar el layout (drag/resize). */
17
+ onLayoutChange: (next: LayoutItem[]) => void
18
+ /** Modo edición — habilita drag y resize. */
19
+ editable: boolean
20
+ /** Callback al eliminar un widget. */
21
+ onRemoveWidget: (widgetId: string) => void
22
+ /** Función i18n del consumidor — usada para resolver `titleKey` y mensajes. */
23
+ t: (key: string) => string
24
+ /** Mensaje cuando no hay widgets visibles. */
25
+ emptyKey?: string
26
+ /** Aria label del botón eliminar de cada widget. */
27
+ removeAriaLabel?: string
28
+ }
29
+
30
+ /**
31
+ * Rejilla responsive con drag-and-drop sobre `react-grid-layout`. Renderiza
32
+ * cada widget de `layout` resolviendo su componente desde `registry`.
33
+ *
34
+ * El consumidor mantiene el estado del layout (`layout` + `onLayoutChange`) y
35
+ * la persistencia (localStorage, backend, etc.).
36
+ */
37
+ export function WidgetGrid({
38
+ registry,
39
+ layout,
40
+ onLayoutChange,
41
+ editable,
42
+ onRemoveWidget,
43
+ t,
44
+ emptyKey = 'dashboard.noWidgets',
45
+ removeAriaLabel,
46
+ }: WidgetGridProps) {
47
+ // Widgets pueden redimensionarse libremente: minW/minH del layout/registry
48
+ // se ignoran para permitir tamaños arbitrarios.
49
+ const validItems = layout
50
+ .filter((item) => item.i in registry)
51
+ .map((item) => ({ ...item, minW: 1, minH: 1 }))
52
+
53
+ const handleStop = useCallback(
54
+ (currentLayout: Layout[]) => {
55
+ if (!editable) return
56
+ const positionMap = Object.fromEntries(currentLayout.map((l) => [l.i, l]))
57
+ const merged: LayoutItem[] = layout.map((item) => {
58
+ const pos = positionMap[item.i]
59
+ return pos ? { ...item, x: pos.x, y: pos.y, w: pos.w, h: pos.h } : item
60
+ })
61
+ onLayoutChange(merged)
62
+ },
63
+ [editable, layout, onLayoutChange],
64
+ )
65
+
66
+ if (validItems.length === 0) {
67
+ return (
68
+ <p className="text-text-secondary dark:text-text-dark-secondary text-sm text-center py-12">
69
+ {t(emptyKey)}
70
+ </p>
71
+ )
72
+ }
73
+
74
+ return (
75
+ <ResponsiveGridLayout
76
+ className="layout"
77
+ layouts={{ lg: validItems, md: validItems, sm: validItems }}
78
+ breakpoints={BREAKPOINTS}
79
+ cols={COLS}
80
+ rowHeight={60}
81
+ margin={[16, 16]}
82
+ isDraggable={editable}
83
+ isResizable={editable}
84
+ compactType={null}
85
+ preventCollision={false}
86
+ allowOverlap={false}
87
+ onDragStop={handleStop}
88
+ onResizeStop={handleStop}
89
+ draggableHandle=".widget-drag-handle"
90
+ >
91
+ {validItems.map((item) => {
92
+ const def = registry[item.i]
93
+ const WidgetComponent = def.component
94
+ return (
95
+ <div key={item.i} className={def.allowOverflow ? 'maya-widget-item--overflow' : undefined}>
96
+ <WidgetFrame
97
+ title={t(def.titleKey)}
98
+ hideTitle={def.hideTitle}
99
+ editable={editable}
100
+ onRemove={() => onRemoveWidget(item.i)}
101
+ removeAriaLabel={removeAriaLabel}
102
+ highlight={def.highlight}
103
+ allowOverflow={def.allowOverflow}
104
+ bleed={def.bleed}
105
+ >
106
+ <WidgetComponent />
107
+ </WidgetFrame>
108
+ </div>
109
+ )
110
+ })}
111
+ </ResponsiveGridLayout>
112
+ )
113
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export { WidgetGrid } from './WidgetGrid'
2
+ export { WidgetFrame } from './WidgetFrame'
3
+ export { DashboardEditToggleButton } from './DashboardEditToggleButton'
4
+ export { DashboardEditToolbar } from './DashboardEditToolbar'
5
+ export { DashboardSkeleton } from './DashboardSkeleton'
6
+ export type { DashboardSkeletonProps, SkeletonBlock } from './DashboardSkeleton'
7
+ export { useDashboardLayoutLocal } from './useDashboardLayoutLocal'
8
+ export type { LayoutItem, WidgetDefinition, WidgetRegistry } from './types'
package/src/types.ts ADDED
@@ -0,0 +1,49 @@
1
+ import type { ComponentType } from 'react'
2
+
3
+ /** Posición y tamaño de un widget en la rejilla. */
4
+ export interface LayoutItem {
5
+ /** id del widget — debe coincidir con la clave en el `WidgetRegistry`. */
6
+ i: string
7
+ x: number
8
+ y: number
9
+ w: number
10
+ h: number
11
+ minW?: number
12
+ minH?: number
13
+ }
14
+
15
+ /** Definición de un widget reutilizable. */
16
+ export interface WidgetDefinition {
17
+ /** id estable. */
18
+ id: string
19
+ /** Clave i18n para el título (resuelta por el consumidor con su `t`). */
20
+ titleKey: string
21
+ /** Si true, oculta la barra de título del frame. */
22
+ hideTitle?: boolean
23
+ /**
24
+ * Realza el widget con borde gradiente (purple → teal). Reservar para
25
+ * KPIs hero (1-2 por dashboard máximo).
26
+ */
27
+ highlight?: boolean
28
+ /**
29
+ * Si true, el frame deja crecer su contenido fuera del card (`overflow:visible`).
30
+ * Útil cuando el widget renderiza decoraciones que deben sobresalir
31
+ * (megáfono, marcadores). Por defecto false — el frame recorta.
32
+ */
33
+ allowOverflow?: boolean
34
+ /**
35
+ * Si true, el frame no aplica padding interno al contenedor de children.
36
+ * El widget controla su propio espaciado — necesario para fondos a sangre
37
+ * que llegan hasta el borde del card.
38
+ */
39
+ bleed?: boolean
40
+ /** Tamaño por defecto al añadir el widget. */
41
+ defaultSize: { w: number; h: number }
42
+ /** Tamaño mínimo al redimensionar. */
43
+ minSize: { w: number; h: number }
44
+ /** Componente React que renderiza el contenido del widget. */
45
+ component: ComponentType<Record<string, unknown>>
46
+ }
47
+
48
+ /** Mapa id → definición. */
49
+ export type WidgetRegistry = Record<string, WidgetDefinition>
@@ -0,0 +1,62 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import type { LayoutItem } from './types'
3
+
4
+ interface Options {
5
+ /** Clave única bajo `localStorage` (recomendado: `maya:<app>:dashboard-layout`). */
6
+ storageKey: string
7
+ /** Layout por defecto al primer arranque o tras un reset. */
8
+ defaultLayout: LayoutItem[]
9
+ }
10
+
11
+ /**
12
+ * Persistencia simple del layout del dashboard usando localStorage.
13
+ * Cada app puede usar este hook si no quiere persistir en backend.
14
+ *
15
+ * const { layout, loading, saveLayout, resetToDefault } = useDashboardLayoutLocal({
16
+ * storageKey: 'maya:dms:dashboard-layout',
17
+ * defaultLayout: DEFAULT_LAYOUT,
18
+ * })
19
+ */
20
+ export function useDashboardLayoutLocal({ storageKey, defaultLayout }: Options) {
21
+ const [layout, setLayout] = useState<LayoutItem[]>(defaultLayout)
22
+ const [loading, setLoading] = useState(true)
23
+
24
+ useEffect(() => {
25
+ try {
26
+ const raw = localStorage.getItem(storageKey)
27
+ if (raw) {
28
+ const parsed = JSON.parse(raw)
29
+ if (Array.isArray(parsed)) {
30
+ setLayout(parsed as LayoutItem[])
31
+ }
32
+ }
33
+ } catch {
34
+ /* localStorage no disponible o JSON corrupto — usar default */
35
+ } finally {
36
+ setLoading(false)
37
+ }
38
+ }, [storageKey])
39
+
40
+ const saveLayout = useCallback(
41
+ async (next: LayoutItem[]) => {
42
+ setLayout(next)
43
+ try {
44
+ localStorage.setItem(storageKey, JSON.stringify(next))
45
+ } catch {
46
+ /* noop — el cambio queda en memoria al menos */
47
+ }
48
+ },
49
+ [storageKey],
50
+ )
51
+
52
+ const resetToDefault = useCallback(async () => {
53
+ setLayout(defaultLayout)
54
+ try {
55
+ localStorage.removeItem(storageKey)
56
+ } catch {
57
+ /* noop */
58
+ }
59
+ }, [storageKey, defaultLayout])
60
+
61
+ return { layout, loading, saveLayout, resetToDefault }
62
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "declaration": true,
11
+ "allowSyntheticDefaultImports": true
12
+ },
13
+ "include": ["src/**/*"]
14
+ }