@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,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
+ }