@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 +21 -0
- package/README.md +33 -0
- package/package.json +63 -0
- package/src/DashboardEditToggleButton.tsx +58 -0
- package/src/DashboardEditToolbar.tsx +131 -0
- package/src/DashboardSkeleton.tsx +59 -0
- package/src/WidgetFrame.tsx +122 -0
- package/src/WidgetGrid.tsx +113 -0
- package/src/index.ts +8 -0
- package/src/types.ts +49 -0
- package/src/useDashboardLayoutLocal.ts +62 -0
- package/tsconfig.json +14 -0
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
|
+
}
|