@innertia-solutions/innertia-nuxt 0.1.51 → 0.1.54

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.
@@ -0,0 +1,22 @@
1
+ <script setup>
2
+ const props = defineProps({
3
+ provider: { type: String, default: 'google_drive' },
4
+ label: { type: String, default: 'Google Drive' },
5
+ })
6
+
7
+ const { status, connect, disconnect } = useProviderConnection(props.provider)
8
+ const { data, isLoading } = status()
9
+
10
+ const connected = computed(() => data.value?.connected ?? data.value?.data?.connected ?? false)
11
+ const email = computed(() => data.value?.email ?? data.value?.data?.email ?? null)
12
+ </script>
13
+
14
+ <template>
15
+ <div class="inline-flex items-center gap-2">
16
+ <template v-if="connected">
17
+ <span class="text-sm text-muted-foreground">{{ label }}: <span class="text-foreground font-medium">{{ email }}</span></span>
18
+ <AppButton text="Desconectar" severity="secondary" size="sm" @click="disconnect" />
19
+ </template>
20
+ <AppButton v-else text="Conectar Google Drive" severity="primary" size="sm" :loading="isLoading" @click="connect" />
21
+ </div>
22
+ </template>
@@ -0,0 +1,59 @@
1
+ <script setup>
2
+ const api = useApi()
3
+
4
+ const { data, pending, error } = await useAsyncData(
5
+ 'observability-database',
6
+ () => api.get('platform/observability/database'),
7
+ )
8
+
9
+ function fmtBytes(bytes) {
10
+ if (bytes == null) return '—'
11
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
12
+ let n = Number(bytes), i = 0
13
+ while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
14
+ return `${n.toFixed(1)} ${units[i]}`
15
+ }
16
+ </script>
17
+
18
+ <template>
19
+ <div>
20
+ <div v-if="pending" class="text-sm text-muted-foreground">Cargando…</div>
21
+ <div v-else-if="error" class="text-sm text-red-500">No se pudo cargar la capacidad de la BD.</div>
22
+ <div v-else class="space-y-6">
23
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
24
+ <div class="rounded-card border border-border p-4">
25
+ <p class="text-xs text-muted-foreground">Tamaño de la BD</p>
26
+ <p class="mt-1 text-lg font-bold text-foreground">{{ fmtBytes(data.capacity?.database_bytes) }}</p>
27
+ </div>
28
+ <div class="rounded-card border border-border p-4">
29
+ <p class="text-xs text-muted-foreground">Conexiones</p>
30
+ <p class="mt-1 text-lg font-bold text-foreground">
31
+ {{ data.capacity?.connections?.used ?? '—' }} / {{ data.capacity?.connections?.max ?? '—' }}
32
+ </p>
33
+ </div>
34
+ <div class="rounded-card border border-border p-4">
35
+ <p class="text-xs text-muted-foreground">Disco usado</p>
36
+ <p class="mt-1 text-lg font-bold text-foreground">{{ data.disk?.percent != null ? data.disk.percent + '%' : '—' }}</p>
37
+ </div>
38
+ </div>
39
+
40
+ <div>
41
+ <h2 class="mb-2 text-sm font-semibold text-foreground">Tablas más grandes</h2>
42
+ <table class="w-full text-sm">
43
+ <thead>
44
+ <tr class="text-left text-muted-foreground">
45
+ <th class="py-1">Tabla</th><th class="py-1">Tamaño</th><th class="py-1">Filas</th>
46
+ </tr>
47
+ </thead>
48
+ <tbody>
49
+ <tr v-for="t in data.capacity?.tables ?? []" :key="t.name" class="border-t border-border">
50
+ <td class="py-1">{{ t.name }}</td>
51
+ <td class="py-1">{{ fmtBytes(t.bytes) }}</td>
52
+ <td class="py-1">{{ Number(t.rows).toLocaleString('es-CL') }}</td>
53
+ </tr>
54
+ </tbody>
55
+ </table>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ </template>
@@ -0,0 +1,44 @@
1
+ <script setup>
2
+ const api = useApi()
3
+ const toast = useToast()
4
+ const reloadKey = ref(0)
5
+
6
+ const columns = [
7
+ { key: 'last_seen', label: 'Última vez', sortable: true },
8
+ { key: 'title', label: 'Error' },
9
+ { key: 'exception_class', label: 'Clase' },
10
+ { key: 'times_seen', label: 'Veces' },
11
+ { key: 'status', label: 'Estado' },
12
+ { key: 'actions', label: '' },
13
+ ]
14
+
15
+ const statusLabels = { open: 'Abierto', resolved: 'Resuelto', ignored: 'Ignorado' }
16
+
17
+ async function setStatus(row, status) {
18
+ try {
19
+ await api.post(`platform/observability/errors/${row.id}/status`, { status })
20
+ toast.success('Estado actualizado.')
21
+ reloadKey.value++
22
+ } catch (e) {
23
+ toast.error(e?.data?.message ?? 'No se pudo cambiar el estado.')
24
+ }
25
+ }
26
+ </script>
27
+
28
+ <template>
29
+ <Table.Standard
30
+ :key="reloadKey"
31
+ name="observability-errors"
32
+ endpoint="platform/observability/errors"
33
+ :columns="columns"
34
+ search-placeholder="Buscar error…"
35
+ >
36
+ <template #status="{ value }">{{ statusLabels[value] ?? value }}</template>
37
+ <template #actions="{ row }">
38
+ <div class="flex gap-2">
39
+ <button class="text-xs text-muted-foreground hover:text-foreground" @click="setStatus(row, 'resolved')">Resolver</button>
40
+ <button class="text-xs text-muted-foreground hover:text-foreground" @click="setStatus(row, 'ignored')">Ignorar</button>
41
+ </div>
42
+ </template>
43
+ </Table.Standard>
44
+ </template>
@@ -0,0 +1,28 @@
1
+ <script setup>
2
+ const props = defineProps({
3
+ channel: { type: String, default: 'operational' },
4
+ })
5
+
6
+ const endpoint = computed(() => `platform/observability/logs?channel=${props.channel}`)
7
+
8
+ const columns = [
9
+ { key: 'occurred_at', label: 'Fecha', sortable: true },
10
+ { key: 'level', label: 'Nivel' },
11
+ { key: 'message', label: 'Mensaje' },
12
+ ]
13
+
14
+ const levelOf = (row) => row?.payload?.level ?? '—'
15
+ const messageOf = (row) => row?.payload?.message ?? '—'
16
+ </script>
17
+
18
+ <template>
19
+ <Table.Standard
20
+ :name="`observability-logs-${channel}`"
21
+ :endpoint="endpoint"
22
+ :columns="columns"
23
+ search-placeholder="Buscar en logs…"
24
+ >
25
+ <template #level="{ row }">{{ levelOf(row) }}</template>
26
+ <template #message="{ row }">{{ messageOf(row) }}</template>
27
+ </Table.Standard>
28
+ </template>
@@ -1,4 +1,4 @@
1
- // useAuthStore, useApi, useOrganizationStore, useInnertiaMode, useGymSelection, useGymStore auto-imported
1
+ // useAuthStore, useApi, useOrganizationStore, useInnertiaMode, useTenantSelection, useActiveTenantStore auto-imported
2
2
 
3
3
  export function useAuth() {
4
4
  const authStore = useAuthStore()
@@ -21,12 +21,12 @@ export function useAuth() {
21
21
  queryClient.clear()
22
22
  await fetchMe()
23
23
 
24
- // En modo 'open' el tenant se resuelve por selección: cargamos los gyms del
24
+ // En modo 'open' el tenant se resuelve por selección: cargamos los tenants del
25
25
  // usuario y devolvemos un destino (0/1/2+). saas/app conservan su return.
26
26
  if (useInnertiaMode().isOpen()) {
27
- const { resolveAfterLogin } = useGymSelection()
28
- const gymDestination = await resolveAfterLogin() // 'onboarding' | 'ready' | 'picker'
29
- return { ...(data ?? {}), gymDestination }
27
+ const { resolveAfterLogin } = useTenantSelection()
28
+ const tenantDestination = await resolveAfterLogin() // 'onboarding' | 'ready' | 'picker'
29
+ return { ...(data ?? {}), tenantDestination }
30
30
  }
31
31
 
32
32
  return data
@@ -77,8 +77,8 @@ export function useAuth() {
77
77
  queryClient.clear()
78
78
  authStore.logout()
79
79
  organizationStore.reset()
80
- // En open, limpiar también el gym seleccionado
81
- if (useInnertiaMode().isOpen()) useGymStore().reset()
80
+ // En open, limpiar también el tenant seleccionado
81
+ if (useInnertiaMode().isOpen()) useActiveTenantStore().reset()
82
82
  await navigateTo(loginPath)
83
83
  }
84
84
 
@@ -0,0 +1,69 @@
1
+ /**
2
+ * useGooglePicker — carga la Google API JS y abre el Google Picker. Client-only.
3
+ *
4
+ * Uso:
5
+ * const { openPicker } = useGooglePicker()
6
+ * const picked = await openPicker({ apiKey, token, appId, mode: 'file', mimeTypes })
7
+ * // picked === { id, name, mimeType, url } o null si se cancela
8
+ */
9
+ export function useGooglePicker() {
10
+ let pickerApiLoaded = false
11
+
12
+ function loadScript(src: string): Promise<void> {
13
+ return new Promise((resolve, reject) => {
14
+ if (document.querySelector(`script[src="${src}"]`)) return resolve()
15
+ const s = document.createElement('script')
16
+ s.src = src
17
+ s.onload = () => resolve()
18
+ s.onerror = reject
19
+ document.head.appendChild(s)
20
+ })
21
+ }
22
+
23
+ async function ensureLoaded() {
24
+ if (pickerApiLoaded) return
25
+ await loadScript('https://apis.google.com/js/api.js')
26
+ await new Promise<void>((res) => (window as any).gapi.load('picker', () => res()))
27
+ pickerApiLoaded = true
28
+ }
29
+
30
+ /**
31
+ * Abre el Google Picker. opts: { apiKey, token, appId, mode: 'file'|'folder', mimeTypes?: string }
32
+ * Resuelve con { id, name, mimeType, url } del item elegido, o null si se cancela.
33
+ */
34
+ async function openPicker(opts: {
35
+ apiKey: string
36
+ token: string
37
+ appId: string
38
+ mode: 'file' | 'folder'
39
+ mimeTypes?: string
40
+ }) {
41
+ if (typeof window === 'undefined') return null
42
+ await ensureLoaded()
43
+ return new Promise((resolve) => {
44
+ const google = (window as any).google
45
+ const view = opts.mode === 'folder'
46
+ ? new google.picker.DocsView(google.picker.ViewId.FOLDERS).setSelectFolderEnabled(true).setMimeTypes('application/vnd.google-apps.folder')
47
+ : new google.picker.DocsView(google.picker.ViewId.DOCS)
48
+ if (opts.mimeTypes && opts.mode !== 'folder') view.setMimeTypes(opts.mimeTypes)
49
+
50
+ const picker = new google.picker.PickerBuilder()
51
+ .setAppId(opts.appId)
52
+ .setOAuthToken(opts.token)
53
+ .setDeveloperKey(opts.apiKey)
54
+ .addView(view)
55
+ .setCallback((data: any) => {
56
+ if (data.action === google.picker.Action.PICKED) {
57
+ const doc = data.docs[0]
58
+ resolve({ id: doc.id, name: doc.name, mimeType: doc.mimeType, url: doc.url })
59
+ } else if (data.action === google.picker.Action.CANCEL) {
60
+ resolve(null)
61
+ }
62
+ })
63
+ .build()
64
+ picker.setVisible(true)
65
+ })
66
+ }
67
+
68
+ return { openPicker }
69
+ }
@@ -12,7 +12,7 @@ import type { InnertiaMode } from '../app.config'
12
12
  * isApp() → true si mode === 'app'
13
13
  * isOpen() → true si mode === 'open'
14
14
  * hasTenant() → true si mode === 'saas' | 'open' (usan multitenancy;
15
- * saas resuelve por subdominio, open por gym seleccionado)
15
+ * saas resuelve por subdominio, open por tenant seleccionado)
16
16
  */
17
17
  export function useInnertiaMode() {
18
18
  const appConfig = useAppConfig()
@@ -0,0 +1,74 @@
1
+ // useTenantSelection, useActiveTenantStore, useAppConfig auto-imported.
2
+ //
3
+ // Modo 'open' — login personal unificado.
4
+ //
5
+ // Tras autenticar NO conocemos el tenant ni el rol: la fuente de verdad es
6
+ // `auth/my-gyms`, que devuelve tenants:[{key,name,status,contexts}] — un tenant
7
+ // puede otorgar varios contextos (p.ej. backoffice + student = 2 destinos).
8
+ //
9
+ // Expande esos tenants en una lista plana de "destinos" (tenant × contexto) para
10
+ // rutear post-login:
11
+ // 0 destinos -> onboarding
12
+ // 1 destino -> entrar directo (fija tenant activo + navega a home del contexto)
13
+ // 2+ -> selector
14
+ //
15
+ // Los contextos que participan del login personal se declaran por producto en
16
+ // `appConfig.innertia.personalContexts` (p.ej. ['backoffice','student','coach']).
17
+ // Un contexto con login dedicado (p.ej. kiosco 'attendance') NO debe listarse ahí.
18
+
19
+ export function useLoginDestinations() {
20
+ const { load } = useTenantSelection()
21
+ const store = useActiveTenantStore()
22
+ const appConfig = useAppConfig()
23
+
24
+ const personalContexts = () => appConfig.innertia?.personalContexts ?? []
25
+ const contextDefs = () => appConfig.innertia?.contexts ?? {}
26
+
27
+ /** Home de un contexto (según app.config), con fallback razonable. */
28
+ function homeFor(context) {
29
+ const def = Object.values(contextDefs()).find((c) => c.context === context)
30
+ return def?.home || `/${context}`
31
+ }
32
+
33
+ /** Label legible de un contexto. */
34
+ function labelFor(context) {
35
+ const def = Object.values(contextDefs()).find((c) => c.context === context)
36
+ return def?.label || context
37
+ }
38
+
39
+ /**
40
+ * Carga los tenants del usuario y los expande a destinos tenant×contexto.
41
+ * Devuelve { destinations, tenants }.
42
+ * destination = { tenantKey, tenantName, tenantStatus, context, contextLabel, home }
43
+ */
44
+ async function resolve() {
45
+ await load() // rellena store.tenants desde auth/my-gyms
46
+ const tenants = store.tenants || []
47
+
48
+ const destinations = []
49
+ for (const tenant of tenants) {
50
+ const ctxs = (tenant.contexts || []).filter((c) => personalContexts().includes(c))
51
+ for (const context of ctxs) {
52
+ destinations.push({
53
+ tenantKey: tenant.key,
54
+ tenantName: tenant.name || tenant.key,
55
+ tenantStatus: tenant.status,
56
+ context,
57
+ contextLabel: labelFor(context),
58
+ home: homeFor(context),
59
+ })
60
+ }
61
+ }
62
+
63
+ return { destinations, tenants }
64
+ }
65
+
66
+ /** Fija el tenant + contexto activos y navega a la home del contexto elegido. */
67
+ async function go(destination) {
68
+ store.setActive(destination.tenantKey)
69
+ store.setActiveContext(destination.context)
70
+ await navigateTo(destination.home)
71
+ }
72
+
73
+ return { resolve, go, homeFor, labelFor }
74
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * useProviderConnection — estado y flujo OAuth de un proveedor de archivos (ej. Google Drive).
3
+ *
4
+ * Consume los endpoints de conexión del backend innertia-laravel:
5
+ * - GET files/providers/{provider}/status → { connected, email }
6
+ * - GET files/providers/{provider}/connect?scope=user → 302 al consent de Google (popup)
7
+ * - DELETE files/providers/{provider}/connection?scope=user → { connected: false }
8
+ *
9
+ * El popup del consent se cierra solo (el callback devuelve <script>window.close()</script>);
10
+ * al detectar el cierre invalidamos el status para reflejar la conexión.
11
+ *
12
+ * useApi / useQueryClient / useQuery / useRuntimeConfig se auto-importan en la capa.
13
+ */
14
+ export function useProviderConnection(provider = 'google_drive') {
15
+ const api = useApi()
16
+ const queryClient = useQueryClient()
17
+
18
+ const status = () => useQuery({
19
+ queryKey: ['provider', provider, 'status'],
20
+ queryFn: () => api.get(`files/providers/${provider}/status`),
21
+ })
22
+
23
+ const invalidate = () => queryClient.invalidateQueries({ queryKey: ['provider', provider, 'status'] })
24
+
25
+ const connect = () => {
26
+ if (typeof window === 'undefined') return
27
+ const cfg = useRuntimeConfig()
28
+ const base = (cfg.public.apiBaseUrl || '/api').replace(/\/$/, '')
29
+ const popup = window.open(`${base}/files/providers/${provider}/connect?scope=user`, 'provider-connect', 'width=520,height=680')
30
+ // cuando el popup se cierra (callback hizo window.close), refrescamos el estado
31
+ const timer = setInterval(() => {
32
+ if (!popup || popup.closed) { clearInterval(timer); invalidate() }
33
+ }, 800)
34
+ }
35
+
36
+ const disconnect = () => api.delete(`files/providers/${provider}/connection?scope=user`).then(invalidate)
37
+
38
+ return { status, connect, disconnect, invalidate }
39
+ }
@@ -0,0 +1,35 @@
1
+ // useApi, useActiveTenantStore auto-imported.
2
+ // Selección de tenant en modo 'open': carga los tenants del usuario y resuelve
3
+ // el destino post-login según cuántos tenga (0 / 1 / 2+).
4
+
5
+ export function useTenantSelection() {
6
+ const api = useApi()
7
+ const store = useActiveTenantStore()
8
+
9
+ /** Carga los tenants del usuario autenticado y los guarda en el store. */
10
+ async function load() {
11
+ // El backend aún expone la ruta gym-flavored: { gyms: [{ key, name, status, contexts }] }
12
+ const data = await api.get('auth/my-gyms')
13
+ store.setTenants(data?.gyms || [])
14
+ return store.tenants
15
+ }
16
+
17
+ /**
18
+ * Resuelve a dónde llevar al usuario tras el login.
19
+ * 'onboarding' -> 0 tenants (debe crear/unirse a uno)
20
+ * 'ready' -> 1 tenant (auto-seleccionado) o ya tenia uno activo valido
21
+ * 'picker' -> 2+ tenants sin seleccion valida previa
22
+ */
23
+ async function resolveAfterLogin() {
24
+ await load()
25
+ if (store.tenants.length === 0) return 'onboarding'
26
+ if (store.tenants.length === 1) { store.setActive(store.tenants[0].key); return 'ready' }
27
+ if (store.activeKey && store.tenants.some((t) => t.key === store.activeKey)) return 'ready'
28
+ return 'picker'
29
+ }
30
+
31
+ /** Cambia el tenant activo (switcher). */
32
+ function switchTo(key) { store.setActive(key) }
33
+
34
+ return { load, resolveAfterLogin, switchTo }
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/innertia-nuxt",
3
- "version": "0.1.51",
3
+ "version": "0.1.54",
4
4
  "description": "Innertia Solutions — Nuxt layer unificada: core, auth, multitenancy, theme y app contexts",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -2,9 +2,9 @@
2
2
  // El backend (ResolveTenantFromHeader) identifica el tenant por su key/slug, no por UUID.
3
3
  // - saas: slug del tenant resuelto por subdominio (tenantStore); solo se envía
4
4
  // cuando el tenant está validado (tenantId != null) para evitar el 'local' de dev.
5
- // - open: key del gym seleccionado in-app (gymStore.activeKey).
5
+ // - open: key del tenant seleccionado in-app (activeTenant.activeKey).
6
6
  // useRequestInterceptors auto-imported desde nuxt-core.
7
- // useTenantStore / useGymStore auto-imported desde stores.
7
+ // useTenantStore / useActiveTenantStore auto-imported desde stores.
8
8
  // Activo en mode === 'saas' | 'open'.
9
9
  export default defineNuxtPlugin(() => {
10
10
  const { isOpen, hasTenant } = useInnertiaMode()
@@ -15,10 +15,10 @@ export default defineNuxtPlugin(() => {
15
15
  const { add } = useRequestInterceptors()
16
16
 
17
17
  add((headers: Record<string, string>) => {
18
- // open: tenant por gym seleccionado
18
+ // open: tenant por selección in-app
19
19
  if (isOpen()) {
20
- const gymStore = useGymStore()
21
- if (gymStore.activeKey) headers['X-Tenant'] = gymStore.activeKey
20
+ const activeTenant = useActiveTenantStore()
21
+ if (activeTenant.activeKey) headers['X-Tenant'] = activeTenant.activeKey
22
22
  return
23
23
  }
24
24
 
@@ -0,0 +1,27 @@
1
+ import { defineStore } from 'pinia'
2
+
3
+ /**
4
+ * Tenant activo en modo 'open'.
5
+ * A diferencia de saas (tenant por subdominio), open resuelve el tenant por
6
+ * selección in-app: el usuario elige entre sus tenants y esa key viaja como X-Tenant.
7
+ * tenants: [{ key, name, status, contexts }]
8
+ */
9
+ export const useActiveTenantStore = defineStore('activeTenant', {
10
+ state: () => ({ activeKey: null, activeContext: null, tenants: [] }),
11
+
12
+ getters: {
13
+ active: (s) => s.tenants.find((t) => t.key === s.activeKey) || null,
14
+ hasMultiple: (s) => s.tenants.length > 1,
15
+ },
16
+
17
+ actions: {
18
+ setTenants(tenants) { this.tenants = tenants || [] },
19
+ setActive(key) { this.activeKey = key },
20
+ setActiveContext(context) { this.activeContext = context },
21
+ reset() { this.activeKey = null; this.activeContext = null; this.tenants = [] },
22
+ },
23
+
24
+ persist: {
25
+ pick: ['activeKey', 'activeContext'],
26
+ },
27
+ })
@@ -1,35 +0,0 @@
1
- // useApi, useGymStore auto-imported.
2
- // Selección de gym en modo 'open': carga los gyms del usuario y resuelve
3
- // el destino post-login según cuántos tenga (0 / 1 / 2+).
4
-
5
- export function useGymSelection() {
6
- const api = useApi()
7
- const gym = useGymStore()
8
-
9
- /** Carga los gyms del usuario autenticado y los guarda en el store. */
10
- async function load() {
11
- // Shape esperado: { gyms: [{ key, name, status, contexts }] }
12
- const data = await api.get('auth/my-gyms')
13
- gym.setGyms(data?.gyms || [])
14
- return gym.gyms
15
- }
16
-
17
- /**
18
- * Resuelve a dónde llevar al usuario tras el login.
19
- * 'onboarding' → 0 gyms (debe crear/unirse a uno)
20
- * 'ready' → 1 gym (auto-seleccionado) o ya tenía uno activo válido
21
- * 'picker' → 2+ gyms sin selección válida previa
22
- */
23
- async function resolveAfterLogin() {
24
- await load()
25
- if (gym.gyms.length === 0) return 'onboarding'
26
- if (gym.gyms.length === 1) { gym.setActive(gym.gyms[0].key); return 'ready' }
27
- if (gym.activeKey && gym.gyms.some((g) => g.key === gym.activeKey)) return 'ready'
28
- return 'picker'
29
- }
30
-
31
- /** Cambia el gym activo (switcher). */
32
- function switchTo(key) { gym.setActive(key) }
33
-
34
- return { load, resolveAfterLogin, switchTo }
35
- }
package/stores/gym.js DELETED
@@ -1,29 +0,0 @@
1
- import { defineStore } from 'pinia'
2
-
3
- /**
4
- * Gym activo en modo 'open'.
5
- * A diferencia de saas (tenant por subdominio), open resuelve el tenant por
6
- * selección in-app: el usuario elige entre sus gyms y esa key viaja como X-Tenant.
7
- * gyms: [{ key, name, status, contexts }]
8
- */
9
- export const useGymStore = defineStore('gym', {
10
- state: () => ({ activeKey: null, activeContext: null, gyms: [] }),
11
-
12
- getters: {
13
- active: (s) => s.gyms.find((g) => g.key === s.activeKey) || null,
14
- hasMultiple: (s) => s.gyms.length > 1,
15
- },
16
-
17
- actions: {
18
- setGyms(gyms) { this.gyms = gyms || [] },
19
- setActive(key) { this.activeKey = key },
20
- // Contexto elegido (backoffice/student/…) — un gym puede otorgar varios.
21
- setActiveContext(context) { this.activeContext = context },
22
- reset() { this.activeKey = null; this.activeContext = null; this.gyms = [] },
23
- },
24
-
25
- persist: {
26
- // Key + contexto activos sobreviven el refresh; la lista se recarga del backend.
27
- pick: ['activeKey', 'activeContext'],
28
- },
29
- })