@innertia-solutions/innertia-nuxt 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/app.config.ts +10 -10
  3. package/components/Attention.vue +269 -0
  4. package/components/Auth/LoginForm.vue +1 -1
  5. package/components/Chart/index.vue +193 -0
  6. package/components/Container.vue +116 -0
  7. package/components/CopyButton.vue +98 -0
  8. package/components/Files/Card.vue +215 -0
  9. package/components/Files/Preview.vue +675 -0
  10. package/components/Files/Upload.vue +408 -0
  11. package/components/Files/UploadModal.vue +83 -0
  12. package/components/Forms/InputPin.vue +162 -0
  13. package/components/InfoToolbar.vue +54 -0
  14. package/components/Kanban/Standard.vue +588 -0
  15. package/components/Loader.vue +358 -0
  16. package/components/MobileBlocker.vue +5 -5
  17. package/components/MobileLoginPicker.vue +13 -13
  18. package/components/Modal/Base.vue +6 -0
  19. package/components/Modal/Confirm.vue +89 -0
  20. package/components/Modal/DeleteConfirm.vue +14 -31
  21. package/components/Modal/Form.vue +76 -0
  22. package/components/Modal/Host.vue +58 -0
  23. package/components/Modal/Prompt.vue +74 -0
  24. package/components/Modal.vue +119 -34
  25. package/components/Organization/Picker.vue +3 -3
  26. package/components/Table/Standard.vue +198 -25
  27. package/components/Table/index.vue +169 -369
  28. package/components/Table/views/ViewCards.vue +74 -0
  29. package/components/Table/views/ViewDatabase.vue +236 -0
  30. package/components/Table/views/ViewTable.vue +299 -0
  31. package/components/Toast/Alert.vue +16 -111
  32. package/components/Toast/Container.vue +4 -29
  33. package/components/Toast/Notification.vue +8 -43
  34. package/components/Toast/Process.vue +8 -86
  35. package/components/Toast/Stack.vue +59 -0
  36. package/components/Toast/Standard.vue +115 -0
  37. package/components/Tree/Standard.vue +49 -4
  38. package/components/Workflow/Board.vue +356 -0
  39. package/components/Workflow/Builder.vue +454 -0
  40. package/components/Workflow/Form.vue +526 -0
  41. package/components/Workflow/Viewer.vue +225 -0
  42. package/components/Workflow/edges/TransitionEdge.vue +84 -0
  43. package/components/Workflow/nodes/StepNode.vue +88 -0
  44. package/composables/useAuth.js +1 -1
  45. package/composables/useClipboard.js +79 -0
  46. package/composables/useConfetti.js +194 -0
  47. package/composables/useContext.js +53 -5
  48. package/composables/useFile.js +189 -0
  49. package/composables/useMobileGuard.ts +21 -21
  50. package/composables/useModal.js +71 -0
  51. package/composables/useOrganization.ts +3 -3
  52. package/composables/useToast.js +38 -65
  53. package/composables/useWorkflow/form.js +162 -0
  54. package/composables/useWorkflow/index.js +18 -0
  55. package/composables/useWorkflow/view.js +220 -0
  56. package/composables/useWorkflowLayout.js +162 -0
  57. package/css/tokens.css +10 -10
  58. package/docs/superpowers/plans/2026-05-27-nuxt-apps-to-contexts-rename.md +630 -0
  59. package/docs/superpowers/specs/2026-05-27-nuxt-apps-to-contexts-rename-design.md +113 -0
  60. package/middleware/{03.apps.global.ts → 03.contexts.global.ts} +25 -25
  61. package/nuxt.config.ts +1 -1
  62. package/package.json +13 -8
  63. package/stores/modal.js +48 -0
  64. package/stores/toast.js +156 -117
  65. package/composables/useApp.ts +0 -46
package/CHANGELOG.md ADDED
@@ -0,0 +1,38 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@innertia-solutions/innertia-nuxt` are documented here.
4
+
5
+ ## [Unreleased]
6
+
7
+ ### BREAKING CHANGES
8
+
9
+ #### apps → contexts rename
10
+
11
+ Aligns frontend with innertia-laravel which already renamed the concept.
12
+
13
+ **Config (`nuxt.config.ts` in your product):**
14
+ ```diff
15
+ - innertia.apps: { backoffice: { ... }, technician: { ... } }
16
+ + innertia.contexts: { backoffice: { ... }, technician: { ... } }
17
+ ```
18
+
19
+ **Types:**
20
+ ```diff
21
+ - import type { AppDefinition } from '@innertia-solutions/innertia-nuxt/app.config'
22
+ + import type { ContextDefinition } from '@innertia-solutions/innertia-nuxt/app.config'
23
+
24
+ - import type { AppMobileMode } from '@innertia-solutions/innertia-nuxt/app.config'
25
+ + import type { ContextMobileMode } from '@innertia-solutions/innertia-nuxt/app.config'
26
+ ```
27
+
28
+ **Composables:**
29
+ ```diff
30
+ - const { current, all, accessible, canAccess } = useApp()
31
+ + const { current, all, accessible, canAccess } = useContext()
32
+ ```
33
+
34
+ `useApp()` has been removed. Use `useContext()` for all context/app logic. `useContext()` now also exposes: `contexts` (dict), `currentContext`, `availableContexts`, `switchContext()`, `confirmSwitch()`, `hasAccessToContext()`.
35
+
36
+ **Login body field:**
37
+
38
+ `performLogin()` now sends `context` instead of `app` in the POST body — matching what innertia-laravel now expects.
package/app.config.ts CHANGED
@@ -2,12 +2,12 @@
2
2
  //
3
3
  // appConfig: {
4
4
  // innertia: {
5
- // apps: { backoffice: {...}, technician: {...} },
5
+ // contexts: { backoffice: {...}, technician: {...} },
6
6
  // mobile: { breakpoint: 1024 },
7
7
  // },
8
8
  // }
9
9
  //
10
- // Si el producto no declara `apps`, el feature de contextos queda inactivo
10
+ // Si el producto no declara `contexts`, el feature de contextos queda inactivo
11
11
  // (middleware no redirige, picker no se muestra).
12
12
  export default defineAppConfig({
13
13
  innertia: {
@@ -121,11 +121,11 @@ export default defineAppConfig({
121
121
  },
122
122
 
123
123
  /**
124
- * Declaración de "apps" (contextos) del producto.
125
- * Cada app define un prefijo de URL que mapea a un contexto del backend
124
+ * Declaración de contextos del producto.
125
+ * Cada contexto define un prefijo de URL que mapea a un contexto del backend
126
126
  * (matching con `availableContexts` que devuelve `auth/me`).
127
127
  */
128
- apps: {} as Record<string, AppDefinition>,
128
+ contexts: {} as Record<string, ContextDefinition>,
129
129
 
130
130
  /**
131
131
  * Política mobile global. Se aplica si una `app` no tiene `mobile.mode` propio.
@@ -144,7 +144,7 @@ export default defineAppConfig({
144
144
 
145
145
  export type InnertiaMode = 'saas' | 'app'
146
146
 
147
- export type AppMobileMode = 'allow' | 'block' | 'redirect'
147
+ export type ContextMobileMode = 'allow' | 'block' | 'redirect'
148
148
 
149
149
  /** Color: nombre de paleta Tailwind o scale custom 50→950. */
150
150
  export type ColorScale = Partial<Record<50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950, string>>
@@ -175,8 +175,8 @@ export interface MenuItem {
175
175
  pattern?: string
176
176
  }
177
177
 
178
- export interface AppDefinition {
179
- /** Prefijo de URL — la ruta debe empezar con esto para considerarse "dentro" del app. */
178
+ export interface ContextDefinition {
179
+ /** Prefijo de URL — la ruta debe empezar con esto para considerarse "dentro" del contexto. */
180
180
  path: string
181
181
  /** Clave del contexto que matchea con `availableContexts` del backend. */
182
182
  context: string
@@ -186,13 +186,13 @@ export interface AppDefinition {
186
186
  description?: string
187
187
  /** Nombre del icono de @tabler/icons-vue (sin "Icon"). */
188
188
  icon?: string
189
- /** Ruta de login del app. */
189
+ /** Ruta de login del contexto. */
190
190
  loginPath: string
191
191
  /** Ruta home (post-login). */
192
192
  home: string
193
193
  /** Política de uso en mobile. */
194
194
  mobile: {
195
- mode: AppMobileMode
195
+ mode: ContextMobileMode
196
196
  /** Solo si mode === 'redirect' — adónde mandar desde mobile. */
197
197
  redirectTo?: string
198
198
  }
@@ -0,0 +1,269 @@
1
+ <script setup>
2
+ /**
3
+ * <Attention> — Wrapper que aplica efectos de "llamar la atención" a su slot.
4
+ *
5
+ * Efectos:
6
+ * - 'ping' → ondas de radar concéntricas saliendo del elemento
7
+ * - 'pulse' → respiración (scale 1 → 1.05)
8
+ * - 'shake' → vibración tipo campana (rotate)
9
+ * - 'bounce' → rebote vertical
10
+ * - 'glow' → halo de color difuminado
11
+ * - 'shimmer-border' → borde con gradiente cónico multicolor girando
12
+ *
13
+ * Props clave:
14
+ * - active → encender/apagar la animación
15
+ * - color → semántico ('primary','success','danger','warning'...) o CSS color
16
+ * - colors → array para shimmer-border (multicolor)
17
+ * - intensity → 'subtle' | 'normal' | 'strong' (escala los keyframes)
18
+ * - duration → ms (por defecto depende del efecto)
19
+ * - border → grosor del shimmer-border en px
20
+ * - radius → radio del wrapper (para que rings/glow lo respeten)
21
+ * - count → número de iteraciones (default Infinity)
22
+ *
23
+ * ```vue
24
+ * <Attention effect="ping" color="danger" radius="full">
25
+ * <button class="bg-red-500 size-10 rounded-full"><IconBell /></button>
26
+ * </Attention>
27
+ *
28
+ * <Attention effect="shake" :duration="1500">
29
+ * <IconBell class="size-6 text-amber-500" />
30
+ * </Attention>
31
+ *
32
+ * <Attention effect="shimmer-border" :colors="['#3b82f6','#8b5cf6','#ec4899']" radius="md">
33
+ * <button class="px-4 py-2 bg-card">Probar PRO</button>
34
+ * </Attention>
35
+ * ```
36
+ */
37
+
38
+ const props = defineProps({
39
+ effect: { type: String, default: 'ping', validator: v => ['ping','pulse','shake','bounce','glow','shimmer-border'].includes(v) },
40
+ active: { type: Boolean, default: true },
41
+ color: { type: String, default: 'primary' },
42
+ colors: { type: Array, default: () => ['#3b82f6', '#8b5cf6', '#ec4899'] }, // para shimmer-border
43
+ intensity: { type: String, default: 'normal', validator: v => ['subtle','normal','strong'].includes(v) },
44
+ duration: { type: Number, default: 0 }, // 0 = default por efecto
45
+ border: { type: Number, default: 2 },
46
+ /** Radio del wrapper: 'inherit' | 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full' o un valor CSS. */
47
+ radius: { type: String, default: 'inherit' },
48
+ /** Iteraciones de la animación. Default 'infinite'. Pasá un número para limitar. */
49
+ count: { type: [Number, String], default: 'infinite' },
50
+ })
51
+
52
+ const COLOR_MAP = {
53
+ primary: 'var(--color-primary)',
54
+ foreground: 'var(--color-foreground)',
55
+ muted: 'var(--color-muted-foreground)',
56
+ success: '#10b981',
57
+ danger: '#ef4444',
58
+ warning: '#f59e0b',
59
+ info: '#3b82f6',
60
+ emerald: '#10b981',
61
+ red: '#ef4444',
62
+ amber: '#f59e0b',
63
+ blue: '#3b82f6',
64
+ violet: '#8b5cf6',
65
+ pink: '#ec4899',
66
+ }
67
+
68
+ const resolvedColor = computed(() => COLOR_MAP[props.color] ?? props.color)
69
+
70
+ const radiusMap = {
71
+ inherit: 'inherit',
72
+ none: '0',
73
+ sm: 'var(--radius-sm, 0.25rem)',
74
+ md: 'var(--radius-control, 0.5rem)',
75
+ lg: 'var(--radius-card, 0.75rem)',
76
+ xl: 'var(--radius-xl, 1rem)',
77
+ full: '9999px',
78
+ }
79
+ const resolvedRadius = computed(() => radiusMap[props.radius] ?? props.radius)
80
+
81
+ // Duración default por efecto — más lentas = más sofisticadas.
82
+ const defaultDuration = {
83
+ ping: 2400,
84
+ pulse: 2400,
85
+ shake: 1400,
86
+ bounce: 1400,
87
+ glow: 2800,
88
+ 'shimmer-border': 4500,
89
+ }
90
+ const resolvedDuration = computed(() => props.duration > 0 ? props.duration : (defaultDuration[props.effect] ?? 1800))
91
+
92
+ const intensityScale = computed(() => ({ subtle: 0.6, normal: 1, strong: 1.5 }[props.intensity] ?? 1))
93
+
94
+ const cssVars = computed(() => ({
95
+ '--att-color': resolvedColor.value,
96
+ '--att-c1': props.colors[0] ?? resolvedColor.value,
97
+ '--att-c2': props.colors[1] ?? resolvedColor.value,
98
+ '--att-c3': props.colors[2] ?? resolvedColor.value,
99
+ '--att-c4': props.colors[3] ?? props.colors[0] ?? resolvedColor.value,
100
+ '--att-dur': resolvedDuration.value + 'ms',
101
+ '--att-radius': resolvedRadius.value,
102
+ '--att-border': props.border + 'px',
103
+ '--att-scale': intensityScale.value,
104
+ '--att-count': String(props.count),
105
+ }))
106
+
107
+ const hasRings = computed(() => props.active && props.effect === 'ping')
108
+ const hasGlow = computed(() => props.active && props.effect === 'glow')
109
+ const hasShimmer = computed(() => props.active && props.effect === 'shimmer-border')
110
+ const hasInlineEffect = computed(() => props.active && ['pulse','shake','bounce'].includes(props.effect))
111
+ </script>
112
+
113
+ <template>
114
+ <span
115
+ class="att-wrap"
116
+ :class="active ? `att-mode-${effect}` : ''"
117
+ :style="cssVars"
118
+ >
119
+ <!-- Ondas de radar (ping) -->
120
+ <span v-if="hasRings" class="att-ring att-ring-a" aria-hidden="true" />
121
+ <span v-if="hasRings" class="att-ring att-ring-b" aria-hidden="true" />
122
+
123
+ <!-- Halo (glow) -->
124
+ <span v-if="hasGlow" class="att-glow" aria-hidden="true" />
125
+
126
+ <!-- Borde gradiente cónico (shimmer-border) -->
127
+ <span v-if="hasShimmer" class="att-shimmer" aria-hidden="true" />
128
+
129
+ <!-- Contenido — recibe la transformación cuando aplica -->
130
+ <span
131
+ class="att-content"
132
+ :class="hasInlineEffect ? `att-anim-${effect}` : ''"
133
+ >
134
+ <slot />
135
+ </span>
136
+ </span>
137
+ </template>
138
+
139
+ <style scoped>
140
+ /* Custom property registrada para que el ángulo del conic-gradient pueda
141
+ interpolar via animation (sin esto, no anima). */
142
+ @property --att-angle {
143
+ syntax: '<angle>';
144
+ inherits: false;
145
+ initial-value: 0deg;
146
+ }
147
+
148
+ .att-wrap {
149
+ position: relative;
150
+ display: inline-flex;
151
+ isolation: isolate;
152
+ border-radius: var(--att-radius);
153
+ }
154
+ .att-content {
155
+ position: relative;
156
+ display: inline-flex;
157
+ z-index: 1;
158
+ border-radius: inherit;
159
+ }
160
+
161
+ /* ── PING — onda discreta ───────────────────────────────────────────── */
162
+ .att-ring {
163
+ position: absolute;
164
+ inset: 0;
165
+ border-radius: inherit;
166
+ border: 1px solid var(--att-color);
167
+ pointer-events: none;
168
+ z-index: 0;
169
+ opacity: 0;
170
+ animation: att-ping var(--att-dur) cubic-bezier(0, 0, 0.2, 1) var(--att-count);
171
+ }
172
+ .att-ring-b { animation-delay: calc(var(--att-dur) / 2); }
173
+ @keyframes att-ping {
174
+ 0% { transform: scale(1); opacity: calc(0.5 * var(--att-scale)); }
175
+ 100% { transform: scale(calc(1.35 + 0.25 * var(--att-scale))); opacity: 0; }
176
+ }
177
+
178
+ /* ── PULSE — respiración casi imperceptible ─────────────────────────── */
179
+ .att-anim-pulse {
180
+ animation: att-pulse var(--att-dur) ease-in-out var(--att-count);
181
+ }
182
+ @keyframes att-pulse {
183
+ 0%, 100% { transform: scale(1); }
184
+ 50% { transform: scale(calc(1 + 0.025 * var(--att-scale))); }
185
+ }
186
+
187
+ /* ── SHAKE — leve oscilación ────────────────────────────────────────── */
188
+ .att-anim-shake {
189
+ animation: att-shake var(--att-dur) ease-in-out var(--att-count);
190
+ transform-origin: top center;
191
+ }
192
+ @keyframes att-shake {
193
+ 0%, 60%, 100% { transform: rotate(0); }
194
+ 10%, 30% { transform: rotate(calc(-7deg * var(--att-scale))); }
195
+ 20%, 40% { transform: rotate(calc(7deg * var(--att-scale))); }
196
+ 50% { transform: rotate(0); }
197
+ }
198
+
199
+ /* ── BOUNCE — rebote sutil ──────────────────────────────────────────── */
200
+ .att-anim-bounce {
201
+ animation: att-bounce var(--att-dur) cubic-bezier(.5,.05,.5,1) var(--att-count);
202
+ }
203
+ @keyframes att-bounce {
204
+ 0%, 100% { transform: translateY(0); }
205
+ 50% { transform: translateY(calc(-3px * var(--att-scale))); }
206
+ }
207
+
208
+ /* ── GLOW — halo difuso muy tenue ───────────────────────────────────── */
209
+ .att-glow {
210
+ position: absolute;
211
+ inset: calc(-2px * var(--att-scale));
212
+ border-radius: inherit;
213
+ background: var(--att-color);
214
+ filter: blur(calc(10px * var(--att-scale)));
215
+ pointer-events: none;
216
+ z-index: 0;
217
+ opacity: 0;
218
+ animation: att-glow var(--att-dur) ease-in-out var(--att-count);
219
+ }
220
+ @keyframes att-glow {
221
+ 0%, 100% { opacity: calc(0.10 * var(--att-scale)); }
222
+ 50% { opacity: calc(0.28 * var(--att-scale)); }
223
+ }
224
+
225
+ /* ── SHIMMER-BORDER — gradiente fluyendo por el borde ───────────────── */
226
+ /* Técnica: contenedor con conic-gradient + mask hueco para que sólo se
227
+ renderice el borde. El ángulo del gradiente se anima (no el elemento)
228
+ para que el flujo sea suave y los corners no se vean. */
229
+ .att-shimmer {
230
+ position: absolute;
231
+ inset: 0;
232
+ border-radius: inherit;
233
+ padding: var(--att-border);
234
+ background:
235
+ conic-gradient(
236
+ from var(--att-angle),
237
+ var(--att-c1),
238
+ var(--att-c2),
239
+ var(--att-c3),
240
+ var(--att-c4),
241
+ var(--att-c1)
242
+ );
243
+ /* Hueco: pinta sólo el padding (el borde). */
244
+ -webkit-mask:
245
+ linear-gradient(#000 0 0) content-box,
246
+ linear-gradient(#000 0 0);
247
+ -webkit-mask-composite: xor;
248
+ mask:
249
+ linear-gradient(#000 0 0) content-box,
250
+ linear-gradient(#000 0 0);
251
+ mask-composite: exclude;
252
+ pointer-events: none;
253
+ z-index: 2; /* Por encima del contenido para que se vea como borde. */
254
+ animation: att-angle var(--att-dur) linear var(--att-count);
255
+ }
256
+ @keyframes att-angle {
257
+ to { --att-angle: 360deg; }
258
+ }
259
+
260
+ /* Reducción de animaciones por accesibilidad */
261
+ @media (prefers-reduced-motion: reduce) {
262
+ .att-ring,
263
+ .att-glow,
264
+ .att-shimmer,
265
+ .att-anim-pulse,
266
+ .att-anim-shake,
267
+ .att-anim-bounce { animation: none !important; }
268
+ }
269
+ </style>
@@ -99,7 +99,7 @@ async function handleOauth(provider: OAuthProvider) {
99
99
 
100
100
  // ── Cross-links a otros contextos mobile-friendly (opcional) ───────────────
101
101
  const { isMobile } = useMobileGuard()
102
- const { all } = useApp()
102
+ const { all } = useContext()
103
103
  const otherMobileApps = computed(() => {
104
104
  if (!props.showCrossLinks) return []
105
105
  return all.value.filter((a: any) => a.path !== `/${props.context}` && a.mobile?.mode === 'allow')
@@ -0,0 +1,193 @@
1
+ <script setup>
2
+ import { computed } from 'vue'
3
+ import VueApexCharts from 'vue3-apexcharts'
4
+
5
+ const props = defineProps({
6
+ /** Tipo de gráfico: line | bar | area | donut | pie | radialBar | scatter | heatmap */
7
+ type: { type: String, default: 'line' },
8
+ /** Series de datos — formato ApexCharts */
9
+ series: { type: Array, required: true },
10
+ /** Categorías para el eje X */
11
+ categories: { type: Array, default: () => [] },
12
+ /** Altura del gráfico en px o string CSS */
13
+ height: { type: [Number, String], default: 320 },
14
+ /** Opciones ApexCharts adicionales — se fusionan sobre las defaults */
15
+ options: { type: Object, default: () => ({}) },
16
+ /** Colores personalizados. Si se omite, usa los tokens --chart-* */
17
+ colors: { type: Array, default: null },
18
+ /** Muestra barra de herramientas */
19
+ toolbar: { type: Boolean, default: false },
20
+ /** Animación al montar */
21
+ animated: { type: Boolean, default: true },
22
+ /** Curva de líneas: smooth | straight | stepline */
23
+ curve: { type: String, default: 'smooth' },
24
+ /** Grosor de líneas (solo line/area) */
25
+ strokeWidth: { type: [Number, Array], default: 2 },
26
+ /** Mostrar dataLabels */
27
+ dataLabels: { type: Boolean, default: false },
28
+ /** Color de fondo de la zona — para area charts */
29
+ fillOpacity: { type: [Number, Array], default: undefined },
30
+ })
31
+
32
+ // ── Resolución de CSS vars a colores reales ───────────────────────────────────
33
+ const resolveCSSColor = (varName) => {
34
+ if (!process.client) return null
35
+ const el = document.createElement('span')
36
+ el.style.cssText = `display:none;color:var(${varName})`
37
+ document.body.appendChild(el)
38
+ const computed = getComputedStyle(el).color // siempre devuelve rgb(r,g,b)
39
+ document.body.removeChild(el)
40
+ // rgb(r, g, b) → #rrggbb
41
+ const match = computed.match(/\d+/g)
42
+ if (!match || match.length < 3) return null
43
+ return '#' + match.slice(0, 3).map(n => parseInt(n).toString(16).padStart(2, '0')).join('')
44
+ }
45
+
46
+ const resolveCSSVar = (varName) => {
47
+ if (!process.client) return ''
48
+ return getComputedStyle(document.documentElement).getPropertyValue(varName).trim()
49
+ }
50
+
51
+ // ── Dark mode reactive ────────────────────────────────────────────────────────
52
+ const isDark = useState('isDark', () => false)
53
+
54
+ // ── Paleta dinámica ───────────────────────────────────────────────────────────
55
+ const CHART_VARS = [
56
+ '--chart-primary',
57
+ '--chart-2', '--chart-3', '--chart-4', '--chart-5',
58
+ '--chart-6', '--chart-7',
59
+ ]
60
+
61
+ const resolvedColors = computed(() => {
62
+ // isDark.value registra reactividad; los colores se re-resuelven al cambiar el tema
63
+ void isDark.value
64
+ if (props.colors) return props.colors
65
+ return CHART_VARS.map(v => resolveCSSColor(v)).filter(Boolean)
66
+ })
67
+
68
+ // ── Opciones base ─────────────────────────────────────────────────────────────
69
+ const baseOptions = computed(() => {
70
+ void isDark.value // trigger reactivity on dark mode toggle
71
+ const mutedFg = resolveCSSColor('--color-muted-foreground') ?? '#9ca3af'
72
+ const cardLine = resolveCSSColor('--color-card-line') ?? '#e5e7eb'
73
+ const tooltipBg = resolveCSSColor('--color-card') ?? '#ffffff'
74
+ const tooltipFg = resolveCSSColor('--color-foreground') ?? '#111827'
75
+
76
+ const isDonutLike = ['donut', 'pie', 'radialBar'].includes(props.type)
77
+
78
+ const opts = {
79
+ chart: {
80
+ background: 'transparent',
81
+ toolbar: { show: props.toolbar },
82
+ fontFamily: 'inherit',
83
+ foreColor: mutedFg,
84
+ animations: { enabled: props.animated },
85
+ redrawOnWindowResize: true,
86
+ },
87
+ colors: resolvedColors.value,
88
+
89
+ stroke: isDonutLike ? undefined : {
90
+ curve: props.curve,
91
+ width: props.strokeWidth,
92
+ },
93
+
94
+ dataLabels: {
95
+ enabled: props.dataLabels,
96
+ style: { fontFamily: 'inherit', fontSize: '11px' },
97
+ },
98
+
99
+ ...(props.fillOpacity !== undefined ? {
100
+ fill: { opacity: props.fillOpacity },
101
+ } : {}),
102
+
103
+ xaxis: isDonutLike ? undefined : {
104
+ categories: props.categories,
105
+ axisBorder: { show: false },
106
+ axisTicks: { show: false },
107
+ labels: {
108
+ style: { colors: mutedFg, fontFamily: 'inherit', fontSize: '12px' },
109
+ },
110
+ },
111
+
112
+ yaxis: isDonutLike ? undefined : {
113
+ labels: {
114
+ style: { colors: mutedFg, fontFamily: 'inherit', fontSize: '12px' },
115
+ },
116
+ },
117
+
118
+ grid: isDonutLike ? { show: false } : {
119
+ borderColor: cardLine,
120
+ strokeDashArray: 4,
121
+ xaxis: { lines: { show: false } },
122
+ },
123
+
124
+ legend: {
125
+ fontFamily: 'inherit',
126
+ fontSize: '13px',
127
+ labels: { colors: tooltipFg },
128
+ },
129
+
130
+ tooltip: {
131
+ theme: isDark.value ? 'dark' : 'light',
132
+ style: { fontFamily: 'inherit', fontSize: '12px' },
133
+ x: { show: !isDonutLike },
134
+ },
135
+
136
+ plotOptions: props.type === 'bar' ? {
137
+ bar: { borderRadius: 4, columnWidth: '60%' },
138
+ } : props.type === 'radialBar' ? {
139
+ radialBar: {
140
+ hollow: { size: '60%' },
141
+ dataLabels: {
142
+ name: { color: tooltipFg, fontFamily: 'inherit' },
143
+ value: { color: tooltipFg, fontFamily: 'inherit', fontSize: '20px' },
144
+ },
145
+ },
146
+ } : undefined,
147
+ }
148
+
149
+ // Limpiar undefined para que ApexCharts no se confunda
150
+ return JSON.parse(JSON.stringify(opts))
151
+ })
152
+
153
+ // ── Merge profundo de opciones ────────────────────────────────────────────────
154
+ const mergedOptions = computed(() => deepMerge(baseOptions.value, props.options))
155
+
156
+ function deepMerge(base, override) {
157
+ const result = { ...base }
158
+ for (const key of Object.keys(override ?? {})) {
159
+ if (
160
+ override[key] !== null &&
161
+ typeof override[key] === 'object' &&
162
+ !Array.isArray(override[key]) &&
163
+ base[key] !== null &&
164
+ typeof base[key] === 'object' &&
165
+ !Array.isArray(base[key])
166
+ ) {
167
+ result[key] = deepMerge(base[key], override[key])
168
+ } else {
169
+ result[key] = override[key]
170
+ }
171
+ }
172
+ return result
173
+ }
174
+ </script>
175
+
176
+ <template>
177
+ <ClientOnly>
178
+ <VueApexCharts
179
+ :type="type"
180
+ :height="height"
181
+ :series="series"
182
+ :options="mergedOptions"
183
+ />
184
+ <template #fallback>
185
+ <div
186
+ :style="{ height: typeof height === 'number' ? height + 'px' : height }"
187
+ class="flex items-center justify-center bg-surface rounded-card"
188
+ >
189
+ <div class="w-6 h-6 rounded-full border-2 border-primary border-t-transparent animate-spin" />
190
+ </div>
191
+ </template>
192
+ </ClientOnly>
193
+ </template>
@@ -0,0 +1,116 @@
1
+ <script setup>
2
+ /**
3
+ * <Container> — Card / panel reutilizable que sigue los tokens del DS.
4
+ *
5
+ * Usado para contenido estático: settings, dashboards, KPI tiles, info boxes,
6
+ * formularios agrupados. NO para Tree/Table/Kanban (esos manejan su propio
7
+ * layout interno complejo).
8
+ *
9
+ * Slots:
10
+ * - default — body
11
+ * - header — reemplaza el header entero (si necesitás algo custom)
12
+ * - actions — botones a la derecha del título
13
+ * - footer — pie con border-top
14
+ *
15
+ * Props:
16
+ * - title / subtitle — texto del header automático
17
+ * - bordered — true = border + rounded-card (default)
18
+ * - padding — 'none' | 'sm' | 'md' (default) | 'lg' del body
19
+ * - size — 'sm' | 'md' | 'lg' | 'fit' (default) — min-height baseline
20
+ * - tone — 'default' | 'muted' | 'primary' | 'destructive' | 'emerald' | 'amber'
21
+ */
22
+
23
+ const props = defineProps({
24
+ title: { type: String, default: '' },
25
+ subtitle: { type: String, default: '' },
26
+ bordered: { type: Boolean, default: true },
27
+ padding: { type: String, default: 'md' },
28
+ size: { type: String, default: 'fit' },
29
+ tone: { type: String, default: 'default' },
30
+ })
31
+
32
+ const slots = useSlots()
33
+
34
+ const hasAutoHeader = computed(() => !!(props.title || props.subtitle || slots.actions))
35
+ const hasHeaderSlot = computed(() => !!slots.header)
36
+ const showHeader = computed(() => hasHeaderSlot.value || hasAutoHeader.value)
37
+
38
+ // ─── Padding del body ────────────────────────────────────────────────────────
39
+ const paddingClass = computed(() => ({
40
+ none: '',
41
+ sm: 'p-3',
42
+ md: 'p-5',
43
+ lg: 'p-7',
44
+ }[props.padding] ?? 'p-5'))
45
+
46
+ // ─── Size (min-height baseline) ──────────────────────────────────────────────
47
+ const sizeStyle = computed(() => {
48
+ switch (props.size) {
49
+ case 'sm': return { minHeight: '8rem' }
50
+ case 'md': return { minHeight: '14rem' }
51
+ case 'lg': return { minHeight: '24rem' }
52
+ case 'fit':
53
+ default: return {}
54
+ }
55
+ })
56
+
57
+ // ─── Tone (color del border + bg sutil) ──────────────────────────────────────
58
+ const toneClasses = computed(() => {
59
+ if (!props.bordered) return 'bg-card'
60
+ switch (props.tone) {
61
+ case 'muted': return 'bg-muted/30 border-card-line'
62
+ case 'primary': return 'bg-primary/5 border-primary/20'
63
+ case 'destructive': return 'bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-900/50'
64
+ case 'emerald': return 'bg-emerald-50 dark:bg-emerald-950/30 border-emerald-200 dark:border-emerald-900/50'
65
+ case 'amber': return 'bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-900/50'
66
+ case 'default':
67
+ default: return 'bg-card border-card-line'
68
+ }
69
+ })
70
+ </script>
71
+
72
+ <template>
73
+ <div
74
+ :class="[
75
+ 'flex flex-col',
76
+ toneClasses,
77
+ bordered ? 'border rounded-card overflow-hidden' : '',
78
+ ]"
79
+ :style="sizeStyle"
80
+ >
81
+ <!-- Header -->
82
+ <div
83
+ v-if="showHeader"
84
+ :class="[
85
+ 'shrink-0 flex items-center gap-3 px-5 py-4',
86
+ bordered ? 'border-b border-card-line' : '',
87
+ ]"
88
+ >
89
+ <slot name="header">
90
+ <div class="flex-1 min-w-0">
91
+ <h3 v-if="title" class="text-sm font-semibold text-foreground truncate">{{ title }}</h3>
92
+ <p v-if="subtitle" class="text-xs text-muted-foreground mt-0.5 truncate">{{ subtitle }}</p>
93
+ </div>
94
+ <div v-if="$slots.actions" class="shrink-0 flex items-center gap-1">
95
+ <slot name="actions" />
96
+ </div>
97
+ </slot>
98
+ </div>
99
+
100
+ <!-- Body -->
101
+ <div :class="['flex-1 min-h-0', paddingClass]">
102
+ <slot />
103
+ </div>
104
+
105
+ <!-- Footer -->
106
+ <div
107
+ v-if="$slots.footer"
108
+ :class="[
109
+ 'shrink-0 px-5 py-3',
110
+ bordered ? 'border-t border-card-line' : '',
111
+ ]"
112
+ >
113
+ <slot name="footer" />
114
+ </div>
115
+ </div>
116
+ </template>