manejo offline de los datos
This commit is contained in:
245
nuxt4-app/app.html
Normal file
245
nuxt4-app/app.html
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html {{ HTML_ATTRS }} style="background: #1b1209;">
|
||||||
|
<head {{ HEAD_ATTRS }}>
|
||||||
|
<!-- Critical styles - must load FIRST to prevent white flash -->
|
||||||
|
<style>
|
||||||
|
/* Critical: Load background immediately */
|
||||||
|
html, body {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
background: #1b1209 !important;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show loading screen immediately */
|
||||||
|
#nuxt-loading-screen {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #1b1209 0%, #2a1a0f 100%);
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 99999;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{ HEAD }}
|
||||||
|
<style>
|
||||||
|
/* Ultra-lightweight loading screen - loads instantly */
|
||||||
|
#nuxt-loading-screen {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #1b1209 0%, #2a1a0f 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nuxt-loading-screen.fade-out {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-logo {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid rgba(255, 224, 160, 0.4);
|
||||||
|
background: radial-gradient(circle, #c08040 0%, #8b5a2b 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
border-top-color: #ffe0a0;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(192, 128, 64, 0.7); }
|
||||||
|
50% { transform: scale(1.05); box-shadow: 0 0 20px 10px rgba(192, 128, 64, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffe0a0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #c08040;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-progress {
|
||||||
|
width: 300px;
|
||||||
|
max-width: 80%;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(192, 128, 64, 0.2);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #c08040 0%, #ffe0a0 100%);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s ease-out;
|
||||||
|
width: 0%;
|
||||||
|
box-shadow: 0 0 10px rgba(255, 224, 160, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-status {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8b7355;
|
||||||
|
text-align: center;
|
||||||
|
min-height: 20px;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots::after {
|
||||||
|
content: '';
|
||||||
|
animation: dots 1.5s steps(4, end) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dots {
|
||||||
|
0%, 20% { content: '.'; }
|
||||||
|
40% { content: '..'; }
|
||||||
|
60%, 100% { content: '...'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the loading screen when Nuxt is ready */
|
||||||
|
html.nuxt-ready #nuxt-loading-screen {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body {{ BODY_ATTRS }} style="background: #1b1209; margin: 0; padding: 0;">
|
||||||
|
<!-- Ultra-lightweight loading screen -->
|
||||||
|
<div id="nuxt-loading-screen">
|
||||||
|
<div class="loading-logo">
|
||||||
|
<div class="loading-icon"></div>
|
||||||
|
</div>
|
||||||
|
<div class="loading-title">Analítica Núcleo</div>
|
||||||
|
<div class="loading-subtitle">Data Studio</div>
|
||||||
|
<div class="loading-progress">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" id="loading-progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<div class="loading-status">
|
||||||
|
<span id="loading-status-text" class="loading-dots">Iniciando aplicación</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ APP }}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Ultra-lightweight progress simulator
|
||||||
|
(function() {
|
||||||
|
const progressFill = document.getElementById('loading-progress-fill');
|
||||||
|
const statusText = document.getElementById('loading-status-text');
|
||||||
|
const loadingScreen = document.getElementById('nuxt-loading-screen');
|
||||||
|
|
||||||
|
const stages = [
|
||||||
|
{ progress: 20, text: 'Cargando recursos', delay: 100 },
|
||||||
|
{ progress: 35, text: 'Inicializando Vue', delay: 300 },
|
||||||
|
{ progress: 50, text: 'Cargando componentes', delay: 500 },
|
||||||
|
{ progress: 65, text: 'Configurando rutas', delay: 700 },
|
||||||
|
{ progress: 80, text: 'Preparando datos', delay: 900 },
|
||||||
|
{ progress: 95, text: 'Finalizando', delay: 1200 }
|
||||||
|
];
|
||||||
|
|
||||||
|
let currentStage = 0;
|
||||||
|
|
||||||
|
function updateProgress() {
|
||||||
|
if (currentStage < stages.length) {
|
||||||
|
const stage = stages[currentStage];
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (progressFill && statusText) {
|
||||||
|
progressFill.style.width = stage.progress + '%';
|
||||||
|
statusText.textContent = stage.text;
|
||||||
|
statusText.className = 'loading-dots';
|
||||||
|
}
|
||||||
|
currentStage++;
|
||||||
|
|
||||||
|
// Check if app is ready
|
||||||
|
if (document.documentElement.classList.contains('nuxt-ready')) {
|
||||||
|
completeLoading();
|
||||||
|
} else {
|
||||||
|
updateProgress();
|
||||||
|
}
|
||||||
|
}, stage.delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeLoading() {
|
||||||
|
if (progressFill && statusText && loadingScreen) {
|
||||||
|
progressFill.style.width = '100%';
|
||||||
|
statusText.textContent = '¡Listo!';
|
||||||
|
statusText.className = '';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
loadingScreen.classList.add('fade-out');
|
||||||
|
setTimeout(() => {
|
||||||
|
loadingScreen.style.display = 'none';
|
||||||
|
}, 300);
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start progress
|
||||||
|
updateProgress();
|
||||||
|
|
||||||
|
// Fallback: hide loading screen after 5 seconds max
|
||||||
|
setTimeout(() => {
|
||||||
|
document.documentElement.classList.add('nuxt-ready');
|
||||||
|
completeLoading();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Listen for when the app is actually ready
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
document.documentElement.classList.add('nuxt-ready');
|
||||||
|
completeLoading();
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -8,3 +8,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</UApp>
|
</UApp>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Signal that the app is ready
|
||||||
|
onMounted(() => {
|
||||||
|
// Add class to HTML element to hide loading screen
|
||||||
|
if (process.client) {
|
||||||
|
// Small delay to ensure everything is painted
|
||||||
|
setTimeout(() => {
|
||||||
|
document.documentElement.classList.add('nuxt-ready')
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -12,10 +12,14 @@
|
|||||||
--brand-text-muted: #d8c7a6;
|
--brand-text-muted: #d8c7a6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Critical: Prevent white flash on load */
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
background-color: var(--brand-bg);
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
background-color: #1b1209 !important;
|
||||||
color: var(--brand-text);
|
color: var(--brand-text);
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-shell {
|
.brand-shell {
|
||||||
|
|||||||
@@ -10,25 +10,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Table Selection Field Group -->
|
<!-- Table Selection and Refresh Controls -->
|
||||||
<UFieldGroup>
|
<div class="flex flex-col gap-4">
|
||||||
<UButton
|
<!-- Table Selector -->
|
||||||
:label="selectedTable ? selectedTable.label : 'Seleccionar tabla'"
|
<UFieldGroup>
|
||||||
:icon="selectedTable ? 'i-lucide-table' : 'i-lucide-loader-circle'"
|
|
||||||
color="neutral"
|
|
||||||
variant="subtle"
|
|
||||||
:loading="metadataStore.loading && !metadataStore.hasMetadata"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UDropdownMenu :items="tableDropdownItems" :loading="metadataStore.loading && !metadataStore.hasMetadata">
|
|
||||||
<UButton
|
<UButton
|
||||||
|
:label="selectedTable ? selectedTable.label : 'Seleccionar tabla'"
|
||||||
|
:icon="selectedTable ? 'i-lucide-table' : 'i-lucide-loader-circle'"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="outline"
|
variant="subtle"
|
||||||
icon="i-lucide-chevron-down"
|
:loading="metadataStore.loading && !metadataStore.hasMetadata"
|
||||||
:disabled="metadataStore.loading && !metadataStore.hasMetadata"
|
|
||||||
/>
|
/>
|
||||||
</UDropdownMenu>
|
|
||||||
</UFieldGroup>
|
<UDropdownMenu :items="tableDropdownItems" :loading="metadataStore.loading && !metadataStore.hasMetadata">
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-chevron-down"
|
||||||
|
:disabled="metadataStore.loading && !metadataStore.hasMetadata"
|
||||||
|
/>
|
||||||
|
</UDropdownMenu>
|
||||||
|
</UFieldGroup>
|
||||||
|
|
||||||
|
<!-- Refresh Controls (only shown when table is selected) -->
|
||||||
|
<div v-if="selectedTable && currentTableStore" class="flex items-center justify-between p-3 rounded-lg bg-[#1c140c] border border-[#3a2a16]">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs font-medium text-[var(--brand-text-muted)]">
|
||||||
|
Última actualización: {{ currentTableStore.formattedLastUpdated }}
|
||||||
|
</span>
|
||||||
|
<span v-if="currentTableStore.isStale" class="text-xs text-yellow-400">
|
||||||
|
⚠️ Los datos pueden estar desactualizados
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
:loading="currentTableStore.isLoading"
|
||||||
|
:ui="{ base: 'bg-[#c08040] text-[#1b1209] border border-[#d99a56] hover:bg-[#d99a56] hover:border-[#f0c07c]' }"
|
||||||
|
size="sm"
|
||||||
|
@click="refreshTableData"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-lucide-refresh-cw" :class="{ 'animate-spin': currentTableStore.isLoading }" />
|
||||||
|
</template>
|
||||||
|
Actualizar datos
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
@@ -43,7 +70,7 @@
|
|||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
<!-- Table Content -->
|
<!-- Table Content -->
|
||||||
<UCard v-else-if="selectedTable" class="brand-card border border-transparent">
|
<UCard v-else-if="selectedTable && currentTableStore" class="brand-card border border-transparent">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h2 class="text-lg font-semibold brand-section-title">
|
<h2 class="text-lg font-semibold brand-section-title">
|
||||||
@@ -51,20 +78,37 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<div class="flex flex-wrap gap-2 text-xs text-[var(--brand-text-muted)]">
|
<div class="flex flex-wrap gap-2 text-xs text-[var(--brand-text-muted)]">
|
||||||
<span class="brand-pill inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs">
|
<span class="brand-pill inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs">
|
||||||
{{ selectedTable.rowCount || 0 }} registros
|
{{ currentTableStore.recordCount }} registros cargados
|
||||||
|
</span>
|
||||||
|
<span v-if="selectedTable.rowCount" class="brand-pill inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs">
|
||||||
|
{{ formatNumber(selectedTable.rowCount) }} total en BD
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Loading State for Table Data -->
|
<!-- Loading State for Table Data -->
|
||||||
<div v-if="tableDataLoading" class="flex items-center justify-center gap-3 py-10 text-[var(--brand-text-muted)]">
|
<div v-if="currentTableStore.isLoading && !currentTableStore.hasData" class="flex items-center justify-center gap-3 py-10 text-[var(--brand-text-muted)]">
|
||||||
<span class="inline-flex h-8 w-8 animate-spin rounded-full border-2 border-[#c08040] border-t-transparent align-middle" aria-hidden="true" />
|
<span class="inline-flex h-8 w-8 animate-spin rounded-full border-2 border-[#c08040] border-t-transparent align-middle" aria-hidden="true" />
|
||||||
<span class="text-sm uppercase tracking-[0.3em]">Cargando datos...</span>
|
<span class="text-sm uppercase tracking-[0.3em]">Cargando datos...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div v-else-if="currentTableStore.hasError" class="py-10 text-center">
|
||||||
|
<div class="rounded-lg border border-red-500/40 bg-red-500/18 p-4 text-sm text-red-200 max-w-md mx-auto">
|
||||||
|
{{ currentTableStore.error }}
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
class="mt-4"
|
||||||
|
:ui="{ base: 'bg-[#c08040] text-[#1b1209] border border-[#d99a56] hover:bg-[#d99a56] hover:border-[#f0c07c]' }"
|
||||||
|
@click="refreshTableData"
|
||||||
|
>
|
||||||
|
Reintentar
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- NuxtUI Table -->
|
<!-- NuxtUI Table -->
|
||||||
<div v-else-if="tableData.length > 0" class="flex-1 divide-y divide-accented w-full">
|
<div v-else-if="currentTableStore.hasData" class="flex-1 divide-y divide-accented w-full">
|
||||||
<!-- Table Controls -->
|
<!-- Table Controls -->
|
||||||
<div class="flex items-center gap-2 px-4 py-3.5 overflow-x-auto">
|
<div class="flex items-center gap-2 px-4 py-3.5 overflow-x-auto">
|
||||||
<UInput
|
<UInput
|
||||||
@@ -94,7 +138,7 @@
|
|||||||
<!-- Table Component -->
|
<!-- Table Component -->
|
||||||
<UTable
|
<UTable
|
||||||
ref="table"
|
ref="table"
|
||||||
:data="tableData"
|
:data="currentTableStore.allRecords"
|
||||||
:columns="tableColumns"
|
:columns="tableColumns"
|
||||||
:global-filter="globalFilter"
|
:global-filter="globalFilter"
|
||||||
sticky
|
sticky
|
||||||
@@ -112,7 +156,21 @@
|
|||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-else class="py-10 text-center text-sm text-[var(--brand-text-muted)]">
|
<div v-else class="py-10 text-center text-sm text-[var(--brand-text-muted)]">
|
||||||
No se encontraron datos en esta tabla.
|
<UIcon name="i-lucide-inbox" class="mx-auto mb-4 size-12 text-[var(--brand-text-muted)]" />
|
||||||
|
<h3 class="text-lg font-semibold text-[var(--brand-text)] mb-2">No hay datos disponibles</h3>
|
||||||
|
<p class="text-sm text-[var(--brand-text-muted)] mb-4">
|
||||||
|
Haz clic en "Actualizar datos" para cargar la información de esta tabla.
|
||||||
|
</p>
|
||||||
|
<UButton
|
||||||
|
:loading="currentTableStore.isLoading"
|
||||||
|
:ui="{ base: 'bg-[#c08040] text-[#1b1209] border border-[#d99a56] hover:bg-[#d99a56] hover:border-[#f0c07c]' }"
|
||||||
|
@click="refreshTableData"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-lucide-refresh-cw" />
|
||||||
|
</template>
|
||||||
|
Cargar datos
|
||||||
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
@@ -129,8 +187,8 @@
|
|||||||
import { h, resolveComponent } from 'vue'
|
import { h, resolveComponent } from 'vue'
|
||||||
import { upperFirst } from 'scule'
|
import { upperFirst } from 'scule'
|
||||||
import type { TableColumn, DropdownMenuItem } from '@nuxt/ui'
|
import type { TableColumn, DropdownMenuItem } from '@nuxt/ui'
|
||||||
import { useRequestFetch } from '#imports'
|
|
||||||
import { useMetadataStore } from '~/stores/metadata'
|
import { useMetadataStore } from '~/stores/metadata'
|
||||||
|
import { useTableDataStore } from '~/stores/tableDataFactory'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'dashboard',
|
layout: 'dashboard',
|
||||||
@@ -141,13 +199,12 @@ const UButton = resolveComponent('UButton')
|
|||||||
|
|
||||||
// State
|
// State
|
||||||
const selectedTableName = ref<string>('')
|
const selectedTableName = ref<string>('')
|
||||||
const tableData = ref<Record<string, unknown>[]>([])
|
|
||||||
const tableDataLoading = ref(false)
|
|
||||||
const globalFilter = ref('')
|
const globalFilter = ref('')
|
||||||
|
const currentTableStore = ref<ReturnType<typeof useTableDataStore> | null>(null)
|
||||||
|
|
||||||
const requestFetch = useRequestFetch()
|
|
||||||
const table = useTemplateRef('table')
|
const table = useTemplateRef('table')
|
||||||
const metadataStore = useMetadataStore()
|
const metadataStore = useMetadataStore()
|
||||||
|
const { $getTableStore } = useNuxtApp()
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const selectedTable = computed(() => {
|
const selectedTable = computed(() => {
|
||||||
@@ -189,9 +246,11 @@ const tableDropdownItems = computed((): DropdownMenuItem[] => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const tableColumns = computed((): TableColumn<Record<string, unknown>>[] => {
|
const tableColumns = computed((): TableColumn<Record<string, unknown>>[] => {
|
||||||
if (!tableData.value.length) return []
|
if (!currentTableStore.value?.hasData) return []
|
||||||
|
|
||||||
|
const firstRow = currentTableStore.value.allRecords[0]
|
||||||
|
if (!firstRow) return []
|
||||||
|
|
||||||
const firstRow = tableData.value[0]
|
|
||||||
const columns = Object.keys(firstRow)
|
const columns = Object.keys(firstRow)
|
||||||
|
|
||||||
return columns.map(column => ({
|
return columns.map(column => ({
|
||||||
@@ -236,42 +295,33 @@ const filteredRowCount = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const totalRowCount = computed(() => {
|
const totalRowCount = computed(() => {
|
||||||
return tableData.value.length
|
return currentTableStore.value?.recordCount || 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
|
function selectTable(tableName: string) {
|
||||||
async function selectTable(tableName: string) {
|
|
||||||
if (selectedTableName.value === tableName) return
|
if (selectedTableName.value === tableName) return
|
||||||
|
|
||||||
selectedTableName.value = tableName
|
selectedTableName.value = tableName
|
||||||
await loadTableData(tableName)
|
|
||||||
|
// Get the table store using the plugin
|
||||||
|
if (typeof $getTableStore === 'function') {
|
||||||
|
const store = $getTableStore(tableName)
|
||||||
|
if (store) {
|
||||||
|
currentTableStore.value = store
|
||||||
|
// Initialize the store (loads from cache or fetches)
|
||||||
|
store.initialize()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: create store directly
|
||||||
|
currentTableStore.value = useTableDataStore(tableName)
|
||||||
|
currentTableStore.value.initialize()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTableData(tableName: string) {
|
async function refreshTableData() {
|
||||||
try {
|
if (currentTableStore.value) {
|
||||||
tableDataLoading.value = true
|
await currentTableStore.value.refreshData()
|
||||||
|
|
||||||
const response = await requestFetch(`/api/data/${tableName}`, {
|
|
||||||
query: { limit: '100' }
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response && typeof response === 'object' && 'records' in response) {
|
|
||||||
const dataset = response as {
|
|
||||||
records?: Record<string, unknown>[]
|
|
||||||
}
|
|
||||||
tableData.value = Array.isArray(dataset.records) ? dataset.records : []
|
|
||||||
} else if (Array.isArray(response)) {
|
|
||||||
tableData.value = response
|
|
||||||
} else {
|
|
||||||
tableData.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading table data:', error)
|
|
||||||
tableData.value = []
|
|
||||||
} finally {
|
|
||||||
tableDataLoading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +329,10 @@ function updateGlobalFilter(value: string) {
|
|||||||
globalFilter.value = value
|
globalFilter.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number): string {
|
||||||
|
return new Intl.NumberFormat('es-ES').format(value)
|
||||||
|
}
|
||||||
|
|
||||||
function formatCellValue(value: unknown): string {
|
function formatCellValue(value: unknown): string {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return '—'
|
return '—'
|
||||||
@@ -306,22 +360,6 @@ function formatCellValue(value: unknown): string {
|
|||||||
return stringValue
|
return stringValue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watchers
|
|
||||||
watch(selectedTableName, async (newTableName) => {
|
|
||||||
if (newTableName) {
|
|
||||||
await loadTableData(newTableName)
|
|
||||||
} else {
|
|
||||||
tableData.value = []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Auto-select first table when metadata becomes available
|
|
||||||
watch(() => metadataStore.hasMetadata, (hasMetadata) => {
|
|
||||||
if (hasMetadata && !selectedTableName.value && metadataStore.allTables.length > 0) {
|
|
||||||
selectedTableName.value = metadataStore.allTables[0].table
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await metadataStore.initialize()
|
await metadataStore.initialize()
|
||||||
@@ -330,8 +368,15 @@ onMounted(async () => {
|
|||||||
if (metadataStore.hasMetadata && !selectedTableName.value) {
|
if (metadataStore.hasMetadata && !selectedTableName.value) {
|
||||||
const firstTable = metadataStore.allTables[0]
|
const firstTable = metadataStore.allTables[0]
|
||||||
if (firstTable) {
|
if (firstTable) {
|
||||||
selectedTableName.value = firstTable.table
|
selectTable(firstTable.table)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Auto-select first table when metadata becomes available
|
||||||
|
watch(() => metadataStore.hasMetadata, (hasMetadata) => {
|
||||||
|
if (hasMetadata && !selectedTableName.value && metadataStore.allTables.length > 0) {
|
||||||
|
selectTable(metadataStore.allTables[0].table)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
794
nuxt4-app/app/stores/README.md
Normal file
794
nuxt4-app/app/stores/README.md
Normal file
@@ -0,0 +1,794 @@
|
|||||||
|
# 📚 Sistema de Stores - Analítica Núcleo
|
||||||
|
|
||||||
|
Este directorio contiene el sistema de gestión de estado de la aplicación usando **Pinia** y **localStorage** para caché persistente.
|
||||||
|
|
||||||
|
## 📋 Índice
|
||||||
|
|
||||||
|
- [Arquitectura General](#-arquitectura-general)
|
||||||
|
- [Stores Disponibles](#-stores-disponibles)
|
||||||
|
- [Metadata Store](#-metadata-store)
|
||||||
|
- [Table Data Factory](#-table-data-factory)
|
||||||
|
- [Flujo de Datos](#-flujo-de-datos)
|
||||||
|
- [Uso en Componentes](#-uso-en-componentes)
|
||||||
|
- [API Reference](#-api-reference)
|
||||||
|
- [Cache y Persistencia](#-cache-y-persistencia)
|
||||||
|
- [Ejemplos Avanzados](#-ejemplos-avanzados)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Arquitectura General
|
||||||
|
|
||||||
|
El sistema de stores se compone de dos partes principales:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/stores/
|
||||||
|
├── metadata.ts # Store de metadatos de tablas
|
||||||
|
├── tableDataFactory.ts # Factory para crear stores de datos
|
||||||
|
└── README.md # Este archivo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flujo de Datos
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
A[App Startup] --> B[Metadata Store]
|
||||||
|
B --> C{¿Tiene cache?}
|
||||||
|
C -->|Sí| D[Carga desde localStorage]
|
||||||
|
C -->|No| E[Fetch desde API]
|
||||||
|
E --> F[/api/metadata]
|
||||||
|
F --> G[Guarda en localStorage]
|
||||||
|
D --> H[Metadata disponible]
|
||||||
|
G --> H
|
||||||
|
|
||||||
|
H --> I[Plugin Auto-registro]
|
||||||
|
I --> J[Crea stores por tabla]
|
||||||
|
|
||||||
|
K[Usuario selecciona tabla] --> L{¿Store existe?}
|
||||||
|
L -->|Sí| M{¿Tiene cache?}
|
||||||
|
L -->|No| N[Crea store]
|
||||||
|
N --> M
|
||||||
|
M -->|Sí| O[Carga desde cache]
|
||||||
|
M -->|No| P[Fetch desde API]
|
||||||
|
P --> Q[/api/data/:table]
|
||||||
|
Q --> R[Guarda en cache]
|
||||||
|
O --> S[Datos disponibles]
|
||||||
|
R --> S
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗂️ Stores Disponibles
|
||||||
|
|
||||||
|
### 1. **Metadata Store** (`metadata.ts`)
|
||||||
|
|
||||||
|
Store global que gestiona la información de todas las tablas disponibles.
|
||||||
|
|
||||||
|
**Responsabilidades:**
|
||||||
|
- Mantener lista de tablas disponibles
|
||||||
|
- Información estructural (columnas, claves primarias, etc.)
|
||||||
|
- Estadísticas de cada tabla
|
||||||
|
- Cache persistente de metadatos
|
||||||
|
|
||||||
|
### 2. **Table Data Stores** (Factory pattern)
|
||||||
|
|
||||||
|
Stores dinámicos, uno por cada tabla de la base de datos.
|
||||||
|
|
||||||
|
**Responsabilidades:**
|
||||||
|
- Almacenar registros de una tabla específica
|
||||||
|
- Gestionar estado de carga
|
||||||
|
- Cache persistente por tabla
|
||||||
|
- Operaciones de filtrado y búsqueda
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Metadata Store
|
||||||
|
|
||||||
|
### Estructura de Datos
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TableMetadata {
|
||||||
|
table: string // Nombre de la tabla
|
||||||
|
rowCount: number // Total de registros
|
||||||
|
primaryKey: string // Clave primaria
|
||||||
|
approxSizeBytes: number // Tamaño aproximado en bytes
|
||||||
|
columns: string[] // Lista de columnas
|
||||||
|
createdAtRange?: {
|
||||||
|
from: string // Fecha de creación más antigua
|
||||||
|
to: string // Fecha de creación más reciente
|
||||||
|
}
|
||||||
|
lastRefreshed?: string // Última actualización de metadatos
|
||||||
|
sampleRow?: Record<string, unknown> // Registro de ejemplo
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estado del Store
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
metadata: TableMetadata[] // Array de metadatos por tabla
|
||||||
|
loading: boolean // Estado de carga
|
||||||
|
error: string | null // Mensaje de error
|
||||||
|
lastUpdated: string | null // Timestamp última actualización
|
||||||
|
initialized: boolean // Si el store está inicializado
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getters Disponibles
|
||||||
|
|
||||||
|
| Getter | Tipo | Descripción |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `allTables` | `TableMetadata[]` | Todas las tablas disponibles |
|
||||||
|
| `getTableMetadata(name)` | `TableMetadata \| undefined` | Metadatos de una tabla específica |
|
||||||
|
| `totalTables` | `number` | Cantidad total de tablas |
|
||||||
|
| `totalRecords` | `number` | Suma de registros de todas las tablas |
|
||||||
|
| `tableNames` | `string[]` | Lista de nombres de tablas |
|
||||||
|
| `hasMetadata` | `boolean` | Si hay metadatos cargados |
|
||||||
|
| `isLoading` | `boolean` | Si está cargando actualmente |
|
||||||
|
| `hasError` | `boolean` | Si hay un error |
|
||||||
|
| `formattedLastUpdated` | `string` | Fecha formateada de última actualización |
|
||||||
|
| `isStale` | `boolean` | Si los datos tienen > 5 minutos |
|
||||||
|
|
||||||
|
### Actions Disponibles
|
||||||
|
|
||||||
|
| Action | Parámetros | Descripción |
|
||||||
|
|--------|------------|-------------|
|
||||||
|
| `loadMetadata(force?)` | `force: boolean` | Carga lazy, solo si necesario |
|
||||||
|
| `refreshMetadata()` | - | Actualización forzada |
|
||||||
|
| `clearMetadata()` | - | Limpia cache y datos |
|
||||||
|
| `initialize()` | - | Inicializa el store (cache + fetch) |
|
||||||
|
|
||||||
|
### Fuente de Datos
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/metadata`
|
||||||
|
|
||||||
|
**Respuesta:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"table": "usuarios",
|
||||||
|
"rowCount": 1523,
|
||||||
|
"primaryKey": "id",
|
||||||
|
"approxSizeBytes": 245760,
|
||||||
|
"columns": ["id", "nombre", "email", "created_at"],
|
||||||
|
"createdAtRange": {
|
||||||
|
"from": "2024-01-01T00:00:00Z",
|
||||||
|
"to": "2025-01-20T15:30:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// ... más tablas
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Uso Básico
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useMetadataStore } from '~/stores/metadata'
|
||||||
|
|
||||||
|
// En un componente
|
||||||
|
const metadataStore = useMetadataStore()
|
||||||
|
|
||||||
|
// Inicializar (carga desde cache o API)
|
||||||
|
await metadataStore.initialize()
|
||||||
|
|
||||||
|
// Acceder a los datos
|
||||||
|
console.log(metadataStore.allTables)
|
||||||
|
console.log(metadataStore.totalTables)
|
||||||
|
|
||||||
|
// Obtener metadatos de una tabla específica
|
||||||
|
const usuariosMetadata = metadataStore.getTableMetadata('usuarios')
|
||||||
|
|
||||||
|
// Refrescar metadatos
|
||||||
|
await metadataStore.refreshMetadata()
|
||||||
|
|
||||||
|
// Verificar frescura de datos
|
||||||
|
if (metadataStore.isStale) {
|
||||||
|
console.log('Los metadatos tienen más de 5 minutos')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏭 Table Data Factory
|
||||||
|
|
||||||
|
Sistema factory que crea stores dinámicos para cada tabla.
|
||||||
|
|
||||||
|
### Estructura de Datos
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TableDataState<T = Record<string, unknown>> {
|
||||||
|
data: T[] // Registros cacheados
|
||||||
|
loading: boolean // Estado de carga
|
||||||
|
error: string | null // Mensaje de error
|
||||||
|
lastUpdated: string | null // Timestamp última actualización
|
||||||
|
initialized: boolean // Si el store está inicializado
|
||||||
|
limit: number // Límite de registros
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getters Disponibles
|
||||||
|
|
||||||
|
| Getter | Tipo | Descripción |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `allRecords` | `T[]` | Todos los registros cacheados |
|
||||||
|
| `hasData` | `boolean` | Si hay datos disponibles |
|
||||||
|
| `isLoading` | `boolean` | Si está cargando actualmente |
|
||||||
|
| `hasError` | `boolean` | Si hay un error |
|
||||||
|
| `recordCount` | `number` | Cantidad de registros |
|
||||||
|
| `formattedLastUpdated` | `string` | Fecha formateada |
|
||||||
|
| `isStale` | `boolean` | Si los datos tienen > 5 minutos |
|
||||||
|
|
||||||
|
### Actions Disponibles
|
||||||
|
|
||||||
|
| Action | Parámetros | Descripción |
|
||||||
|
|--------|------------|-------------|
|
||||||
|
| `loadData(force?)` | `force: boolean` | Carga lazy |
|
||||||
|
| `refreshData()` | - | Actualización forzada |
|
||||||
|
| `clearData()` | - | Limpia cache y datos |
|
||||||
|
| `initialize()` | - | Inicializa (cache + fetch) |
|
||||||
|
| `getRecord(id)` | `id: string \| number` | Obtiene un registro por ID |
|
||||||
|
| `filterRecords(predicate)` | `predicate: (record: T) => boolean` | Filtra registros |
|
||||||
|
|
||||||
|
### Fuente de Datos
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/data/:tableName?limit=100`
|
||||||
|
|
||||||
|
**Respuesta:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"table": "usuarios",
|
||||||
|
"count": 100,
|
||||||
|
"limit": 100,
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"nombre": "Juan Pérez",
|
||||||
|
"email": "juan@example.com",
|
||||||
|
"created_at": "2024-01-15T10:30:00Z"
|
||||||
|
},
|
||||||
|
// ... más registros
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creación de Stores
|
||||||
|
|
||||||
|
#### Método 1: Usando la Factory Directamente
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createTableDataStore } from '~/stores/tableDataFactory'
|
||||||
|
|
||||||
|
// Crear un store para la tabla "usuarios"
|
||||||
|
const useUsuariosStore = createTableDataStore('usuarios', 100)
|
||||||
|
|
||||||
|
// Usar el store
|
||||||
|
const usuariosStore = useUsuariosStore()
|
||||||
|
await usuariosStore.initialize()
|
||||||
|
|
||||||
|
console.log(usuariosStore.allRecords)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Método 2: Usando el Helper
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useTableDataStore } from '~/stores/tableDataFactory'
|
||||||
|
|
||||||
|
// Crear y usar el store en un solo paso
|
||||||
|
const usuariosStore = useTableDataStore('usuarios', 100)
|
||||||
|
await usuariosStore.initialize()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Método 3: Usando el Plugin (Recomendado)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// El plugin auto-registra todos los stores
|
||||||
|
const { $getTableStore } = useNuxtApp()
|
||||||
|
|
||||||
|
// Obtener un store ya registrado
|
||||||
|
const usuariosStore = $getTableStore('usuarios')
|
||||||
|
|
||||||
|
if (usuariosStore) {
|
||||||
|
await usuariosStore.initialize()
|
||||||
|
console.log(usuariosStore.allRecords)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Flujo de Datos
|
||||||
|
|
||||||
|
### Inicialización de la App
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. App.vue o plugin se ejecuta
|
||||||
|
onMounted(async () => {
|
||||||
|
// 2. Metadata store se inicializa
|
||||||
|
const metadataStore = useMetadataStore()
|
||||||
|
await metadataStore.initialize()
|
||||||
|
|
||||||
|
// 3. Plugin auto-registra stores (tableStores.client.ts)
|
||||||
|
// Se crean stores para cada tabla encontrada en metadataStore.allTables
|
||||||
|
|
||||||
|
// 4. Los stores están listos pero NO han cargado datos aún
|
||||||
|
// Esto es lazy loading - solo cargan cuando se necesitan
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Carga de Datos de una Tabla
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Usuario selecciona tabla "usuarios"
|
||||||
|
const selectTable = async (tableName: string) => {
|
||||||
|
// 1. Obtener o crear store
|
||||||
|
const store = $getTableStore(tableName) || useTableDataStore(tableName)
|
||||||
|
|
||||||
|
// 2. Inicializar (intenta cache primero)
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
// Flujo interno de initialize():
|
||||||
|
// ├─ loadFromCache()
|
||||||
|
// │ └─ Lee localStorage['table-data-usuarios']
|
||||||
|
// │ ├─ Si existe → usa datos cacheados
|
||||||
|
// │ └─ Si no existe → continúa
|
||||||
|
// └─ loadData()
|
||||||
|
// └─ fetchData()
|
||||||
|
// └─ $fetch('/api/data/usuarios?limit=100')
|
||||||
|
// └─ Guarda en localStorage
|
||||||
|
// └─ Actualiza store
|
||||||
|
|
||||||
|
// 3. Datos disponibles
|
||||||
|
console.log(store.allRecords)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Actualización Manual
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Usuario hace click en "Actualizar datos"
|
||||||
|
const refreshTable = async () => {
|
||||||
|
// 1. Ejecutar refreshData()
|
||||||
|
await store.refreshData()
|
||||||
|
|
||||||
|
// Flujo interno de refreshData():
|
||||||
|
// └─ fetchData()
|
||||||
|
// ├─ loading = true
|
||||||
|
// ├─ $fetch('/api/data/usuarios?limit=100')
|
||||||
|
// ├─ Actualiza store.data
|
||||||
|
// ├─ Actualiza lastUpdated
|
||||||
|
// ├─ Guarda en localStorage
|
||||||
|
// └─ loading = false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 Uso en Componentes
|
||||||
|
|
||||||
|
### Ejemplo Completo: Explorador de Tablas
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Selector de tabla -->
|
||||||
|
<select v-model="selectedTable" @change="onTableChange">
|
||||||
|
<option v-for="table in metadataStore.tableNames" :key="table">
|
||||||
|
{{ table }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Información de cache -->
|
||||||
|
<div v-if="currentStore">
|
||||||
|
<p>Última actualización: {{ currentStore.formattedLastUpdated }}</p>
|
||||||
|
<p v-if="currentStore.isStale" class="warning">
|
||||||
|
⚠️ Datos desactualizados
|
||||||
|
</p>
|
||||||
|
<button @click="refreshData" :disabled="currentStore.isLoading">
|
||||||
|
{{ currentStore.isLoading ? 'Actualizando...' : 'Actualizar' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabla de datos -->
|
||||||
|
<div v-if="currentStore?.hasData">
|
||||||
|
<p>{{ currentStore.recordCount }} registros</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-for="col in columns" :key="col">{{ col }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="record in currentStore.allRecords" :key="record.id">
|
||||||
|
<td v-for="col in columns" :key="col">
|
||||||
|
{{ record[col] }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Estados -->
|
||||||
|
<div v-else-if="currentStore?.isLoading">Cargando...</div>
|
||||||
|
<div v-else-if="currentStore?.hasError">
|
||||||
|
Error: {{ currentStore.error }}
|
||||||
|
</div>
|
||||||
|
<div v-else>Selecciona una tabla</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useMetadataStore } from '~/stores/metadata'
|
||||||
|
import { useTableDataStore } from '~/stores/tableDataFactory'
|
||||||
|
|
||||||
|
const metadataStore = useMetadataStore()
|
||||||
|
const selectedTable = ref('')
|
||||||
|
const currentStore = ref<ReturnType<typeof useTableDataStore> | null>(null)
|
||||||
|
|
||||||
|
// Columnas de la tabla actual
|
||||||
|
const columns = computed(() => {
|
||||||
|
if (!currentStore.value?.hasData) return []
|
||||||
|
const firstRecord = currentStore.value.allRecords[0]
|
||||||
|
return Object.keys(firstRecord)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cambio de tabla
|
||||||
|
async function onTableChange() {
|
||||||
|
if (!selectedTable.value) return
|
||||||
|
|
||||||
|
// Obtener store de la tabla
|
||||||
|
currentStore.value = useTableDataStore(selectedTable.value)
|
||||||
|
|
||||||
|
// Inicializar (usa cache si existe)
|
||||||
|
await currentStore.value.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refrescar datos
|
||||||
|
async function refreshData() {
|
||||||
|
if (!currentStore.value) return
|
||||||
|
await currentStore.value.refreshData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inicializar metadatos al montar
|
||||||
|
onMounted(async () => {
|
||||||
|
await metadataStore.initialize()
|
||||||
|
|
||||||
|
// Auto-seleccionar primera tabla
|
||||||
|
if (metadataStore.tableNames.length > 0) {
|
||||||
|
selectedTable.value = metadataStore.tableNames[0]
|
||||||
|
await onTableChange()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejemplo: Búsqueda y Filtrado
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useTableDataStore } from '~/stores/tableDataFactory'
|
||||||
|
|
||||||
|
const usuariosStore = useTableDataStore('usuarios')
|
||||||
|
await usuariosStore.initialize()
|
||||||
|
|
||||||
|
// Filtrar usuarios activos
|
||||||
|
const usuariosActivos = usuariosStore.filterRecords(
|
||||||
|
user => user.activo === true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Buscar usuario por ID
|
||||||
|
const usuario = usuariosStore.getRecord(123)
|
||||||
|
|
||||||
|
// Búsqueda personalizada
|
||||||
|
const usuariosPorEmail = usuariosStore.filterRecords(
|
||||||
|
user => user.email.includes('@gmail.com')
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(`Encontrados ${usuariosPorEmail.length} usuarios con Gmail`)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 API Reference
|
||||||
|
|
||||||
|
### Metadata Store API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface MetadataStore {
|
||||||
|
// State
|
||||||
|
metadata: TableMetadata[]
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
lastUpdated: string | null
|
||||||
|
initialized: boolean
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
allTables: TableMetadata[]
|
||||||
|
getTableMetadata(name: string): TableMetadata | undefined
|
||||||
|
totalTables: number
|
||||||
|
totalRecords: number
|
||||||
|
tableNames: string[]
|
||||||
|
hasMetadata: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
hasError: boolean
|
||||||
|
formattedLastUpdated: string
|
||||||
|
isStale: boolean
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
loadMetadata(force?: boolean): Promise<void>
|
||||||
|
refreshMetadata(): Promise<void>
|
||||||
|
clearMetadata(): void
|
||||||
|
initialize(): Promise<void>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table Data Store API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TableDataStore<T> {
|
||||||
|
// State
|
||||||
|
data: T[]
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
lastUpdated: string | null
|
||||||
|
initialized: boolean
|
||||||
|
limit: number
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
allRecords: T[]
|
||||||
|
hasData: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
hasError: boolean
|
||||||
|
recordCount: number
|
||||||
|
formattedLastUpdated: string
|
||||||
|
isStale: boolean
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
loadData(force?: boolean): Promise<void>
|
||||||
|
refreshData(): Promise<void>
|
||||||
|
clearData(): void
|
||||||
|
initialize(): Promise<void>
|
||||||
|
getRecord(id: string | number): T | undefined
|
||||||
|
filterRecords(predicate: (record: T) => boolean): T[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💾 Cache y Persistencia
|
||||||
|
|
||||||
|
### Estrategia de Cache
|
||||||
|
|
||||||
|
El sistema usa **localStorage** para persistir datos:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Estructura de cache en localStorage
|
||||||
|
{
|
||||||
|
// Metadatos
|
||||||
|
"metadata-cache": {
|
||||||
|
metadata: TableMetadata[],
|
||||||
|
lastUpdated: "2025-01-20T10:30:00Z"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Datos de tabla usuarios
|
||||||
|
"table-data-usuarios": {
|
||||||
|
data: [...registros],
|
||||||
|
lastUpdated: "2025-01-20T10:35:00Z",
|
||||||
|
limit: 100
|
||||||
|
},
|
||||||
|
|
||||||
|
// Datos de tabla productos
|
||||||
|
"table-data-productos": {
|
||||||
|
data: [...registros],
|
||||||
|
lastUpdated: "2025-01-20T10:40:00Z",
|
||||||
|
limit: 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Políticas de Cache
|
||||||
|
|
||||||
|
1. **Freshness Check**: Los datos se consideran "stale" después de 5 minutos
|
||||||
|
2. **Lazy Loading**: Los stores solo cargan cuando se accede a ellos
|
||||||
|
3. **Cache-First**: Siempre intenta cargar desde cache primero
|
||||||
|
4. **Auto-Refresh**: El usuario controla manualmente cuándo refrescar
|
||||||
|
5. **Offline Support**: Datos disponibles incluso sin conexión
|
||||||
|
|
||||||
|
### Limpieza de Cache
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Limpiar cache de metadatos
|
||||||
|
const metadataStore = useMetadataStore()
|
||||||
|
metadataStore.clearMetadata()
|
||||||
|
|
||||||
|
// Limpiar cache de una tabla
|
||||||
|
const usuariosStore = useTableDataStore('usuarios')
|
||||||
|
usuariosStore.clearData()
|
||||||
|
|
||||||
|
// Limpiar TODO el cache (localStorage)
|
||||||
|
if (process.client) {
|
||||||
|
localStorage.clear()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Ejemplos Avanzados
|
||||||
|
|
||||||
|
### Sincronización Automática
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Auto-refrescar cada 5 minutos si los datos están stale
|
||||||
|
const autoRefresh = () => {
|
||||||
|
const store = useTableDataStore('usuarios')
|
||||||
|
|
||||||
|
setInterval(async () => {
|
||||||
|
if (store.isStale && !store.isLoading) {
|
||||||
|
console.log('Refrescando datos automáticamente...')
|
||||||
|
await store.refreshData()
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000) // 5 minutos
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validación de Datos
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Verificar integridad de datos cacheados
|
||||||
|
const validateCache = async (tableName: string) => {
|
||||||
|
const store = useTableDataStore(tableName)
|
||||||
|
const metadata = metadataStore.getTableMetadata(tableName)
|
||||||
|
|
||||||
|
if (!metadata) return false
|
||||||
|
|
||||||
|
// Verificar que el número de registros sea razonable
|
||||||
|
if (store.recordCount > metadata.rowCount) {
|
||||||
|
console.warn('Cache corrupto, limpiando...')
|
||||||
|
store.clearData()
|
||||||
|
await store.refreshData()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Composable Personalizado
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// composables/useTableData.ts
|
||||||
|
export function useTableData(tableName: string) {
|
||||||
|
const store = useTableDataStore(tableName)
|
||||||
|
const initialized = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.initialize()
|
||||||
|
initialized.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-refrescar si stale
|
||||||
|
watch(() => store.isStale, async (isStale) => {
|
||||||
|
if (isStale && initialized.value) {
|
||||||
|
await store.refreshData()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: computed(() => store.allRecords),
|
||||||
|
loading: computed(() => store.isLoading),
|
||||||
|
error: computed(() => store.error),
|
||||||
|
refresh: () => store.refreshData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript Tipado
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Definir tipos para tus tablas
|
||||||
|
interface Usuario {
|
||||||
|
id: number
|
||||||
|
nombre: string
|
||||||
|
email: string
|
||||||
|
activo: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Producto {
|
||||||
|
id: number
|
||||||
|
nombre: string
|
||||||
|
precio: number
|
||||||
|
stock: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usar stores tipados
|
||||||
|
const usuariosStore = useTableDataStore<Usuario>('usuarios')
|
||||||
|
const productosStore = useTableDataStore<Producto>('productos')
|
||||||
|
|
||||||
|
// TypeScript ahora conoce la estructura
|
||||||
|
usuariosStore.allRecords.forEach(user => {
|
||||||
|
console.log(user.email) // ✅ TypeScript sabe que existe
|
||||||
|
// console.log(user.foo) // ❌ Error: Property 'foo' does not exist
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Debugging
|
||||||
|
|
||||||
|
### Inspeccionar Estado
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// En DevTools Console
|
||||||
|
import { useMetadataStore } from '~/stores/metadata'
|
||||||
|
import { useTableDataStore } from '~/stores/tableDataFactory'
|
||||||
|
|
||||||
|
const metadataStore = useMetadataStore()
|
||||||
|
const usuariosStore = useTableDataStore('usuarios')
|
||||||
|
|
||||||
|
// Ver estado completo
|
||||||
|
console.log('Metadata:', metadataStore.$state)
|
||||||
|
console.log('Usuarios:', usuariosStore.$state)
|
||||||
|
|
||||||
|
// Ver cache
|
||||||
|
console.log('Cache:', {
|
||||||
|
metadata: localStorage.getItem('metadata-cache'),
|
||||||
|
usuarios: localStorage.getItem('table-data-usuarios')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs Detallados
|
||||||
|
|
||||||
|
Descomenta los `console.log` en los stores para ver el flujo completo:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// En tableDataFactory.ts línea 144
|
||||||
|
console.log(`[${tableName}] Fetching data...`)
|
||||||
|
|
||||||
|
// En tableDataFactory.ts línea 158
|
||||||
|
console.log(`[${tableName}] Data fetched: ${this.recordCount} records`)
|
||||||
|
|
||||||
|
// En tableDataFactory.ts línea 164
|
||||||
|
console.log(`[${tableName}] Saved to cache`)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Notas Importantes
|
||||||
|
|
||||||
|
### ⚠️ Limitaciones
|
||||||
|
|
||||||
|
1. **Límite de localStorage**: ~5-10MB por dominio
|
||||||
|
- Solución: Implementar estrategia LRU si se excede
|
||||||
|
|
||||||
|
2. **Límite de registros**: Por defecto 100 registros por tabla
|
||||||
|
- Modificable en la factory: `useTableDataStore('tabla', 500)`
|
||||||
|
|
||||||
|
3. **Sin paginación**: Todos los registros se cargan de una vez
|
||||||
|
- Para tablas grandes, considerar implementar paginación
|
||||||
|
|
||||||
|
4. **Sin sincronización real-time**: Los datos se actualizan manualmente
|
||||||
|
- Para real-time, considerar WebSockets
|
||||||
|
|
||||||
|
### 🚀 Performance Tips
|
||||||
|
|
||||||
|
1. **Lazy Loading**: Los stores no cargan datos hasta que se necesitan
|
||||||
|
2. **Cache-First**: Usa cache para respuesta instantánea
|
||||||
|
3. **Selectores Computados**: Usa `computed()` para filtros complejos
|
||||||
|
4. **Debounce**: Para búsquedas, usa debounce para evitar renders excesivos
|
||||||
|
|
||||||
|
### 🔒 Seguridad
|
||||||
|
|
||||||
|
- Los datos en localStorage NO están encriptados
|
||||||
|
- No almacenes información sensible (contraseñas, tokens)
|
||||||
|
- El API backend debe validar permisos y autenticación
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Referencias
|
||||||
|
|
||||||
|
- [Pinia Documentation](https://pinia.vuejs.org/)
|
||||||
|
- [Nuxt 4 Documentation](https://nuxt.com/)
|
||||||
|
- [LocalStorage API](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Última actualización:** 2025-01-20
|
||||||
|
**Versión:** 1.0.0
|
||||||
|
**Autor:** Equipo Núcleo
|
||||||
277
nuxt4-app/app/stores/tableDataFactory.ts
Normal file
277
nuxt4-app/app/stores/tableDataFactory.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export interface TableDataState<T = Record<string, unknown>> {
|
||||||
|
data: T[]
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
lastUpdated: string | null
|
||||||
|
initialized: boolean
|
||||||
|
limit: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableDataGetters<T = Record<string, unknown>> {
|
||||||
|
allRecords: (state: TableDataState<T>) => T[]
|
||||||
|
hasData: (state: TableDataState<T>) => boolean
|
||||||
|
isLoading: (state: TableDataState<T>) => boolean
|
||||||
|
hasError: (state: TableDataState<T>) => boolean
|
||||||
|
recordCount: (state: TableDataState<T>) => number
|
||||||
|
formattedLastUpdated: (state: TableDataState<T>) => string
|
||||||
|
isStale: (state: TableDataState<T>) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableDataActions<T = Record<string, unknown>> {
|
||||||
|
loadData(force?: boolean): Promise<void>
|
||||||
|
refreshData(): Promise<void>
|
||||||
|
fetchData(): Promise<void>
|
||||||
|
clearData(): void
|
||||||
|
loadFromCache(): void
|
||||||
|
extractErrorMessage(error: unknown): string
|
||||||
|
initialize(): Promise<void>
|
||||||
|
getRecord(id: string | number): T | undefined
|
||||||
|
filterRecords(predicate: (record: T) => boolean): T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create a Pinia store for table data
|
||||||
|
* @param tableName - The name of the table
|
||||||
|
* @param defaultLimit - Default limit for data fetching (default: 100)
|
||||||
|
*/
|
||||||
|
export function createTableDataStore<T = Record<string, unknown>>(
|
||||||
|
tableName: string,
|
||||||
|
defaultLimit: number = 100
|
||||||
|
) {
|
||||||
|
const storeId = `table-${tableName}`
|
||||||
|
const cacheKey = `table-data-${tableName}`
|
||||||
|
|
||||||
|
return defineStore(storeId, {
|
||||||
|
state: (): TableDataState<T> => ({
|
||||||
|
data: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: null,
|
||||||
|
initialized: false,
|
||||||
|
limit: defaultLimit
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
/**
|
||||||
|
* Get all records
|
||||||
|
*/
|
||||||
|
allRecords: (state): T[] => state.data,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if data is available
|
||||||
|
*/
|
||||||
|
hasData: (state): boolean => state.data.length > 0,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if data is currently loading
|
||||||
|
*/
|
||||||
|
isLoading: (state): boolean => state.loading,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there's an error
|
||||||
|
*/
|
||||||
|
hasError: (state): boolean => !!state.error,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total number of records
|
||||||
|
*/
|
||||||
|
recordCount: (state): number => state.data.length,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get formatted last updated time
|
||||||
|
*/
|
||||||
|
formattedLastUpdated: (state): string => {
|
||||||
|
if (!state.lastUpdated) return 'Nunca'
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new Date(state.lastUpdated).toLocaleString('es-ES', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return 'Fecha inválida'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if data is stale (older than 5 minutes)
|
||||||
|
*/
|
||||||
|
isStale: (state): boolean => {
|
||||||
|
if (!state.lastUpdated) return true
|
||||||
|
|
||||||
|
const lastUpdate = new Date(state.lastUpdated)
|
||||||
|
const now = new Date()
|
||||||
|
const fiveMinutes = 5 * 60 * 1000
|
||||||
|
|
||||||
|
return (now.getTime() - lastUpdate.getTime()) > fiveMinutes
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
/**
|
||||||
|
* Load data lazily (only if not already loaded)
|
||||||
|
*/
|
||||||
|
async loadData(force = false): Promise<void> {
|
||||||
|
// Don't load if already loading
|
||||||
|
if (this.loading) return
|
||||||
|
|
||||||
|
// Don't load if already initialized and not forced
|
||||||
|
if (this.initialized && !force && !this.isStale) return
|
||||||
|
|
||||||
|
await this.fetchData()
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force refresh data
|
||||||
|
*/
|
||||||
|
async refreshData(): Promise<void> {
|
||||||
|
await this.fetchData()
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal method to fetch data from API
|
||||||
|
*/
|
||||||
|
async fetchData(): Promise<void> {
|
||||||
|
this.loading = true
|
||||||
|
this.error = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch(`/api/data/${tableName}`, {
|
||||||
|
query: { limit: String(this.limit) }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response && typeof response === 'object' && 'records' in response) {
|
||||||
|
const dataset = response as { records?: T[] }
|
||||||
|
this.data = Array.isArray(dataset.records) ? dataset.records : []
|
||||||
|
} else if (Array.isArray(response)) {
|
||||||
|
this.data = response as T[]
|
||||||
|
} else {
|
||||||
|
this.data = []
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastUpdated = new Date().toISOString()
|
||||||
|
this.initialized = true
|
||||||
|
|
||||||
|
// Persist to localStorage for offline access
|
||||||
|
if (process.client) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(cacheKey, JSON.stringify({
|
||||||
|
data: this.data,
|
||||||
|
lastUpdated: this.lastUpdated,
|
||||||
|
limit: this.limit
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to persist ${tableName} data to localStorage:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
this.error = this.extractErrorMessage(error)
|
||||||
|
console.error(`Error fetching ${tableName} data:`, error)
|
||||||
|
|
||||||
|
// Try to load from cache if available
|
||||||
|
if (process.client && !this.hasData) {
|
||||||
|
this.loadFromCache()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load data from localStorage cache
|
||||||
|
*/
|
||||||
|
loadFromCache(): void {
|
||||||
|
if (!process.client) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
const parsedCache = JSON.parse(cached)
|
||||||
|
this.data = parsedCache.data || []
|
||||||
|
this.lastUpdated = parsedCache.lastUpdated || null
|
||||||
|
this.limit = parsedCache.limit || defaultLimit
|
||||||
|
this.initialized = true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to load ${tableName} data from cache:`, error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all data
|
||||||
|
*/
|
||||||
|
clearData(): void {
|
||||||
|
this.data = []
|
||||||
|
this.error = null
|
||||||
|
this.lastUpdated = null
|
||||||
|
this.initialized = false
|
||||||
|
|
||||||
|
if (process.client) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(cacheKey)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to clear ${tableName} data cache:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract error message from various error types
|
||||||
|
*/
|
||||||
|
extractErrorMessage(error: unknown): string {
|
||||||
|
if (error && typeof error === 'object' && 'statusMessage' in error) {
|
||||||
|
return String((error as { statusMessage: string }).statusMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Error inesperado al cargar datos de ${tableName}`
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize store (called on app startup)
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
// Load from cache first for immediate availability
|
||||||
|
this.loadFromCache()
|
||||||
|
|
||||||
|
// Then try to fetch fresh data
|
||||||
|
await this.loadData()
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific record by ID
|
||||||
|
*/
|
||||||
|
getRecord(id: string | number): T | undefined {
|
||||||
|
return this.data.find((record: any) => {
|
||||||
|
return record.id === id || String(record.id) === String(id)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter records by a predicate function
|
||||||
|
*/
|
||||||
|
filterRecords(predicate: (record: T) => boolean): T[] {
|
||||||
|
return this.data.filter(predicate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get or create a table data store
|
||||||
|
*/
|
||||||
|
export function useTableDataStore<T = Record<string, unknown>>(
|
||||||
|
tableName: string,
|
||||||
|
limit?: number
|
||||||
|
) {
|
||||||
|
const store = createTableDataStore<T>(tableName, limit)
|
||||||
|
return store()
|
||||||
|
}
|
||||||
@@ -5,6 +5,27 @@ export default defineNuxtConfig({
|
|||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
css: ['~/assets/css/main.css'],
|
css: ['~/assets/css/main.css'],
|
||||||
modules: ['@nuxt/image', '@nuxt/ui', '@nuxt/test-utils', '@vite-pwa/nuxt', '@pinia/nuxt'],
|
modules: ['@nuxt/image', '@nuxt/ui', '@nuxt/test-utils', '@vite-pwa/nuxt', '@pinia/nuxt'],
|
||||||
|
|
||||||
|
// Performance optimizations
|
||||||
|
experimental: {
|
||||||
|
payloadExtraction: false,
|
||||||
|
viewTransition: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Optimize build
|
||||||
|
vite: {
|
||||||
|
build: {
|
||||||
|
cssCodeSplit: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
'vendor-ui': ['@nuxt/ui'],
|
||||||
|
'vendor-pinia': ['pinia', '@pinia/nuxt']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
app: {
|
app: {
|
||||||
head: {
|
head: {
|
||||||
link: [
|
link: [
|
||||||
@@ -13,7 +34,7 @@ export default defineNuxtConfig({
|
|||||||
{ rel: 'manifest', href: '/manifest.webmanifest' }
|
{ rel: 'manifest', href: '/manifest.webmanifest' }
|
||||||
],
|
],
|
||||||
meta: [
|
meta: [
|
||||||
{ name: 'theme-color', content: '#14100b' },
|
{ name: 'theme-color', content: '#1b1209' },
|
||||||
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
||||||
{ name: 'mobile-web-app-capable', content: 'yes' },
|
{ name: 'mobile-web-app-capable', content: 'yes' },
|
||||||
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' }
|
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' }
|
||||||
|
|||||||
22
nuxt4-app/plugins/loading.client.ts
Normal file
22
nuxt4-app/plugins/loading.client.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
// This plugin ensures the loading screen is properly hidden when the app is ready
|
||||||
|
|
||||||
|
if (process.client) {
|
||||||
|
// Listen for when the app is fully hydrated
|
||||||
|
const checkReady = () => {
|
||||||
|
// Wait for next tick to ensure DOM is painted
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
document.documentElement.classList.add('nuxt-ready')
|
||||||
|
}, 50)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple triggers to ensure loading screen is hidden
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
checkReady()
|
||||||
|
} else {
|
||||||
|
window.addEventListener('load', checkReady)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
42
nuxt4-app/plugins/tableStores.client.ts
Normal file
42
nuxt4-app/plugins/tableStores.client.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useMetadataStore } from '~/stores/metadata'
|
||||||
|
import { createTableDataStore } from '~/stores/tableDataFactory'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(async (nuxtApp) => {
|
||||||
|
// Wait for metadata to be available
|
||||||
|
const metadataStore = useMetadataStore()
|
||||||
|
|
||||||
|
// Initialize metadata first
|
||||||
|
await metadataStore.initialize()
|
||||||
|
|
||||||
|
// Create stores for all available tables
|
||||||
|
const tableStores = new Map<string, ReturnType<typeof createTableDataStore>>()
|
||||||
|
|
||||||
|
metadataStore.allTables.forEach((table) => {
|
||||||
|
const storeName = table.table
|
||||||
|
const storeFactory = createTableDataStore(storeName, 100)
|
||||||
|
|
||||||
|
// Register the store
|
||||||
|
tableStores.set(storeName, storeFactory)
|
||||||
|
|
||||||
|
// Optionally initialize stores in the background (lazy loading)
|
||||||
|
// You can uncomment this to preload all data
|
||||||
|
// const storeInstance = storeFactory()
|
||||||
|
// storeInstance.loadFromCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Provide access to table stores
|
||||||
|
return {
|
||||||
|
provide: {
|
||||||
|
tableStores,
|
||||||
|
// Helper function to get a table store
|
||||||
|
getTableStore: (tableName: string) => {
|
||||||
|
const storeFactory = tableStores.get(tableName)
|
||||||
|
if (!storeFactory) {
|
||||||
|
console.warn(`Table store for "${tableName}" not found`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return storeFactory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user