@innertia-solutions/ui 0.1.0
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/components/App/EmptyState.vue +433 -0
- package/components/App/Icon.vue +99 -0
- package/components/App/LoadingState.vue +40 -0
- package/components/App/SwitchColorTheme.vue +55 -0
- package/components/Forms/Select.vue +89 -0
- package/components/Modal/DeleteConfirm.vue +163 -0
- package/components/Table/DownloadDropdown.vue +111 -0
- package/components/Table/FilterDropdown.vue +226 -0
- package/components/Toast/Alert.vue +113 -0
- package/components/Toast/Notification.vue +45 -0
- package/components/Toast/Process.vue +88 -0
- package/composables/useDate.js +240 -0
- package/composables/useDevice.js +21 -0
- package/composables/useDownload.js +67 -0
- package/composables/useForm.js +259 -0
- package/composables/useRutFormatter.js +20 -0
- package/composables/useTable.ts +124 -0
- package/composables/useTimeAgo.js +25 -0
- package/composables/useToast.js +69 -0
- package/nuxt.config.ts +8 -0
- package/package.json +22 -0
- package/stores/toast.js +131 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="max-w-xs relative bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl shadow-lg p-4 pr-10 flex items-start overflow-hidden"
|
|
4
|
+
:class="{
|
|
5
|
+
'border-green-200': toast.severity === 'success',
|
|
6
|
+
'border-red-200': toast.severity === 'danger',
|
|
7
|
+
'border-yellow-200': toast.severity === 'warning',
|
|
8
|
+
'border-blue-200': toast.severity === 'info',
|
|
9
|
+
}"
|
|
10
|
+
role="alert"
|
|
11
|
+
>
|
|
12
|
+
<div class="mr-3 mt-1">
|
|
13
|
+
<i v-if="toast.icon" :class="toast.icon + ' text-gray-400 text-xl'" />
|
|
14
|
+
</div>
|
|
15
|
+
<div class="flex-1 mt-1">
|
|
16
|
+
<h3 v-if="toast.title" class="font-semibold text-sm text-gray-800">
|
|
17
|
+
{{ toast.title }}
|
|
18
|
+
</h3>
|
|
19
|
+
<div class="text-sm dark:text-white text-gray-600" v-html="toast.message"></div>
|
|
20
|
+
</div>
|
|
21
|
+
<button
|
|
22
|
+
class="ml-3 text-gray-400 hover:text-gray-700 absolute top-2 right-2"
|
|
23
|
+
@click="$emit('close')"
|
|
24
|
+
>
|
|
25
|
+
<span class="sr-only">Cerrar</span>
|
|
26
|
+
<svg
|
|
27
|
+
class="size-4"
|
|
28
|
+
viewBox="0 0 24 24"
|
|
29
|
+
fill="none"
|
|
30
|
+
stroke="currentColor"
|
|
31
|
+
stroke-width="2"
|
|
32
|
+
stroke-linecap="round"
|
|
33
|
+
stroke-linejoin="round"
|
|
34
|
+
>
|
|
35
|
+
<path d="M18 6 6 18" />
|
|
36
|
+
<path d="m6 6 12 12" />
|
|
37
|
+
</svg>
|
|
38
|
+
</button>
|
|
39
|
+
|
|
40
|
+
<!-- Barra de progreso temporal -->
|
|
41
|
+
<div
|
|
42
|
+
v-if="toast.duration && toast.duration > 0"
|
|
43
|
+
class="absolute bottom-0 left-0 w-full h-1 bg-gray-200 dark:bg-gray-700"
|
|
44
|
+
>
|
|
45
|
+
<div
|
|
46
|
+
class="h-full bg-gradient-to-r"
|
|
47
|
+
:class="{
|
|
48
|
+
'from-green-400 to-green-600': toast.severity === 'success',
|
|
49
|
+
'from-red-400 to-red-600': toast.severity === 'danger',
|
|
50
|
+
'from-yellow-400 to-yellow-600': toast.severity === 'warning',
|
|
51
|
+
'from-blue-400 to-blue-600': toast.severity === 'info',
|
|
52
|
+
}"
|
|
53
|
+
:style="{ width: progressWidth + '%' }"
|
|
54
|
+
></div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<script setup>
|
|
60
|
+
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
|
61
|
+
|
|
62
|
+
const props = defineProps({ toast: Object });
|
|
63
|
+
|
|
64
|
+
const progressWidth = ref(100)
|
|
65
|
+
const startTime = ref(null)
|
|
66
|
+
let animationFrame = null
|
|
67
|
+
|
|
68
|
+
// Computed para detectar cambios en duration
|
|
69
|
+
const duration = computed(() => props.toast?.duration || 0)
|
|
70
|
+
|
|
71
|
+
// Función para actualizar el progreso
|
|
72
|
+
const updateProgress = () => {
|
|
73
|
+
if (!startTime.value || duration.value <= 0) {
|
|
74
|
+
progressWidth.value = 100
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const elapsed = Date.now() - startTime.value
|
|
79
|
+
const remaining = Math.max(0, duration.value - elapsed)
|
|
80
|
+
progressWidth.value = (remaining / duration.value) * 100
|
|
81
|
+
|
|
82
|
+
if (remaining > 0) {
|
|
83
|
+
animationFrame = requestAnimationFrame(updateProgress)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Watch para reiniciar cuando cambie el duration
|
|
88
|
+
watch(duration, (newDuration) => {
|
|
89
|
+
if (animationFrame) {
|
|
90
|
+
cancelAnimationFrame(animationFrame)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (newDuration && newDuration > 0) {
|
|
94
|
+
startTime.value = Date.now()
|
|
95
|
+
updateProgress()
|
|
96
|
+
} else {
|
|
97
|
+
progressWidth.value = 100
|
|
98
|
+
}
|
|
99
|
+
}, { immediate: true })
|
|
100
|
+
|
|
101
|
+
onMounted(() => {
|
|
102
|
+
if (duration.value > 0) {
|
|
103
|
+
startTime.value = Date.now()
|
|
104
|
+
updateProgress()
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
onUnmounted(() => {
|
|
109
|
+
if (animationFrame) {
|
|
110
|
+
cancelAnimationFrame(animationFrame)
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
</script>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="max-w-xs bg-white border border-gray-200 rounded-xl shadow-lg p-4 flex"
|
|
4
|
+
role="alert"
|
|
5
|
+
>
|
|
6
|
+
<div class="shrink-0">
|
|
7
|
+
<svg
|
|
8
|
+
class="size-5 text-gray-600 mt-1"
|
|
9
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
10
|
+
width="24"
|
|
11
|
+
height="24"
|
|
12
|
+
viewBox="0 0 24 24"
|
|
13
|
+
fill="none"
|
|
14
|
+
stroke="currentColor"
|
|
15
|
+
stroke-width="2"
|
|
16
|
+
stroke-linecap="round"
|
|
17
|
+
stroke-linejoin="round"
|
|
18
|
+
>
|
|
19
|
+
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
|
20
|
+
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
|
21
|
+
</svg>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="ms-4">
|
|
24
|
+
<h3 v-if="toast.title" class="text-gray-800 font-semibold">
|
|
25
|
+
{{ toast.title }}
|
|
26
|
+
</h3>
|
|
27
|
+
<div class="mt-1 text-sm text-gray-600" v-html="toast.message"></div>
|
|
28
|
+
<div v-if="toast.actions" class="mt-4">
|
|
29
|
+
<div class="flex gap-x-3">
|
|
30
|
+
<button
|
|
31
|
+
v-for="(action, i) in toast.actions"
|
|
32
|
+
:key="i"
|
|
33
|
+
@click="action.onClick"
|
|
34
|
+
class="text-blue-600 decoration-2 hover:underline font-medium text-sm focus:outline-hidden focus:underline"
|
|
35
|
+
>
|
|
36
|
+
{{ action.label }}
|
|
37
|
+
</button>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</template>
|
|
43
|
+
<script setup>
|
|
44
|
+
defineProps({ toast: Object });
|
|
45
|
+
</script>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="max-w-xs relative bg-white border border-gray-200 rounded-xl shadow-lg"
|
|
4
|
+
role="alert"
|
|
5
|
+
>
|
|
6
|
+
<div class="flex gap-x-3 p-4">
|
|
7
|
+
<div class="shrink-0">
|
|
8
|
+
<span
|
|
9
|
+
class="m-1 inline-flex justify-center items-center size-8 rounded-full bg-gray-100 text-gray-800"
|
|
10
|
+
>
|
|
11
|
+
<svg
|
|
12
|
+
class="shrink-0 size-4"
|
|
13
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
14
|
+
width="24"
|
|
15
|
+
height="24"
|
|
16
|
+
viewBox="0 0 24 24"
|
|
17
|
+
fill="none"
|
|
18
|
+
stroke="currentColor"
|
|
19
|
+
stroke-width="2"
|
|
20
|
+
stroke-linecap="round"
|
|
21
|
+
stroke-linejoin="round"
|
|
22
|
+
>
|
|
23
|
+
<path
|
|
24
|
+
d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"
|
|
25
|
+
/>
|
|
26
|
+
<path d="M12 12v9" />
|
|
27
|
+
<path d="m16 16-4-4-4 4" />
|
|
28
|
+
</svg>
|
|
29
|
+
</span>
|
|
30
|
+
<button
|
|
31
|
+
class="absolute top-3 end-3 inline-flex shrink-0 justify-center items-center size-5 rounded-lg text-gray-800 opacity-50 hover:opacity-100 focus:outline-hidden focus:opacity-100"
|
|
32
|
+
@click="$emit('close')"
|
|
33
|
+
aria-label="Close"
|
|
34
|
+
>
|
|
35
|
+
<span class="sr-only">Close</span>
|
|
36
|
+
<svg
|
|
37
|
+
class="shrink-0 size-4"
|
|
38
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
39
|
+
width="24"
|
|
40
|
+
height="24"
|
|
41
|
+
viewBox="0 0 24 24"
|
|
42
|
+
fill="none"
|
|
43
|
+
stroke="currentColor"
|
|
44
|
+
stroke-width="2"
|
|
45
|
+
stroke-linecap="round"
|
|
46
|
+
stroke-linejoin="round"
|
|
47
|
+
>
|
|
48
|
+
<path d="M18 6 6 18" />
|
|
49
|
+
<path d="m6 6 12 12" />
|
|
50
|
+
</svg>
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="grow me-5">
|
|
54
|
+
<h3 v-if="toast.title" class="text-gray-800 font-medium text-sm">
|
|
55
|
+
{{ toast.title }}
|
|
56
|
+
</h3>
|
|
57
|
+
<div
|
|
58
|
+
v-if="toast.progress !== undefined"
|
|
59
|
+
class="mt-2 flex flex-col gap-x-3"
|
|
60
|
+
>
|
|
61
|
+
<span class="block mb-1.5 text-xs text-gray-500">{{
|
|
62
|
+
toast.progressLabel
|
|
63
|
+
}}</span>
|
|
64
|
+
<div
|
|
65
|
+
class="flex w-full h-1 bg-gray-200 rounded-full overflow-hidden"
|
|
66
|
+
role="progressbar"
|
|
67
|
+
:aria-valuenow="toast.progress"
|
|
68
|
+
aria-valuemin="0"
|
|
69
|
+
aria-valuemax="100"
|
|
70
|
+
>
|
|
71
|
+
<div
|
|
72
|
+
class="flex flex-col justify-center overflow-hidden bg-blue-600 text-xs text-white text-center whitespace-nowrap"
|
|
73
|
+
:style="{ width: toast.progress + '%' }"
|
|
74
|
+
></div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
<div
|
|
78
|
+
v-else
|
|
79
|
+
class="mt-2 text-sm text-gray-600"
|
|
80
|
+
v-html="toast.message"
|
|
81
|
+
></div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</template>
|
|
86
|
+
<script setup>
|
|
87
|
+
defineProps({ toast: Object });
|
|
88
|
+
</script>
|
|
@@ -0,0 +1,240 @@
|
|
|
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
|
+
// Configuración base de la zona horaria del tenant
|
|
50
|
+
const tenantTimeZone = 'America/Santiago';
|
|
51
|
+
|
|
52
|
+
// Suscribirse al timer global cuando se monta el componente
|
|
53
|
+
onMounted(() => {
|
|
54
|
+
globalTimeStore.subscribe();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
onUnmounted(() => {
|
|
58
|
+
globalTimeStore.unsubscribe();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Como la DB de Laravel suele enviar fechas "YYYY-MM-DD HH:mm:ss" sin zona horaria,
|
|
63
|
+
* y siempre están en UTC, forzamos la lectura como UTC si no viene indicada.
|
|
64
|
+
*/
|
|
65
|
+
const parseAsUTC = (input) => {
|
|
66
|
+
if (!input) return new Date("");
|
|
67
|
+
if (input instanceof Date) return input;
|
|
68
|
+
|
|
69
|
+
let dateStr = String(input);
|
|
70
|
+
if (!dateStr.includes('T') && dateStr.includes(' ')) {
|
|
71
|
+
dateStr = dateStr.replace(' ', 'T');
|
|
72
|
+
}
|
|
73
|
+
// Si no termina en Z, ni tiene offset tipo +00:00, agregar Z
|
|
74
|
+
if (!/(Z|[+-]\d{2}(:\d{2})?)$/.test(dateStr)) {
|
|
75
|
+
dateStr += 'Z';
|
|
76
|
+
}
|
|
77
|
+
return new Date(dateStr);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Obtiene la fecha formato YYYY-MM-DD según la zona horaria para comparaciones justas
|
|
82
|
+
*/
|
|
83
|
+
const getTimeZoneDateString = (date) => {
|
|
84
|
+
if (isNaN(date.getTime())) return "";
|
|
85
|
+
const formatter = new Intl.DateTimeFormat('en-CA', {
|
|
86
|
+
timeZone: tenantTimeZone,
|
|
87
|
+
year: 'numeric',
|
|
88
|
+
month: '2-digit',
|
|
89
|
+
day: '2-digit'
|
|
90
|
+
});
|
|
91
|
+
return formatter.format(date);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Devuelve "hace X minutos/horas/días" en español.
|
|
96
|
+
*/
|
|
97
|
+
const relativeTime = (input, watch = false) => {
|
|
98
|
+
if (watch) {
|
|
99
|
+
globalTimeStore.currentTime.value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!input) return "";
|
|
103
|
+
|
|
104
|
+
const then = parseAsUTC(input);
|
|
105
|
+
if (isNaN(then.getTime())) return "";
|
|
106
|
+
|
|
107
|
+
const now = new Date();
|
|
108
|
+
const diffMs = now - then;
|
|
109
|
+
const diffMin = Math.floor(diffMs / 60000);
|
|
110
|
+
|
|
111
|
+
if (diffMin < 1) return "hace unos segundos";
|
|
112
|
+
if (diffMin < 60) return `hace ${diffMin} ${diffMin === 1 ? "minuto" : "minutos"}`;
|
|
113
|
+
|
|
114
|
+
const diffHrs = Math.floor(diffMin / 60);
|
|
115
|
+
if (diffHrs < 24) return `hace ${diffHrs} ${diffHrs === 1 ? "hora" : "horas"}`;
|
|
116
|
+
|
|
117
|
+
const diffDays = Math.floor(diffHrs / 24);
|
|
118
|
+
if (diffDays < 30) return `hace ${diffDays} ${diffDays === 1 ? "día" : "días"}`;
|
|
119
|
+
|
|
120
|
+
const diffMonths = Math.floor(diffDays / 30);
|
|
121
|
+
if (diffMonths < 12) return `hace ${diffMonths} ${diffMonths === 1 ? "mes" : "meses"}`;
|
|
122
|
+
|
|
123
|
+
const diffYears = Math.floor(diffMonths / 12);
|
|
124
|
+
return `hace ${diffYears} ${diffYears === 1 ? "año" : "años"}`;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Formatea la fecha para mostrar asegurando parseo UTC y formateo Santiago.
|
|
129
|
+
*/
|
|
130
|
+
const formatDate = (input, options = {}) => {
|
|
131
|
+
if (!input) return "";
|
|
132
|
+
|
|
133
|
+
const d = parseAsUTC(input);
|
|
134
|
+
if (isNaN(d.getTime())) return input;
|
|
135
|
+
|
|
136
|
+
const formatter = new Intl.DateTimeFormat('es-CL', {
|
|
137
|
+
timeZone: tenantTimeZone,
|
|
138
|
+
year: 'numeric',
|
|
139
|
+
month: '2-digit',
|
|
140
|
+
day: '2-digit',
|
|
141
|
+
hour: '2-digit',
|
|
142
|
+
minute: '2-digit',
|
|
143
|
+
hour12: false
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const parts = formatter.formatToParts(d);
|
|
147
|
+
let day, month, year, hours, minutes;
|
|
148
|
+
|
|
149
|
+
for (const part of parts) {
|
|
150
|
+
if (part.type === 'day') day = part.value;
|
|
151
|
+
if (part.type === 'month') month = part.value;
|
|
152
|
+
if (part.type === 'year') year = part.value;
|
|
153
|
+
if (part.type === 'hour') hours = part.value;
|
|
154
|
+
if (part.type === 'minute') minutes = part.value;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (hours === '24') hours = '00';
|
|
158
|
+
|
|
159
|
+
if (options.onlyDate) return `${day} / ${month} / ${year}`;
|
|
160
|
+
if (options.onlyTime) return `${hours}:${minutes}`;
|
|
161
|
+
|
|
162
|
+
return `${day} / ${month} / ${year} ${hours}:${minutes}`;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const formatDateOnly = (input) => formatDate(input, { onlyDate: true });
|
|
166
|
+
const formatTimeOnly = (input) => formatDate(input, { onlyTime: true });
|
|
167
|
+
|
|
168
|
+
const isToday = (input) => {
|
|
169
|
+
if (!input) return false;
|
|
170
|
+
const date = parseAsUTC(input);
|
|
171
|
+
return getTimeZoneDateString(new Date()) === getTimeZoneDateString(date);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const isYesterday = (input) => {
|
|
175
|
+
if (!input) return false;
|
|
176
|
+
const date = parseAsUTC(input);
|
|
177
|
+
const yesterday = new Date(Date.now() - 86400000);
|
|
178
|
+
return getTimeZoneDateString(yesterday) === getTimeZoneDateString(date);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const formatSmart = (input) => {
|
|
182
|
+
if (!input) return "";
|
|
183
|
+
|
|
184
|
+
if (isToday(input)) {
|
|
185
|
+
return `Hoy a las ${formatTimeOnly(input)}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (isYesterday(input)) {
|
|
189
|
+
return `Ayer a las ${formatTimeOnly(input)}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return formatDate(input);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const getDayName = (input) => {
|
|
196
|
+
if (!input) return "";
|
|
197
|
+
const date = parseAsUTC(input);
|
|
198
|
+
if (isNaN(date.getTime())) return "";
|
|
199
|
+
return new Intl.DateTimeFormat('es-ES', { timeZone: tenantTimeZone, weekday: 'long' }).format(date);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const getMonthName = (input) => {
|
|
203
|
+
if (!input) return "";
|
|
204
|
+
const date = parseAsUTC(input);
|
|
205
|
+
if (isNaN(date.getTime())) return "";
|
|
206
|
+
return new Intl.DateTimeFormat('es-ES', { timeZone: tenantTimeZone, month: 'long' }).format(date);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const daysDiff = (date1, date2 = new Date()) => {
|
|
210
|
+
const d1 = parseAsUTC(date1);
|
|
211
|
+
const d2 = typeof date2 === 'string' ? parseAsUTC(date2) : new Date(date2);
|
|
212
|
+
const diffTime = Math.abs(d2 - d1);
|
|
213
|
+
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const isFuture = (input) => {
|
|
217
|
+
if (!input) return false;
|
|
218
|
+
return parseAsUTC(input) > new Date();
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const isPast = (input) => {
|
|
222
|
+
if (!input) return false;
|
|
223
|
+
return parseAsUTC(input) < new Date();
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
relativeTime,
|
|
228
|
+
formatDate,
|
|
229
|
+
formatDateOnly,
|
|
230
|
+
formatTimeOnly,
|
|
231
|
+
formatSmart,
|
|
232
|
+
isToday,
|
|
233
|
+
isYesterday,
|
|
234
|
+
getDayName,
|
|
235
|
+
getMonthName,
|
|
236
|
+
daysDiff,
|
|
237
|
+
isFuture,
|
|
238
|
+
isPast,
|
|
239
|
+
};
|
|
240
|
+
};
|
|
@@ -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,67 @@
|
|
|
1
|
+
// app/composables/useDownload.js
|
|
2
|
+
import { useAuthStore } from '@/stores/auth'
|
|
3
|
+
|
|
4
|
+
export function useDownload() {
|
|
5
|
+
const auth = useAuthStore()
|
|
6
|
+
const config = useRuntimeConfig()
|
|
7
|
+
const baseUrl = config.public.apiBaseUrl
|
|
8
|
+
|
|
9
|
+
function buildHeaders(useToken = true) {
|
|
10
|
+
const headers = {}
|
|
11
|
+
if (useToken && auth.getToken()) {
|
|
12
|
+
headers['Authorization'] = `Bearer ${auth.getToken()}`
|
|
13
|
+
}
|
|
14
|
+
return headers
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Descarga un archivo usando XHR para obtener progreso y headers
|
|
19
|
+
* @param {string} url
|
|
20
|
+
* @param {object} params
|
|
21
|
+
* @param {object} options { onProgress, useToken, method, headers }
|
|
22
|
+
* @returns {Promise<{ blob: Blob, headers: object }>}
|
|
23
|
+
*/
|
|
24
|
+
function download(url, params = {}, options = {}) {
|
|
25
|
+
const {
|
|
26
|
+
onProgress = null,
|
|
27
|
+
useToken = true,
|
|
28
|
+
method = 'GET',
|
|
29
|
+
headers = {},
|
|
30
|
+
} = options
|
|
31
|
+
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const xhr = new XMLHttpRequest()
|
|
34
|
+
let query = ''
|
|
35
|
+
if (method === 'GET' && Object.keys(params).length) {
|
|
36
|
+
query = '?' + new URLSearchParams(params).toString()
|
|
37
|
+
}
|
|
38
|
+
xhr.open(method, `${baseUrl}/${url}${query}`)
|
|
39
|
+
const allHeaders = { ...buildHeaders(useToken), ...headers }
|
|
40
|
+
Object.entries(allHeaders).forEach(([k, v]) => xhr.setRequestHeader(k, v))
|
|
41
|
+
xhr.responseType = 'blob'
|
|
42
|
+
xhr.onload = function () {
|
|
43
|
+
const responseHeaders = {}
|
|
44
|
+
xhr.getAllResponseHeaders().split('\r\n').forEach(line => {
|
|
45
|
+
const [key, value] = line.split(': ')
|
|
46
|
+
if (key) responseHeaders[key.toLowerCase()] = value
|
|
47
|
+
})
|
|
48
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
49
|
+
resolve({ blob: xhr.response, headers: responseHeaders })
|
|
50
|
+
} else {
|
|
51
|
+
reject(new Error('Download failed'))
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
xhr.onerror = function () {
|
|
55
|
+
reject(new Error('Network error'))
|
|
56
|
+
}
|
|
57
|
+
xhr.onprogress = function (event) {
|
|
58
|
+
if (onProgress && event.lengthComputable) {
|
|
59
|
+
onProgress(Math.round((event.loaded / event.total) * 100), event)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
xhr.send(method === 'GET' ? null : JSON.stringify(params))
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { download }
|
|
67
|
+
}
|