@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.
- package/.github/workflows/auto-publish.yml +64 -0
- package/.github/workflows/release.yml +59 -0
- package/README.md +60 -0
- package/app.config.ts +70 -0
- package/components/Admin/Base.vue +144 -0
- package/components/Admin/Header.vue +32 -0
- package/components/Admin/Page.vue +65 -0
- package/components/Admin/PageHeader.vue +31 -0
- package/components/App/Button.vue +59 -0
- package/components/App/DevEnvironmentBar.vue +43 -0
- package/components/App/Dropdown.vue +286 -0
- package/components/App/EmptyState.vue +433 -0
- package/components/App/LoadingState.vue +40 -0
- package/components/App/PageLoadingSpinner.vue +118 -0
- package/components/App/PreviewDock.vue +64 -0
- package/components/App/SwitchColorTheme.vue +51 -0
- package/components/App/Tag.vue +193 -0
- package/components/DataTable.vue +713 -0
- package/components/Forms/DatePicker.vue +255 -0
- package/components/Forms/Input.vue +75 -0
- package/components/Forms/Select.vue +100 -0
- package/components/Forms/SelectServer.vue +726 -0
- package/components/Layout/Admin.vue +32 -0
- package/components/Layout/Auth.vue +29 -0
- package/components/Layout/SidebarWithAppColumn.vue +388 -0
- package/components/Layout/TopBar.vue +113 -0
- package/components/MobileBlocker.vue +85 -0
- package/components/MobileLoginPicker.vue +83 -0
- package/components/Modal/Base.vue +29 -0
- package/components/Modal/DeleteConfirm.vue +48 -0
- package/components/Modal.vue +103 -0
- package/components/Nav/Tabs.vue +55 -0
- package/components/PermissionsTree.vue +272 -0
- package/components/Table/Database.vue +183 -0
- package/components/Table/DownloadDropdown.vue +111 -0
- package/components/Table/Enterprise.vue +540 -0
- package/components/Table/FilterDropdown.vue +226 -0
- package/components/Table/Grid.vue +62 -0
- package/components/Table/Kanban.vue +188 -0
- package/components/Table/List.vue +128 -0
- package/components/Table/PreviewTimeline.vue +118 -0
- package/components/Table/Standard.vue +1217 -0
- package/components/Table/index.vue +974 -0
- package/components/TableExportable.vue +172 -0
- package/components/TableFilter.vue +93 -0
- package/components/Toast/Alert.vue +113 -0
- package/components/Toast/Container.vue +34 -0
- package/components/Toast/Notification.vue +45 -0
- package/components/Toast/Process.vue +88 -0
- package/composables/useApi.js +95 -0
- package/composables/useApp.ts +46 -0
- package/composables/useAuth.js +82 -0
- package/composables/useContext.js +44 -0
- package/composables/useDate.js +241 -0
- package/composables/useDevice.js +21 -0
- package/composables/useDockedPreviews.js +56 -0
- package/composables/useDownload.js +87 -0
- package/composables/useEntity.js +82 -0
- package/composables/useForm.js +119 -0
- package/composables/useInnertiaMode.ts +25 -0
- package/composables/useMobileGuard.ts +81 -0
- package/composables/useNotifications.js +22 -0
- package/composables/usePermissions.js +23 -0
- package/composables/useRealtime.js +123 -0
- package/composables/useRequestInterceptors.js +27 -0
- package/composables/useRoles.js +53 -0
- package/composables/useRutFormatter.js +39 -0
- package/composables/useTable.ts +94 -0
- package/composables/useTablePreferences.ts +33 -0
- package/composables/useTenant.js +27 -0
- package/composables/useTimeAgo.js +37 -0
- package/composables/useToast.js +69 -0
- package/composables/useUserRealtime.js +17 -0
- package/composables/useUsers.js +111 -0
- package/css/themes/autumn.css +401 -0
- package/css/themes/bubblegum.css +408 -0
- package/css/themes/cashmere.css +412 -0
- package/css/themes/harvest.css +416 -0
- package/css/themes/moon.css +140 -0
- package/css/themes/ocean.css +273 -0
- package/css/themes/olive.css +413 -0
- package/css/themes/retro.css +431 -0
- package/css/themes/theme.css +725 -0
- package/error.vue +78 -0
- package/middleware/01.detect-subdomain.global.ts +43 -0
- package/middleware/02.validate-tenant.global.ts +67 -0
- package/middleware/03.apps.global.ts +88 -0
- package/middleware/auth.ts +9 -0
- package/middleware/guest.ts +9 -0
- package/nuxt.config.ts +42 -0
- package/package.json +60 -0
- package/pages/tenant-error.vue +50 -0
- package/plugins/api-auth.ts +12 -0
- package/plugins/api-tenant.client.ts +21 -0
- package/plugins/appearance.ts +8 -0
- package/plugins/auth-init.ts +34 -0
- package/plugins/dark-state.client.ts +29 -0
- package/plugins/dockedPreviewsSync.client.js +17 -0
- package/plugins/preline.client.ts +68 -0
- package/plugins/theme.client.ts +7 -0
- package/plugins/vue-query.ts +29 -0
- package/public/init-theme.js +15 -0
- package/spark.css +721 -0
- package/stores/auth.js +130 -0
- package/stores/dockedPreviews.js +34 -0
- package/stores/notifications.js +24 -0
- package/stores/tenant.js +54 -0
- 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
|
+
}
|