@innertia-solutions/innertia-nuxt 0.1.1

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 (108) hide show
  1. package/.github/workflows/auto-publish.yml +64 -0
  2. package/.github/workflows/release.yml +59 -0
  3. package/README.md +60 -0
  4. package/app.config.ts +70 -0
  5. package/components/Admin/Base.vue +144 -0
  6. package/components/Admin/Header.vue +32 -0
  7. package/components/Admin/Page.vue +65 -0
  8. package/components/Admin/PageHeader.vue +31 -0
  9. package/components/App/Button.vue +59 -0
  10. package/components/App/DevEnvironmentBar.vue +43 -0
  11. package/components/App/Dropdown.vue +286 -0
  12. package/components/App/EmptyState.vue +433 -0
  13. package/components/App/LoadingState.vue +40 -0
  14. package/components/App/PageLoadingSpinner.vue +118 -0
  15. package/components/App/PreviewDock.vue +64 -0
  16. package/components/App/SwitchColorTheme.vue +51 -0
  17. package/components/App/Tag.vue +193 -0
  18. package/components/DataTable.vue +713 -0
  19. package/components/Forms/DatePicker.vue +255 -0
  20. package/components/Forms/Input.vue +75 -0
  21. package/components/Forms/Select.vue +100 -0
  22. package/components/Forms/SelectServer.vue +726 -0
  23. package/components/Layout/Admin.vue +32 -0
  24. package/components/Layout/Auth.vue +29 -0
  25. package/components/Layout/SidebarWithAppColumn.vue +388 -0
  26. package/components/Layout/TopBar.vue +113 -0
  27. package/components/MobileBlocker.vue +85 -0
  28. package/components/MobileLoginPicker.vue +83 -0
  29. package/components/Modal/Base.vue +29 -0
  30. package/components/Modal/DeleteConfirm.vue +48 -0
  31. package/components/Modal.vue +103 -0
  32. package/components/Nav/Tabs.vue +55 -0
  33. package/components/PermissionsTree.vue +272 -0
  34. package/components/Table/Database.vue +183 -0
  35. package/components/Table/DownloadDropdown.vue +111 -0
  36. package/components/Table/Enterprise.vue +540 -0
  37. package/components/Table/FilterDropdown.vue +226 -0
  38. package/components/Table/Grid.vue +62 -0
  39. package/components/Table/Kanban.vue +188 -0
  40. package/components/Table/List.vue +128 -0
  41. package/components/Table/PreviewTimeline.vue +118 -0
  42. package/components/Table/Standard.vue +1217 -0
  43. package/components/Table/index.vue +974 -0
  44. package/components/TableExportable.vue +172 -0
  45. package/components/TableFilter.vue +93 -0
  46. package/components/Toast/Alert.vue +113 -0
  47. package/components/Toast/Container.vue +34 -0
  48. package/components/Toast/Notification.vue +45 -0
  49. package/components/Toast/Process.vue +88 -0
  50. package/composables/useApi.js +95 -0
  51. package/composables/useApp.ts +46 -0
  52. package/composables/useAuth.js +82 -0
  53. package/composables/useContext.js +44 -0
  54. package/composables/useDate.js +241 -0
  55. package/composables/useDevice.js +21 -0
  56. package/composables/useDockedPreviews.js +56 -0
  57. package/composables/useDownload.js +87 -0
  58. package/composables/useEntity.js +82 -0
  59. package/composables/useForm.js +119 -0
  60. package/composables/useInnertiaMode.ts +25 -0
  61. package/composables/useMobileGuard.ts +81 -0
  62. package/composables/useNotifications.js +22 -0
  63. package/composables/usePermissions.js +23 -0
  64. package/composables/useRealtime.js +123 -0
  65. package/composables/useRequestInterceptors.js +27 -0
  66. package/composables/useRoles.js +53 -0
  67. package/composables/useRutFormatter.js +39 -0
  68. package/composables/useTable.ts +94 -0
  69. package/composables/useTablePreferences.ts +33 -0
  70. package/composables/useTenant.js +27 -0
  71. package/composables/useTimeAgo.js +37 -0
  72. package/composables/useToast.js +69 -0
  73. package/composables/useUserRealtime.js +17 -0
  74. package/composables/useUsers.js +111 -0
  75. package/css/themes/autumn.css +401 -0
  76. package/css/themes/bubblegum.css +408 -0
  77. package/css/themes/cashmere.css +412 -0
  78. package/css/themes/harvest.css +416 -0
  79. package/css/themes/moon.css +140 -0
  80. package/css/themes/ocean.css +273 -0
  81. package/css/themes/olive.css +413 -0
  82. package/css/themes/retro.css +431 -0
  83. package/css/themes/theme.css +725 -0
  84. package/error.vue +78 -0
  85. package/middleware/01.detect-subdomain.global.ts +43 -0
  86. package/middleware/02.validate-tenant.global.ts +67 -0
  87. package/middleware/03.apps.global.ts +88 -0
  88. package/middleware/auth.ts +9 -0
  89. package/middleware/guest.ts +9 -0
  90. package/nuxt.config.ts +42 -0
  91. package/package.json +60 -0
  92. package/pages/tenant-error.vue +50 -0
  93. package/plugins/api-auth.ts +12 -0
  94. package/plugins/api-tenant.client.ts +21 -0
  95. package/plugins/appearance.ts +8 -0
  96. package/plugins/auth-init.ts +34 -0
  97. package/plugins/dark-state.client.ts +29 -0
  98. package/plugins/dockedPreviewsSync.client.js +17 -0
  99. package/plugins/preline.client.ts +68 -0
  100. package/plugins/theme.client.ts +7 -0
  101. package/plugins/vue-query.ts +29 -0
  102. package/public/init-theme.js +15 -0
  103. package/spark.css +721 -0
  104. package/stores/auth.js +130 -0
  105. package/stores/dockedPreviews.js +34 -0
  106. package/stores/notifications.js +24 -0
  107. package/stores/tenant.js +54 -0
  108. package/stores/toast.js +129 -0
@@ -0,0 +1,119 @@
1
+ const rules = {
2
+ required: (value) => {
3
+ if (value === null || value === undefined) return 'Este campo es obligatorio'
4
+ if (typeof value === 'string' && value.trim() === '') return 'Este campo es obligatorio'
5
+ if (Array.isArray(value) && value.length === 0) return 'Este campo es obligatorio'
6
+ return true
7
+ },
8
+ email: (value) => /.+@.+\..+/.test(value) || 'El correo no es válido',
9
+ min: (value, arg) => value.length >= arg || `Debe tener al menos ${arg} caracteres`,
10
+ int: (value) => Number.isInteger(+value) || 'Debe ser un número entero',
11
+ rut: (value) => validateRut(value) || 'El RUT no es válido',
12
+ same: (value, arg, form) => value === form[arg] || 'Los campos no coinciden',
13
+ }
14
+
15
+ const dictionary = {
16
+ unique: 'Ya está registrado',
17
+ required: 'Este campo es obligatorio',
18
+ invalid: 'Dato inválido',
19
+ }
20
+
21
+ function validateRut(rut) {
22
+ if (!rut || typeof rut !== 'string') return false
23
+ rut = rut.replace(/^0+|[^0-9kK]+/g, '').toUpperCase()
24
+ if (rut.length < 8) return false
25
+ const body = rut.slice(0, -1)
26
+ const dv = rut.slice(-1)
27
+ let sum = 0, multiplier = 2
28
+ for (let i = body.length - 1; i >= 0; i--) {
29
+ sum += parseInt(body[i]) * multiplier
30
+ multiplier = multiplier < 7 ? multiplier + 1 : 2
31
+ }
32
+ const expected = 11 - (sum % 11)
33
+ const expectedDV = expected === 11 ? '0' : expected === 10 ? 'K' : expected.toString()
34
+ return dv === expectedDV
35
+ }
36
+
37
+ export function useForm(formDefinition, options = {}) {
38
+ const zodSchema = options.zodSchema
39
+ const form = reactive({})
40
+ const errors = reactive({})
41
+
42
+ for (const field in formDefinition) {
43
+ form[field] = formDefinition[field]?.value !== undefined ? formDefinition[field].value : ''
44
+ errors[field] = []
45
+ }
46
+
47
+ const reset = () => {
48
+ for (const field in formDefinition) {
49
+ form[field] = formDefinition[field]?.value !== undefined ? formDefinition[field].value : ''
50
+ errors[field] = []
51
+ }
52
+ }
53
+
54
+ const resetErrors = () => {
55
+ for (const field in formDefinition) {
56
+ errors[field] = []
57
+ }
58
+ }
59
+
60
+ const validateField = (field) => {
61
+ const def = formDefinition[field]
62
+ const value = form[field]
63
+ errors[field] = []
64
+ if (!def?.rules) return true
65
+ def.rules.forEach(rule => {
66
+ const ruleName = typeof rule === 'string' ? rule : rule.name
67
+ const arg = typeof rule === 'object' ? rule.arg : undefined
68
+ const result = rules[ruleName](value, arg, form)
69
+ if (result !== true) {
70
+ const custom = def.messages?.[ruleName]
71
+ errors[field].push(custom || result)
72
+ }
73
+ })
74
+ return errors[field].length === 0
75
+ }
76
+
77
+ const validateForm = () => {
78
+ if (zodSchema) {
79
+ const result = zodSchema.safeParse(form)
80
+ resetErrors()
81
+ if (!result.success) {
82
+ for (const issue of result.error.errors) {
83
+ const field = issue.path[0]
84
+ if (errors[field] !== undefined) errors[field].push(issue.message)
85
+ }
86
+ return false
87
+ }
88
+ return true
89
+ }
90
+ for (const field in formDefinition) validateField(field)
91
+ return Object.values(errors).every(e => e.length === 0)
92
+ }
93
+
94
+ const addError = (field, message) => {
95
+ if (message.startsWith('validation.')) {
96
+ const key = message.split('.')[1]
97
+ message = dictionary[key] || key
98
+ }
99
+ if (errors[field] !== undefined) errors[field].push(message)
100
+ }
101
+
102
+ const loadFromObject = (obj) => {
103
+ for (const field in formDefinition) {
104
+ if (obj[field] !== undefined) form[field] = obj[field]
105
+ }
106
+ }
107
+
108
+ return {
109
+ ...toRefs(form),
110
+ values: form,
111
+ errors,
112
+ validate: (field) => field ? validateField(field) : validateForm(),
113
+ reset,
114
+ resetErrors,
115
+ addError,
116
+ loadFromObject,
117
+ config: formDefinition,
118
+ }
119
+ }
@@ -0,0 +1,25 @@
1
+ import type { InnertiaMode } from '../app.config'
2
+
3
+ /**
4
+ * Devuelve el modo activo de la librería: 'saas' | 'app'.
5
+ * Default: 'saas' (compatibilidad con productos existentes).
6
+ *
7
+ * Se configura en `nuxt.config.ts` del producto:
8
+ * appConfig: { innertia: { mode: 'app' } }
9
+ *
10
+ * Helpers de conveniencia:
11
+ * isSaas() → true si mode === 'saas'
12
+ * isApp() → true si mode === 'app'
13
+ * hasTenant() → true si mode === 'saas' (solo saas usa multitenancy)
14
+ */
15
+ export function useInnertiaMode() {
16
+ const appConfig = useAppConfig()
17
+ const mode: InnertiaMode = (appConfig.innertia?.mode as InnertiaMode) ?? 'saas'
18
+
19
+ return {
20
+ mode,
21
+ isSaas: () => mode === 'saas',
22
+ isApp: () => mode === 'app',
23
+ hasTenant: () => mode === 'saas',
24
+ }
25
+ }
@@ -0,0 +1,81 @@
1
+ import { useMediaQuery } from '@vueuse/core'
2
+ import type { AppDefinition } from '../app.config'
3
+
4
+ const PICKER_CHOICE_COOKIE = 'innertia_mobile_app_choice'
5
+ const PICKER_COOKIE_MAX_AGE = 60 * 60 * 24 * 30 // 30 días
6
+
7
+ /**
8
+ * Detección reactiva de viewport mobile + lógica de landing.
9
+ *
10
+ * Reglas para `landing` (qué mostrar cuando el usuario entra a `/` en mobile):
11
+ * - 0 apps mobile-friendly → blocker "abre en escritorio"
12
+ * - 1 app mobile-friendly → redirect directo al login de ese app
13
+ * - 2+ apps mobile-friendly → picker (con cookie para recordar última elección)
14
+ *
15
+ * El breakpoint se lee desde `appConfig.innertia.mobile.breakpoint` (default 1024).
16
+ */
17
+ export function useMobileGuard() {
18
+ const { all, current, accessible } = useApp()
19
+ const authStore = useAuthStore()
20
+ const appConfig = useAppConfig()
21
+
22
+ const breakpoint: number = appConfig.innertia?.mobile?.breakpoint ?? 1024
23
+ const isMobile = useMediaQuery(`(max-width: ${breakpoint - 1}px)`)
24
+
25
+ /** Apps con mode 'allow' en mobile. */
26
+ const mobileApps = computed<AppDefinition[]>(() =>
27
+ all.value.filter(a => a.mobile?.mode === 'allow')
28
+ )
29
+
30
+ /** Apps mobile-friendly + accesibles para el usuario autenticado. */
31
+ const mobileAccessibleApps = computed<AppDefinition[]>(() =>
32
+ accessible.value.filter(a => a.mobile?.mode === 'allow')
33
+ )
34
+
35
+ /** ¿El app actual está bloqueado en mobile? */
36
+ const isCurrentAppBlocked = computed<boolean>(() => {
37
+ if (!isMobile.value) return false
38
+ if (!current.value) return false
39
+ return current.value.mobile?.mode === 'block'
40
+ })
41
+
42
+ /**
43
+ * Para el blocker: si el usuario está autenticado y tiene OTRO app mobile-friendly,
44
+ * lo ofrecemos como fallback ("continuar en X").
45
+ */
46
+ const mobileFallbackApp = computed<AppDefinition | null>(() => {
47
+ if (!authStore.isAuthenticated()) return null
48
+ return mobileAccessibleApps.value[0] ?? null
49
+ })
50
+
51
+ // ── Cookie helpers para recordar última elección del picker ─────────────────
52
+ function rememberPickerChoice(appKey: string) {
53
+ if (!(appConfig.innertia?.mobile?.rememberChoice ?? true)) return
54
+ const cookie = useCookie<string | null>(PICKER_CHOICE_COOKIE, {
55
+ maxAge: PICKER_COOKIE_MAX_AGE,
56
+ sameSite: 'lax',
57
+ })
58
+ cookie.value = appKey
59
+ }
60
+
61
+ function getRememberedPickerChoice(): string | null {
62
+ const cookie = useCookie<string | null>(PICKER_CHOICE_COOKIE)
63
+ return cookie.value ?? null
64
+ }
65
+
66
+ function clearRememberedPickerChoice() {
67
+ const cookie = useCookie<string | null>(PICKER_CHOICE_COOKIE)
68
+ cookie.value = null
69
+ }
70
+
71
+ return {
72
+ isMobile,
73
+ mobileApps,
74
+ mobileAccessibleApps,
75
+ isCurrentAppBlocked,
76
+ mobileFallbackApp,
77
+ rememberPickerChoice,
78
+ getRememberedPickerChoice,
79
+ clearRememberedPickerChoice,
80
+ }
81
+ }
@@ -0,0 +1,22 @@
1
+ export function useNotifications() {
2
+ const api = useApi()
3
+ const store = useNotificationsStore()
4
+
5
+ async function fetchNotifications(params = {}) {
6
+ const data = await api.get('auth/me/notifications', { params })
7
+ store.setNotifications(data?.data ?? data ?? [])
8
+ return data
9
+ }
10
+
11
+ async function markAsRead(id) {
12
+ await api.put(`auth/me/notifications/${id}/read`)
13
+ store.markRead(id)
14
+ }
15
+
16
+ async function markAllAsRead() {
17
+ await api.put('auth/me/notifications/read-all')
18
+ store.markAllRead()
19
+ }
20
+
21
+ return { fetchNotifications, markAsRead, markAllAsRead }
22
+ }
@@ -0,0 +1,23 @@
1
+ // useAuthStore, useApi auto-imported
2
+
3
+ export function usePermissions() {
4
+ const authStore = useAuthStore()
5
+ const api = useApi()
6
+
7
+ /** Check a single permission string */
8
+ const can = (permission) => authStore.permissions.includes(permission)
9
+
10
+ /** Check a single role string */
11
+ const hasRole = (role) => authStore.user?.roles?.includes(role) ?? false
12
+
13
+ /** True if user has at least one of the given permissions */
14
+ const hasAny = (permissions) => permissions.some(p => authStore.permissions.includes(p))
15
+
16
+ /** True if user has all of the given permissions */
17
+ const hasAll = (permissions) => permissions.every(p => authStore.permissions.includes(p))
18
+
19
+ /** Fetch all permission groups from backoffice — returns [{ category, category_alias, permissions[] }] */
20
+ const all = (params = {}) => api.get('backoffice/permissions', { params })
21
+
22
+ return { can, hasRole, hasAny, hasAll, all }
23
+ }
@@ -0,0 +1,123 @@
1
+ import { ref, readonly } from 'vue'
2
+ // useRequestInterceptors is auto-imported from this same package (nuxt-core)
3
+
4
+ const pusher = ref(null)
5
+ const connected = ref(false)
6
+ const error = ref(null)
7
+ const subscribedChannels = ref({})
8
+ let alreadyConnected = false
9
+
10
+ export function useRealtime() {
11
+ const config = useRuntimeConfig()
12
+ const {
13
+ pusherAppKey,
14
+ pusherAppCluster,
15
+ pusherWsHost,
16
+ pusherWsPort,
17
+ } = config.public
18
+
19
+ const connect = async () => {
20
+ if (alreadyConnected || pusher.value) return
21
+
22
+ if (!pusherAppKey) {
23
+ error.value = '[nuxt-core] Falta runtimeConfig.public.pusherAppKey'
24
+ console.error(error.value)
25
+ return
26
+ }
27
+
28
+ const PusherModule = await import('pusher-js')
29
+ const Pusher = PusherModule.default ?? PusherModule
30
+
31
+ // Build auth headers from all registered interceptors (auth token, X-Tenant-Id, etc.)
32
+ const { run } = useRequestInterceptors()
33
+ const authHeaders = {}
34
+ run(authHeaders)
35
+
36
+ const options = {
37
+ cluster: pusherAppCluster || 'mt1',
38
+ forceTLS: true,
39
+ enabledTransports: ['ws', 'wss'],
40
+ auth: {
41
+ headers: authHeaders,
42
+ },
43
+ }
44
+
45
+ // Socketi / host personalizado
46
+ if (pusherWsHost) {
47
+ options.wsHost = pusherWsHost
48
+ options.wsPort = pusherWsPort ? Number(pusherWsPort) : 443
49
+ options.wssPort = pusherWsPort ? Number(pusherWsPort) : 443
50
+ }
51
+
52
+ pusher.value = new Pusher(pusherAppKey, options)
53
+
54
+ pusher.value.connection.bind('connected', () => {
55
+ connected.value = true
56
+ alreadyConnected = true
57
+ })
58
+
59
+ pusher.value.connection.bind('error', (err) => {
60
+ error.value = err
61
+ connected.value = false
62
+ alreadyConnected = false
63
+ pusher.value = null // allow reconnect after error
64
+ console.error('[nuxt-core] Realtime error:', err)
65
+ })
66
+
67
+ pusher.value.connection.bind('disconnected', () => {
68
+ connected.value = false
69
+ alreadyConnected = false
70
+ })
71
+ }
72
+
73
+ const subscribe = (channelName, eventHandlers = {}) => {
74
+ if (!pusher.value) {
75
+ console.warn(`[nuxt-core] Realtime no conectado. Canal "${channelName}" ignorado.`)
76
+ return null
77
+ }
78
+
79
+ if (subscribedChannels.value[channelName]) {
80
+ const channel = subscribedChannels.value[channelName]
81
+ Object.entries(eventHandlers).forEach(([event, handler]) => {
82
+ channel.unbind(event)
83
+ channel.bind(event, handler)
84
+ })
85
+ return channel
86
+ }
87
+
88
+ const channel = pusher.value.subscribe(channelName)
89
+ Object.entries(eventHandlers).forEach(([event, handler]) => {
90
+ channel.bind(event, handler)
91
+ })
92
+ subscribedChannels.value[channelName] = channel
93
+ return channel
94
+ }
95
+
96
+ const unsubscribe = (channelName) => {
97
+ if (pusher.value && subscribedChannels.value[channelName]) {
98
+ pusher.value.unsubscribe(channelName)
99
+ delete subscribedChannels.value[channelName]
100
+ }
101
+ }
102
+
103
+ const disconnect = () => {
104
+ if (pusher.value) {
105
+ Object.keys(subscribedChannels.value).forEach(ch => pusher.value.unsubscribe(ch))
106
+ subscribedChannels.value = {}
107
+ pusher.value.disconnect()
108
+ pusher.value = null
109
+ connected.value = false
110
+ alreadyConnected = false
111
+ }
112
+ }
113
+
114
+ return {
115
+ pusherInstance: readonly(pusher),
116
+ connected: readonly(connected),
117
+ error: readonly(error),
118
+ connect,
119
+ disconnect,
120
+ subscribe,
121
+ unsubscribe,
122
+ }
123
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Module-level singleton registry.
3
+ * Shared by useApi (nuxt-app), useRealtime, and useDownload (nuxt-core).
4
+ * Each layer adds interceptors at plugin init time.
5
+ */
6
+ const interceptors = []
7
+
8
+ export function useRequestInterceptors() {
9
+ /**
10
+ * Register an interceptor function.
11
+ * fn(headers: object, options: object) — mutates headers in place.
12
+ * Idempotent: adding the same fn twice is a no-op.
13
+ */
14
+ const add = (fn) => {
15
+ if (!interceptors.includes(fn)) interceptors.push(fn)
16
+ }
17
+
18
+ /**
19
+ * Run all registered interceptors.
20
+ * headers and options are passed by reference; interceptors mutate headers.
21
+ */
22
+ const run = (headers, options = {}) => {
23
+ for (const fn of interceptors) fn(headers, options)
24
+ }
25
+
26
+ return { add, run }
27
+ }
@@ -0,0 +1,53 @@
1
+ export function useRoles() {
2
+ const api = useApi()
3
+ const queryClient = useQueryClient()
4
+
5
+ // ─── Queries ──────────────────────────────────────────────────────────────
6
+
7
+ const list = (params = {}) => useQuery({
8
+ queryKey: computed(() => ['roles', toValue(params)]),
9
+ queryFn: () => api.post('backoffice/roles', toValue(params)),
10
+ })
11
+
12
+ const detail = (id) => useQuery({
13
+ queryKey: computed(() => ['roles', toValue(id)]),
14
+ queryFn: () => api.get(`backoffice/roles/${toValue(id)}`),
15
+ enabled: computed(() => !!toValue(id)),
16
+ })
17
+
18
+ // ─── Mutations ────────────────────────────────────────────────────────────
19
+
20
+ const invalidateRoles = () => queryClient.invalidateQueries({ queryKey: ['roles'] })
21
+
22
+ const create = () => useMutation({
23
+ mutationFn: (data) => api.post('backoffice/roles', data),
24
+ onSuccess: invalidateRoles,
25
+ })
26
+
27
+ const update = () => useMutation({
28
+ mutationFn: ({ id, ...data }) => api.put(`backoffice/roles/${id}`, data),
29
+ onSuccess: (_, { id }) => {
30
+ queryClient.invalidateQueries({ queryKey: ['roles', id] })
31
+ invalidateRoles()
32
+ },
33
+ })
34
+
35
+ const remove = () => useMutation({
36
+ mutationFn: (id) => api.delete(`backoffice/roles/${id}`),
37
+ onSuccess: invalidateRoles,
38
+ })
39
+
40
+ const syncPermissions = () => useMutation({
41
+ mutationFn: ({ id, permissions }) =>
42
+ api.post(`backoffice/roles/${id}/permissions`, { permissions }),
43
+ onSuccess: (_, { id }) =>
44
+ queryClient.invalidateQueries({ queryKey: ['roles', id] }),
45
+ })
46
+
47
+ return {
48
+ // queries
49
+ list, detail,
50
+ // mutations
51
+ create, update, remove, syncPermissions,
52
+ }
53
+ }
@@ -0,0 +1,39 @@
1
+ import { ref, onMounted, onBeforeUnmount } from 'vue'
2
+
3
+ const formatRut = (rut) => {
4
+ const clean = rut.replace(/[^\dKk]/g, '').toUpperCase()
5
+ if (clean.length <= 1) return clean
6
+ const body = clean.slice(0, -1)
7
+ const dv = clean.slice(-1)
8
+ const formatted = body.replace(/\B(?=(\d{3})+(?!\d))/g, '.')
9
+ return `${formatted}-${dv}`
10
+ }
11
+
12
+ /**
13
+ * Attaches RUT auto-formatting to an input element ref.
14
+ * Cleans up the listener on unmount.
15
+ *
16
+ * @param {Ref<HTMLInputElement | null>} inputRef - template ref to the input
17
+ * @returns {{ formattedRut: Ref<string> }}
18
+ */
19
+ export const useRutFormatter = (inputRef) => {
20
+ const formattedRut = ref('')
21
+
22
+ const handler = (e) => {
23
+ const value = formatRut(e.target.value)
24
+ e.target.value = value
25
+ formattedRut.value = value
26
+ }
27
+
28
+ onMounted(() => {
29
+ const el = inputRef?.value ?? inputRef
30
+ if (el) el.addEventListener('input', handler)
31
+ })
32
+
33
+ onBeforeUnmount(() => {
34
+ const el = inputRef?.value ?? inputRef
35
+ if (el) el.removeEventListener('input', handler)
36
+ })
37
+
38
+ return { formattedRut }
39
+ }
@@ -0,0 +1,94 @@
1
+ // composables/useTable.ts
2
+
3
+ type TableConfig = { name: string; endpoint: string } | string
4
+
5
+ export function useTable(tableOrName?: TableConfig) {
6
+ const resolvedName = typeof tableOrName === 'object' ? tableOrName?.name : tableOrName
7
+
8
+ const invalidateCache = (tableName: string) => {
9
+ if (!tableName) {
10
+ console.warn('[useTable] No table name provided');
11
+ return;
12
+ }
13
+
14
+ const fullCacheKey = `table_cache_${tableName}`;
15
+
16
+ try {
17
+ sessionStorage.removeItem(fullCacheKey);
18
+ } catch (error) {
19
+ console.warn('[useTable] Error invalidating cache:', error);
20
+ }
21
+ };
22
+
23
+ const invalidateMultiple = (tableNames: string[]) => {
24
+ tableNames.forEach(name => invalidateCache(name));
25
+ };
26
+
27
+ const clearAllCache = () => {
28
+ try {
29
+ const keys = Object.keys(sessionStorage);
30
+ const tableCacheKeys = keys.filter(key => key.startsWith('table_cache_'));
31
+ tableCacheKeys.forEach(key => sessionStorage.removeItem(key));
32
+ } catch (error) {
33
+ console.warn('[useTable] Error clearing all cache:', error);
34
+ }
35
+ };
36
+
37
+ const useSearch = (tableName: string) => {
38
+ if (!tableName) {
39
+ throw new Error('[useTable] Table name is required for useSearch');
40
+ }
41
+
42
+ const searchCache = useState<Record<string, string>>("table-search-cache", () => ({}));
43
+ const search = ref(searchCache.value[tableName] || "");
44
+
45
+ watch(search, (newSearch, oldSearch) => {
46
+ searchCache.value[tableName] = newSearch;
47
+
48
+ if (oldSearch !== undefined && newSearch !== oldSearch) {
49
+ invalidateCache(tableName);
50
+ }
51
+ }, { immediate: true });
52
+
53
+ const clearSearch = () => { search.value = ""; };
54
+
55
+ return { search, clearSearch };
56
+ };
57
+
58
+ const clearAllSearches = () => {
59
+ const searchCache = useState<Record<string, string>>("table-search-cache", () => ({}));
60
+ searchCache.value = {};
61
+ };
62
+
63
+ const getSearchCache = () => {
64
+ const searchCache = useState<Record<string, string>>("table-search-cache", () => ({}));
65
+ return searchCache.value;
66
+ };
67
+
68
+ const useFilters = <T extends Record<string, any>>(tableName: string, initialFilters: T) => {
69
+ if (!tableName) {
70
+ throw new Error('[useTable] Table name is required for useFilters');
71
+ }
72
+
73
+ const filters = useState<T>(`table_filters_${tableName}`, () => ({ ...initialFilters }));
74
+ const resetFilters = () => { filters.value = { ...initialFilters }; };
75
+
76
+ return { filters, resetFilters };
77
+ };
78
+
79
+ const invalidate = () => {
80
+ if (resolvedName) invalidateCache(resolvedName)
81
+ else console.warn('[useTable] No table name to invalidate')
82
+ }
83
+
84
+ return {
85
+ invalidate,
86
+ invalidateCache,
87
+ invalidateMultiple,
88
+ clearAllCache,
89
+ useSearch,
90
+ useFilters,
91
+ clearAllSearches,
92
+ getSearchCache
93
+ };
94
+ }
@@ -0,0 +1,33 @@
1
+ import { useDebounceFn } from '@vueuse/core'
2
+
3
+ export interface TablePreferences {
4
+ pinning?: { left: string[], right: string[] }
5
+ visibility?: Record<string, boolean>
6
+ order?: string[]
7
+ }
8
+
9
+ export function useTablePreferences(tableName: string) {
10
+ const api = useApi()
11
+ const prefKey = `table:${tableName}:columns`
12
+
13
+ const preferences = ref<TablePreferences>({})
14
+
15
+ const load = async () => {
16
+ try {
17
+ const data = await api.get(`auth/me/preferences/${prefKey}`)
18
+ preferences.value = data?.value ?? {}
19
+ } catch {
20
+ preferences.value = {}
21
+ }
22
+ }
23
+
24
+ const save = useDebounceFn(async (value: TablePreferences) => {
25
+ try {
26
+ await api.put(`auth/me/preferences/${prefKey}`, { value, cast: 'json' })
27
+ } catch {
28
+ // silent fail — preferencias no son críticas
29
+ }
30
+ }, 800)
31
+
32
+ return { preferences, load, save }
33
+ }
@@ -0,0 +1,27 @@
1
+ // useTenantStore auto-imported from saas stores
2
+ // useApi auto-imported from nuxt-app composables
3
+ import { computed } from 'vue'
4
+
5
+ export function useTenant() {
6
+ const tenantStore = useTenantStore()
7
+ const api = useApi()
8
+
9
+ const currentTenant = computed(() => tenantStore.tenantId)
10
+ const tenantSlug = computed(() => tenantStore.tenantSlug)
11
+
12
+ /**
13
+ * Recarga la config completa del tenant desde el backend.
14
+ * El header X-Tenant-Id se inyecta automáticamente via el plugin api-tenant.
15
+ * Llamar después del login en una app SaaS.
16
+ */
17
+ async function loadConfig() {
18
+ const data = await api.get('tenant/config')
19
+ tenantStore.setTenant(tenantStore.tenantId, data)
20
+ return data
21
+ }
22
+
23
+ const isFeatureEnabled = (feature) => tenantStore.isFeatureEnabled(feature)
24
+ const getOauthProviders = () => tenantStore.getOauthProviders()
25
+
26
+ return { currentTenant, tenantSlug, loadConfig, isFeatureEnabled, getOauthProviders }
27
+ }