@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,82 @@
|
|
|
1
|
+
// useAuthStore, useApi auto-imported
|
|
2
|
+
|
|
3
|
+
export function useAuth() {
|
|
4
|
+
const authStore = useAuthStore()
|
|
5
|
+
const api = useApi()
|
|
6
|
+
const config = useRuntimeConfig()
|
|
7
|
+
const loginPath = config.public.loginPath || '/login'
|
|
8
|
+
const queryClient = useQueryClient()
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Standard login (email + password).
|
|
12
|
+
* context: role/area slug used in the API path (e.g. 'admin', 'technician').
|
|
13
|
+
*/
|
|
14
|
+
async function performLogin(context, email, password, remember = false) {
|
|
15
|
+
authStore.rememberUser = remember
|
|
16
|
+
const data = await api.post(`${context}/auth/login`, { email, password, app: context })
|
|
17
|
+
authStore.saveToken(data.token ?? data.access_token)
|
|
18
|
+
authStore.setCurrentContext(context)
|
|
19
|
+
queryClient.clear()
|
|
20
|
+
await fetchMe()
|
|
21
|
+
return data
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Load current user, permissions, and available contexts from the API.
|
|
26
|
+
* Called after login and after context switch.
|
|
27
|
+
*/
|
|
28
|
+
async function fetchMe() {
|
|
29
|
+
const data = await api.get('auth/me')
|
|
30
|
+
if (!data) return null
|
|
31
|
+
authStore.saveUser(data.user ?? data)
|
|
32
|
+
authStore.savePermissions(data.permissions ?? [])
|
|
33
|
+
authStore.availableContexts = data.availableContexts ?? []
|
|
34
|
+
applyAppearance(data.preferences?.appearance)
|
|
35
|
+
return data
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function applyAppearance(appearance) {
|
|
39
|
+
if (!appearance || !import.meta.client) return
|
|
40
|
+
const dark = appearance === 'dark'
|
|
41
|
+
document.documentElement.classList.toggle('dark', dark)
|
|
42
|
+
localStorage.setItem('hs_theme', appearance)
|
|
43
|
+
document.cookie = `hs_theme=${appearance};path=/;max-age=${60 * 60 * 24 * 365};SameSite=Lax`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Logout: best-effort POST to backend, then clear local state and redirect.
|
|
48
|
+
*/
|
|
49
|
+
async function logout() {
|
|
50
|
+
try {
|
|
51
|
+
await api.post('auth/logout', {})
|
|
52
|
+
} catch {
|
|
53
|
+
// best-effort — ignore network failures
|
|
54
|
+
}
|
|
55
|
+
queryClient.clear()
|
|
56
|
+
authStore.logout()
|
|
57
|
+
await navigateTo(loginPath)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get the OAuth redirect URL for a provider.
|
|
62
|
+
* Returns the URL string from the backend.
|
|
63
|
+
*/
|
|
64
|
+
async function getOauthRedirectUrl(context, provider) {
|
|
65
|
+
const data = await api.get(`${context}/auth/oauth/${provider}/redirect`)
|
|
66
|
+
return data.url
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Handle OAuth callback. Same success path as performLogin.
|
|
71
|
+
*/
|
|
72
|
+
async function handleOauthCallback(context, provider, code) {
|
|
73
|
+
const data = await api.post(`${context}/auth/oauth/${provider}/callback`, { code })
|
|
74
|
+
authStore.saveToken(data.token ?? data.access_token)
|
|
75
|
+
authStore.setCurrentContext(context)
|
|
76
|
+
queryClient.clear()
|
|
77
|
+
await fetchMe()
|
|
78
|
+
return data
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { performLogin, fetchMe, logout, getOauthRedirectUrl, handleOauthCallback }
|
|
82
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// useAuthStore, useApi, useAuth auto-imported
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
|
|
4
|
+
export function useContext() {
|
|
5
|
+
const authStore = useAuthStore()
|
|
6
|
+
const api = useApi()
|
|
7
|
+
const { fetchMe } = useAuth()
|
|
8
|
+
|
|
9
|
+
const currentContext = computed(() => authStore.currentContext)
|
|
10
|
+
const availableContexts = computed(() => authStore.availableContexts)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Check whether user has permission to switch to targetContext.
|
|
14
|
+
* Returns:
|
|
15
|
+
* { success: false, reason: 'no_permission' } — user cannot switch
|
|
16
|
+
* { success: true, requiresConfirmation: true } — show confirmation UI
|
|
17
|
+
*/
|
|
18
|
+
async function switchContext(targetContext) {
|
|
19
|
+
const data = await api.get(`auth/context/${targetContext}/check`)
|
|
20
|
+
if (!data.hasAccess) {
|
|
21
|
+
return { success: false, reason: 'no_permission' }
|
|
22
|
+
}
|
|
23
|
+
return { success: true, requiresConfirmation: true }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Execute the context switch after user confirmation.
|
|
28
|
+
* Updates store and reloads permissions via fetchMe.
|
|
29
|
+
*/
|
|
30
|
+
async function confirmSwitch(targetContext) {
|
|
31
|
+
authStore.setCurrentContext(targetContext)
|
|
32
|
+
await fetchMe()
|
|
33
|
+
return { success: true }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Quick synchronous check — is this context in the available list?
|
|
38
|
+
*/
|
|
39
|
+
function hasAccessToContext(context) {
|
|
40
|
+
return authStore.availableContexts.includes(context)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { currentContext, availableContexts, switchContext, confirmSwitch, hasAccessToContext }
|
|
44
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { ref, onMounted, onUnmounted } from "vue";
|
|
2
|
+
|
|
3
|
+
// Store global para el tiempo actual (compartido entre todas las instancias)
|
|
4
|
+
const globalTimeStore = (() => {
|
|
5
|
+
const currentTime = ref(Date.now());
|
|
6
|
+
let intervalId = null;
|
|
7
|
+
let subscriberCount = 0;
|
|
8
|
+
|
|
9
|
+
const startGlobalTimer = () => {
|
|
10
|
+
if (!intervalId) {
|
|
11
|
+
intervalId = setInterval(() => {
|
|
12
|
+
currentTime.value = Date.now();
|
|
13
|
+
}, 5000); // Actualiza cada 5 segundos
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const stopGlobalTimer = () => {
|
|
18
|
+
if (intervalId) {
|
|
19
|
+
clearInterval(intervalId);
|
|
20
|
+
intervalId = null;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const subscribe = () => {
|
|
25
|
+
subscriberCount++;
|
|
26
|
+
if (subscriberCount === 1) {
|
|
27
|
+
startGlobalTimer();
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const unsubscribe = () => {
|
|
32
|
+
subscriberCount--;
|
|
33
|
+
if (subscriberCount === 0) {
|
|
34
|
+
stopGlobalTimer();
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
currentTime,
|
|
40
|
+
subscribe,
|
|
41
|
+
unsubscribe,
|
|
42
|
+
};
|
|
43
|
+
})();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Composable para manejo de fechas y tiempos relativos
|
|
47
|
+
*/
|
|
48
|
+
export const useDate = () => {
|
|
49
|
+
// Zona horaria: configurable via runtimeConfig.public.timeZone, fallback Santiago
|
|
50
|
+
const config = useRuntimeConfig()
|
|
51
|
+
const tenantTimeZone = config.public?.timeZone || 'America/Santiago';
|
|
52
|
+
|
|
53
|
+
// Suscribirse al timer global cuando se monta el componente
|
|
54
|
+
onMounted(() => {
|
|
55
|
+
globalTimeStore.subscribe();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
onUnmounted(() => {
|
|
59
|
+
globalTimeStore.unsubscribe();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Como la DB de Laravel suele enviar fechas "YYYY-MM-DD HH:mm:ss" sin zona horaria,
|
|
64
|
+
* y siempre están en UTC, forzamos la lectura como UTC si no viene indicada.
|
|
65
|
+
*/
|
|
66
|
+
const parseAsUTC = (input) => {
|
|
67
|
+
if (!input) return new Date("");
|
|
68
|
+
if (input instanceof Date) return input;
|
|
69
|
+
|
|
70
|
+
let dateStr = String(input);
|
|
71
|
+
if (!dateStr.includes('T') && dateStr.includes(' ')) {
|
|
72
|
+
dateStr = dateStr.replace(' ', 'T');
|
|
73
|
+
}
|
|
74
|
+
// Si no termina en Z, ni tiene offset tipo +00:00, agregar Z
|
|
75
|
+
if (!/(Z|[+-]\d{2}(:\d{2})?)$/.test(dateStr)) {
|
|
76
|
+
dateStr += 'Z';
|
|
77
|
+
}
|
|
78
|
+
return new Date(dateStr);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Obtiene la fecha formato YYYY-MM-DD según la zona horaria para comparaciones justas
|
|
83
|
+
*/
|
|
84
|
+
const getTimeZoneDateString = (date) => {
|
|
85
|
+
if (isNaN(date.getTime())) return "";
|
|
86
|
+
const formatter = new Intl.DateTimeFormat('en-CA', {
|
|
87
|
+
timeZone: tenantTimeZone,
|
|
88
|
+
year: 'numeric',
|
|
89
|
+
month: '2-digit',
|
|
90
|
+
day: '2-digit'
|
|
91
|
+
});
|
|
92
|
+
return formatter.format(date);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Devuelve "hace X minutos/horas/días" en español.
|
|
97
|
+
*/
|
|
98
|
+
const relativeTime = (input, watch = false) => {
|
|
99
|
+
if (watch) {
|
|
100
|
+
globalTimeStore.currentTime.value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!input) return "";
|
|
104
|
+
|
|
105
|
+
const then = parseAsUTC(input);
|
|
106
|
+
if (isNaN(then.getTime())) return "";
|
|
107
|
+
|
|
108
|
+
const now = new Date();
|
|
109
|
+
const diffMs = now - then;
|
|
110
|
+
const diffMin = Math.floor(diffMs / 60000);
|
|
111
|
+
|
|
112
|
+
if (diffMin < 1) return "hace unos segundos";
|
|
113
|
+
if (diffMin < 60) return `hace ${diffMin} ${diffMin === 1 ? "minuto" : "minutos"}`;
|
|
114
|
+
|
|
115
|
+
const diffHrs = Math.floor(diffMin / 60);
|
|
116
|
+
if (diffHrs < 24) return `hace ${diffHrs} ${diffHrs === 1 ? "hora" : "horas"}`;
|
|
117
|
+
|
|
118
|
+
const diffDays = Math.floor(diffHrs / 24);
|
|
119
|
+
if (diffDays < 30) return `hace ${diffDays} ${diffDays === 1 ? "día" : "días"}`;
|
|
120
|
+
|
|
121
|
+
const diffMonths = Math.floor(diffDays / 30);
|
|
122
|
+
if (diffMonths < 12) return `hace ${diffMonths} ${diffMonths === 1 ? "mes" : "meses"}`;
|
|
123
|
+
|
|
124
|
+
const diffYears = Math.floor(diffMonths / 12);
|
|
125
|
+
return `hace ${diffYears} ${diffYears === 1 ? "año" : "años"}`;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Formatea la fecha para mostrar asegurando parseo UTC y formateo Santiago.
|
|
130
|
+
*/
|
|
131
|
+
const formatDate = (input, options = {}) => {
|
|
132
|
+
if (!input) return "";
|
|
133
|
+
|
|
134
|
+
const d = parseAsUTC(input);
|
|
135
|
+
if (isNaN(d.getTime())) return input;
|
|
136
|
+
|
|
137
|
+
const formatter = new Intl.DateTimeFormat('es-CL', {
|
|
138
|
+
timeZone: tenantTimeZone,
|
|
139
|
+
year: 'numeric',
|
|
140
|
+
month: '2-digit',
|
|
141
|
+
day: '2-digit',
|
|
142
|
+
hour: '2-digit',
|
|
143
|
+
minute: '2-digit',
|
|
144
|
+
hour12: false
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const parts = formatter.formatToParts(d);
|
|
148
|
+
let day, month, year, hours, minutes;
|
|
149
|
+
|
|
150
|
+
for (const part of parts) {
|
|
151
|
+
if (part.type === 'day') day = part.value;
|
|
152
|
+
if (part.type === 'month') month = part.value;
|
|
153
|
+
if (part.type === 'year') year = part.value;
|
|
154
|
+
if (part.type === 'hour') hours = part.value;
|
|
155
|
+
if (part.type === 'minute') minutes = part.value;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (hours === '24') hours = '00';
|
|
159
|
+
|
|
160
|
+
if (options.onlyDate) return `${day} / ${month} / ${year}`;
|
|
161
|
+
if (options.onlyTime) return `${hours}:${minutes}`;
|
|
162
|
+
|
|
163
|
+
return `${day} / ${month} / ${year} ${hours}:${minutes}`;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const formatDateOnly = (input) => formatDate(input, { onlyDate: true });
|
|
167
|
+
const formatTimeOnly = (input) => formatDate(input, { onlyTime: true });
|
|
168
|
+
|
|
169
|
+
const isToday = (input) => {
|
|
170
|
+
if (!input) return false;
|
|
171
|
+
const date = parseAsUTC(input);
|
|
172
|
+
return getTimeZoneDateString(new Date()) === getTimeZoneDateString(date);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const isYesterday = (input) => {
|
|
176
|
+
if (!input) return false;
|
|
177
|
+
const date = parseAsUTC(input);
|
|
178
|
+
const yesterday = new Date(Date.now() - 86400000);
|
|
179
|
+
return getTimeZoneDateString(yesterday) === getTimeZoneDateString(date);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const formatSmart = (input) => {
|
|
183
|
+
if (!input) return "";
|
|
184
|
+
|
|
185
|
+
if (isToday(input)) {
|
|
186
|
+
return `Hoy a las ${formatTimeOnly(input)}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (isYesterday(input)) {
|
|
190
|
+
return `Ayer a las ${formatTimeOnly(input)}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return formatDate(input);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const getDayName = (input) => {
|
|
197
|
+
if (!input) return "";
|
|
198
|
+
const date = parseAsUTC(input);
|
|
199
|
+
if (isNaN(date.getTime())) return "";
|
|
200
|
+
return new Intl.DateTimeFormat('es-ES', { timeZone: tenantTimeZone, weekday: 'long' }).format(date);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const getMonthName = (input) => {
|
|
204
|
+
if (!input) return "";
|
|
205
|
+
const date = parseAsUTC(input);
|
|
206
|
+
if (isNaN(date.getTime())) return "";
|
|
207
|
+
return new Intl.DateTimeFormat('es-ES', { timeZone: tenantTimeZone, month: 'long' }).format(date);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const daysDiff = (date1, date2 = new Date()) => {
|
|
211
|
+
const d1 = parseAsUTC(date1);
|
|
212
|
+
const d2 = typeof date2 === 'string' ? parseAsUTC(date2) : new Date(date2);
|
|
213
|
+
const diffTime = Math.abs(d2 - d1);
|
|
214
|
+
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const isFuture = (input) => {
|
|
218
|
+
if (!input) return false;
|
|
219
|
+
return parseAsUTC(input) > new Date();
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const isPast = (input) => {
|
|
223
|
+
if (!input) return false;
|
|
224
|
+
return parseAsUTC(input) < new Date();
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
relativeTime,
|
|
229
|
+
formatDate,
|
|
230
|
+
formatDateOnly,
|
|
231
|
+
formatTimeOnly,
|
|
232
|
+
formatSmart,
|
|
233
|
+
isToday,
|
|
234
|
+
isYesterday,
|
|
235
|
+
getDayName,
|
|
236
|
+
getMonthName,
|
|
237
|
+
daysDiff,
|
|
238
|
+
isFuture,
|
|
239
|
+
isPast,
|
|
240
|
+
};
|
|
241
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { onMounted } from 'vue';
|
|
2
|
+
|
|
3
|
+
export const useDevice = () => {
|
|
4
|
+
const getDeviceId = () => {
|
|
5
|
+
if (process.server) return null;
|
|
6
|
+
|
|
7
|
+
let deviceId = localStorage.getItem('app_device_id');
|
|
8
|
+
|
|
9
|
+
if (!deviceId) {
|
|
10
|
+
// Generate a random UUID-like string
|
|
11
|
+
deviceId = 'dev_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
|
12
|
+
localStorage.setItem('app_device_id', deviceId);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return deviceId;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
getDeviceId
|
|
20
|
+
};
|
|
21
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable para manejar los previews minimizados.
|
|
3
|
+
*
|
|
4
|
+
* - `docked` / `dock` / `undock` / `isActive` → Pinia store (persiste en localStorage, sync entre tabs)
|
|
5
|
+
* - `activeDockId` / `activeDockRect` / expand / collapse → estado de UI efímero (no persistido)
|
|
6
|
+
*/
|
|
7
|
+
export function useDockedPreviews() {
|
|
8
|
+
const store = useDockedPreviewsStore()
|
|
9
|
+
|
|
10
|
+
// ─── UI state (no persiste) ───────────────────────────────────────────────────
|
|
11
|
+
const activeDockId = useState('docked-active-id', () => null)
|
|
12
|
+
const activeDockRect = useState('docked-active-rect', () => null)
|
|
13
|
+
|
|
14
|
+
// ─── Acceso reactivo a los items persistidos ──────────────────────────────────
|
|
15
|
+
const docked = computed(() => store.items)
|
|
16
|
+
|
|
17
|
+
// ─── Dock / undock ────────────────────────────────────────────────────────────
|
|
18
|
+
const dock = (payload) => store.add(payload)
|
|
19
|
+
|
|
20
|
+
const undock = (id) => {
|
|
21
|
+
store.remove(id)
|
|
22
|
+
if (activeDockId.value === id) {
|
|
23
|
+
activeDockId.value = null
|
|
24
|
+
activeDockRect.value = null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const isActive = (id) => !!store.items.find(d => d.id === id)
|
|
29
|
+
|
|
30
|
+
// ─── Panel flotante ───────────────────────────────────────────────────────────
|
|
31
|
+
const expandDock = (id, rect = null) => {
|
|
32
|
+
if (activeDockId.value === id) {
|
|
33
|
+
activeDockId.value = null
|
|
34
|
+
activeDockRect.value = null
|
|
35
|
+
} else {
|
|
36
|
+
activeDockId.value = id
|
|
37
|
+
activeDockRect.value = rect ? { left: rect.left, width: rect.width } : null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const collapseDock = () => {
|
|
42
|
+
activeDockId.value = null
|
|
43
|
+
activeDockRect.value = null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
docked,
|
|
48
|
+
activeDockId,
|
|
49
|
+
activeDockRect,
|
|
50
|
+
dock,
|
|
51
|
+
undock,
|
|
52
|
+
isActive,
|
|
53
|
+
expandDock,
|
|
54
|
+
collapseDock,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Descarga un archivo usando XHR con soporte de progreso.
|
|
3
|
+
* Los headers de autenticación se inyectan automáticamente vía useRequestInterceptors.
|
|
4
|
+
*/
|
|
5
|
+
// useRequestInterceptors is auto-imported from nuxt-core composables
|
|
6
|
+
export function useDownload() {
|
|
7
|
+
const config = useRuntimeConfig()
|
|
8
|
+
const baseUrl = config.public.apiBaseUrl
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} url - ruta relativa al baseUrl
|
|
12
|
+
* @param {object} params - query params (GET) o body (POST)
|
|
13
|
+
* @param {object} options - { onProgress, method, headers }
|
|
14
|
+
* `headers` es mezclado DESPUÉS de que corran los interceptores (el caller puede sobreescribir)
|
|
15
|
+
* @returns {Promise<{ blob: Blob, headers: object }>}
|
|
16
|
+
*/
|
|
17
|
+
function serializeParams(obj, prefix = '') {
|
|
18
|
+
const parts = []
|
|
19
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
20
|
+
if (val === null || val === undefined) continue
|
|
21
|
+
const fullKey = prefix ? `${prefix}[${key}]` : key
|
|
22
|
+
if (Array.isArray(val)) {
|
|
23
|
+
val.forEach((item, i) => {
|
|
24
|
+
if (item !== null && typeof item === 'object') {
|
|
25
|
+
parts.push(serializeParams(item, `${fullKey}[${i}]`))
|
|
26
|
+
} else {
|
|
27
|
+
parts.push(`${encodeURIComponent(`${fullKey}[${i}]`)}=${encodeURIComponent(item)}`)
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
} else if (typeof val === 'object') {
|
|
31
|
+
parts.push(serializeParams(val, fullKey))
|
|
32
|
+
} else {
|
|
33
|
+
parts.push(`${encodeURIComponent(fullKey)}=${encodeURIComponent(val)}`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return parts.filter(Boolean).join('&')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function download(url, params = {}, options = {}) {
|
|
40
|
+
const {
|
|
41
|
+
onProgress = null,
|
|
42
|
+
method = 'GET',
|
|
43
|
+
headers: extraHeaders = {},
|
|
44
|
+
} = options
|
|
45
|
+
|
|
46
|
+
// Run all interceptors (auth token, X-Tenant-Id, etc.)
|
|
47
|
+
const { run } = useRequestInterceptors()
|
|
48
|
+
const headers = {}
|
|
49
|
+
run(headers, options)
|
|
50
|
+
// Merge caller-supplied headers last (allow override)
|
|
51
|
+
Object.assign(headers, extraHeaders)
|
|
52
|
+
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const xhr = new XMLHttpRequest()
|
|
55
|
+
let query = ''
|
|
56
|
+
if (method === 'GET' && Object.keys(params).length) {
|
|
57
|
+
const qs = serializeParams(params)
|
|
58
|
+
if (qs) query = '?' + qs
|
|
59
|
+
}
|
|
60
|
+
const cleanUrl = url.startsWith('/') ? url.slice(1) : url
|
|
61
|
+
xhr.open(method, `${baseUrl}/${cleanUrl}${query}`)
|
|
62
|
+
Object.entries(headers).forEach(([k, v]) => xhr.setRequestHeader(k, v))
|
|
63
|
+
xhr.responseType = 'blob'
|
|
64
|
+
xhr.onload = function () {
|
|
65
|
+
const responseHeaders = {}
|
|
66
|
+
xhr.getAllResponseHeaders().split('\r\n').forEach(line => {
|
|
67
|
+
const [key, value] = line.split(': ')
|
|
68
|
+
if (key) responseHeaders[key.toLowerCase()] = value
|
|
69
|
+
})
|
|
70
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
71
|
+
resolve({ blob: xhr.response, headers: responseHeaders })
|
|
72
|
+
} else {
|
|
73
|
+
reject(new Error(`Download failed: ${xhr.status}`))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
xhr.onerror = () => reject(new Error('Network error'))
|
|
77
|
+
xhr.onprogress = (event) => {
|
|
78
|
+
if (onProgress && event.lengthComputable) {
|
|
79
|
+
onProgress(Math.round((event.loaded / event.total) * 100), event)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
xhr.send(method === 'GET' ? null : JSON.stringify(params))
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { download }
|
|
87
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PLANTILLA BASE — copiar y renombrar para cada nueva entidad.
|
|
3
|
+
*
|
|
4
|
+
* Convención de uso en componentes (siempre en setup(), no en callbacks):
|
|
5
|
+
*
|
|
6
|
+
* const { list, detail, create, update, remove } = useEntity()
|
|
7
|
+
*
|
|
8
|
+
* // Query reactiva — re-fetcha automáticamente cuando cambian los params
|
|
9
|
+
* const { data, isLoading } = list(filters)
|
|
10
|
+
*
|
|
11
|
+
* // Detail con id reactivo
|
|
12
|
+
* const { data: entity } = detail(route.params.id)
|
|
13
|
+
*
|
|
14
|
+
* // Mutaciones
|
|
15
|
+
* const { mutate: createEntity, isPending } = create()
|
|
16
|
+
* const { mutate: updateEntity } = update()
|
|
17
|
+
* const { mutate: deleteEntity } = remove()
|
|
18
|
+
*
|
|
19
|
+
* // Ejecutar mutación
|
|
20
|
+
* createEntity({ name: 'Nuevo' })
|
|
21
|
+
* updateEntity({ id: '123', name: 'Editado' })
|
|
22
|
+
* deleteEntity('123')
|
|
23
|
+
*
|
|
24
|
+
* Capas:
|
|
25
|
+
* useEntity → qué datos, cuándo, cómo se invalidan
|
|
26
|
+
* useApi → cómo viaja la petición HTTP (headers, auth, tenant)
|
|
27
|
+
* QueryClient → cache compartido entre componentes
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
// ── Rename: useEntity → use{Entity}, 'entities' → '{entity}s' ───────────────
|
|
31
|
+
|
|
32
|
+
export function useEntity() {
|
|
33
|
+
const api = useApi()
|
|
34
|
+
const queryClient = useQueryClient()
|
|
35
|
+
|
|
36
|
+
// ─── Queries ──────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const list = (params = {}) => useQuery({
|
|
39
|
+
queryKey: computed(() => ['entities', toValue(params)]),
|
|
40
|
+
queryFn: () => api.post('backoffice/entities', toValue(params)),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const detail = (id) => useQuery({
|
|
44
|
+
queryKey: computed(() => ['entities', toValue(id)]),
|
|
45
|
+
queryFn: () => api.get(`backoffice/entities/${toValue(id)}`),
|
|
46
|
+
enabled: computed(() => !!toValue(id)),
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// ─── Mutations ────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['entities'] })
|
|
52
|
+
|
|
53
|
+
const create = () => useMutation({
|
|
54
|
+
mutationFn: (data) => api.post('backoffice/entities', data),
|
|
55
|
+
onSuccess: invalidate,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const update = () => useMutation({
|
|
59
|
+
mutationFn: ({ id, ...data }) => api.put(`backoffice/entities/${id}`, data),
|
|
60
|
+
onSuccess: (_, { id }) => {
|
|
61
|
+
queryClient.invalidateQueries({ queryKey: ['entities', id] })
|
|
62
|
+
invalidate()
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const remove = () => useMutation({
|
|
67
|
+
mutationFn: (id) => api.delete(`backoffice/entities/${id}`),
|
|
68
|
+
onSuccess: invalidate,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// Agregar acciones adicionales siguiendo el mismo patrón:
|
|
72
|
+
//
|
|
73
|
+
// const activate = () => useMutation({
|
|
74
|
+
// mutationFn: (id) => api.post(`backoffice/entities/${id}/activate`),
|
|
75
|
+
// onSuccess: invalidate,
|
|
76
|
+
// })
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
list, detail,
|
|
80
|
+
create, update, remove,
|
|
81
|
+
}
|
|
82
|
+
}
|