Initial Nuxt data explorer setup
This commit is contained in:
24
nuxt4-app/.gitignore
vendored
Normal file
24
nuxt4-app/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
75
nuxt4-app/README.md
Normal file
75
nuxt4-app/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Nuxt Minimal Starter
|
||||
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
6
nuxt4-app/app/app.vue
Normal file
6
nuxt4-app/app/app.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950 text-slate-100">
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</template>
|
||||
14
nuxt4-app/nuxt.config.ts
Normal file
14
nuxt4-app/nuxt.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
ssr: false,
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
modules: ['@nuxt/image', '@nuxt/ui', '@nuxt/test-utils'],
|
||||
runtimeConfig: {
|
||||
supabase: {
|
||||
url: process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
serviceRoleKey:
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
}
|
||||
}
|
||||
})
|
||||
12396
nuxt4-app/package-lock.json
generated
Normal file
12396
nuxt4-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
nuxt4-app/package.json
Normal file
22
nuxt4-app/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "nuxt-app",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/image": "^1.11.0",
|
||||
"@nuxt/test-utils": "^3.19.2",
|
||||
"@nuxt/ui": "^4.0.0",
|
||||
"@supabase/supabase-js": "^2.48.0",
|
||||
"nuxt": "^4.1.2",
|
||||
"typescript": "^5.9.2",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.5.1"
|
||||
}
|
||||
}
|
||||
326
nuxt4-app/pages/index.vue
Normal file
326
nuxt4-app/pages/index.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<div class="mx-auto flex max-w-6xl flex-col gap-6 px-4 py-10">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-2xl font-semibold">Visor de datos</h1>
|
||||
<p class="text-sm text-slate-300">
|
||||
Selecciona una tabla e introduce filtros para consultar los datos en modo solo lectura.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-4">
|
||||
<UFormGroup label="Tabla" name="table">
|
||||
<USelectMenu
|
||||
v-model="filters.table"
|
||||
:options="tableOptions"
|
||||
value-attribute="value"
|
||||
option-attribute="label"
|
||||
placeholder="Selecciona una tabla"
|
||||
/>
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="ID" name="id">
|
||||
<UInput v-model="filters.id" placeholder="Filtrar por ID" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Fecha desde" name="from">
|
||||
<UInput v-model="filters.from" type="date" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Fecha hasta" name="to">
|
||||
<UInput v-model="filters.to" type="date" />
|
||||
</UFormGroup>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="text-xs text-slate-400">
|
||||
Para filtros avanzados puedes codificar un objeto JSON en base64 e invocarlo mediante la ruta
|
||||
<code class="rounded bg-slate-800 px-2 py-1">/api/data/{{ filters.table }}/[query]</code>.
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<UButton color="gray" variant="soft" @click="resetFilters" :disabled="loading">
|
||||
Limpiar filtros
|
||||
</UButton>
|
||||
<UButton @click="refresh" :loading="loading" :disabled="!filters.table">
|
||||
Consultar datos
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
|
||||
<div v-if="errorMessage" class="rounded-lg border border-red-500 bg-red-500/10 p-4 text-sm text-red-200">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div v-if="activeMetadata" class="grid gap-4 lg:grid-cols-2">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Resumen de {{ activeMetadata.table }}</h2>
|
||||
<UBadge color="primary">{{ activeMetadata.rowCount }} registros</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
<dl class="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<dt class="text-slate-400">Clave primaria</dt>
|
||||
<dd class="font-medium">{{ activeMetadata.primaryKey }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-400">Última consulta</dt>
|
||||
<dd class="font-medium">{{ formatDate(activeMetadata.lastRefreshed) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-400">Tamaño aprox.</dt>
|
||||
<dd class="font-medium">{{ formatSize(activeMetadata.approxSizeBytes) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-400">Rango de creación</dt>
|
||||
<dd class="font-medium">
|
||||
{{ formatDate(activeMetadata.createdAtRange?.from) }}
|
||||
—
|
||||
{{ formatDate(activeMetadata.createdAtRange?.to) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<template #footer>
|
||||
<div class="text-xs text-slate-400">
|
||||
Columnas detectadas: {{ activeMetadata.columns.join(', ') }}
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
|
||||
<UCard v-if="activeMetadata.sampleRow">
|
||||
<template #header>
|
||||
<h2 class="text-lg font-semibold">Registro de ejemplo</h2>
|
||||
</template>
|
||||
<pre class="overflow-auto rounded bg-slate-900 p-4 text-sm">{{ formatSample(activeMetadata.sampleRow) }}</pre>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h2 class="text-lg font-semibold">Datos</h2>
|
||||
<UBadge v-if="tableData" color="gray">
|
||||
{{ tableData.length }} registros visibles
|
||||
</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-10">
|
||||
<ULoadingIndicator size="lg" />
|
||||
</div>
|
||||
<div v-else-if="tableData.length === 0" class="py-10 text-center text-sm text-slate-400">
|
||||
No hay datos que coincidan con los filtros actuales.
|
||||
</div>
|
||||
<div v-else class="overflow-auto">
|
||||
<table class="min-w-full divide-y divide-slate-800 text-sm">
|
||||
<thead class="bg-slate-900/60">
|
||||
<tr>
|
||||
<th v-for="column in visibleColumns" :key="column" class="px-4 py-2 text-left font-semibold">
|
||||
{{ column }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
<tr v-for="(row, index) in tableData" :key="index" class="hover:bg-slate-900/40">
|
||||
<td v-for="column in visibleColumns" :key="column" class="px-4 py-2">
|
||||
{{ formatCell(row[column]) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const tableData = ref<Record<string, unknown>[]>([])
|
||||
const activeMetadata = ref<any | null>(null)
|
||||
const availableMetadata = ref<any[]>([])
|
||||
|
||||
const filters = reactive({
|
||||
table: '',
|
||||
id: '',
|
||||
from: '',
|
||||
to: ''
|
||||
})
|
||||
|
||||
const tableOptions = computed(() =>
|
||||
availableMetadata.value.map((meta) => ({
|
||||
label: `${meta.table} (${meta.rowCount})`,
|
||||
value: meta.table
|
||||
}))
|
||||
)
|
||||
|
||||
const visibleColumns = computed(() => (tableData.value[0] ? Object.keys(tableData.value[0]) : []))
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAvailableMetadata()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => filters.table,
|
||||
async (value) => {
|
||||
if (!value) {
|
||||
tableData.value = []
|
||||
activeMetadata.value = null
|
||||
return
|
||||
}
|
||||
|
||||
await refresh()
|
||||
}
|
||||
)
|
||||
|
||||
async function loadAvailableMetadata() {
|
||||
try {
|
||||
const metadata = await $fetch('/api/metadata')
|
||||
availableMetadata.value = Array.isArray(metadata) ? metadata : []
|
||||
} catch (error) {
|
||||
errorMessage.value = extractErrorMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!filters.table) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = null
|
||||
|
||||
try {
|
||||
const [metadata, data] = await Promise.all([
|
||||
$fetch(`/api/metadata/${filters.table}`),
|
||||
$fetch(`/api/data/${filters.table}`, {
|
||||
query: buildQueryParams()
|
||||
})
|
||||
])
|
||||
|
||||
activeMetadata.value = metadata
|
||||
const metadataIndex = availableMetadata.value.findIndex((item) => item.table === metadata.table)
|
||||
if (metadataIndex >= 0) {
|
||||
availableMetadata.value[metadataIndex] = metadata
|
||||
}
|
||||
tableData.value = data?.records ?? []
|
||||
} catch (error) {
|
||||
errorMessage.value = extractErrorMessage(error)
|
||||
tableData.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function buildQueryParams() {
|
||||
const params: Record<string, string> = { limit: '100' }
|
||||
|
||||
if (filters.id) {
|
||||
params.id = filters.id.trim()
|
||||
}
|
||||
|
||||
if (filters.from) {
|
||||
params.created_from = filters.from
|
||||
}
|
||||
|
||||
if (filters.to) {
|
||||
params.created_to = filters.to
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.id = ''
|
||||
filters.from = ''
|
||||
filters.to = ''
|
||||
|
||||
if (filters.table) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
function extractErrorMessage(error: unknown) {
|
||||
if (error && typeof error === 'object' && 'statusMessage' in error) {
|
||||
return String((error as any).statusMessage)
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
return 'Ocurrió un error inesperado al consultar los datos.'
|
||||
}
|
||||
|
||||
function formatSize(bytes: number | null) {
|
||||
if (!bytes) {
|
||||
return 'No disponible'
|
||||
}
|
||||
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`
|
||||
}
|
||||
|
||||
const units = ['KB', 'MB', 'GB']
|
||||
let size = bytes / 1024
|
||||
let unitIndex = 0
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
function formatCell(value: unknown) {
|
||||
if (value === null || value === undefined) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString()
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch (error) {
|
||||
return '[objeto]'
|
||||
}
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function formatSample(value: unknown) {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch (error) {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
BIN
nuxt4-app/public/favicon.ico
Normal file
BIN
nuxt4-app/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
2
nuxt4-app/public/robots.txt
Normal file
2
nuxt4-app/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-Agent: *
|
||||
Disallow:
|
||||
22
nuxt4-app/server/api/data/[table]/[...segment].get.ts
Normal file
22
nuxt4-app/server/api/data/[table]/[...segment].get.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { parseQuerySegment } from '../../../services/query-parser'
|
||||
import { fetchTableData, fetchTableRecord } from '../../../services/table-service'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const table = event.context.params?.table
|
||||
const segmentParam = event.context.params?.segment
|
||||
|
||||
if (!table || !segmentParam) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Tabla o parámetro no especificados' })
|
||||
}
|
||||
|
||||
const values = Array.isArray(segmentParam) ? segmentParam : [segmentParam]
|
||||
const target = values[0]
|
||||
|
||||
const parsedQuery = parseQuerySegment(target)
|
||||
|
||||
if (parsedQuery) {
|
||||
return await fetchTableData(table, { parsedQuery })
|
||||
}
|
||||
|
||||
return await fetchTableRecord(table, target)
|
||||
})
|
||||
26
nuxt4-app/server/api/data/[table]/index.get.ts
Normal file
26
nuxt4-app/server/api/data/[table]/index.get.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { parseQuerySegment } from '../../../services/query-parser'
|
||||
import { fetchTableData } from '../../../services/table-service'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const table = event.context.params?.table
|
||||
|
||||
if (!table) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Tabla no especificada' })
|
||||
}
|
||||
|
||||
const query = getQuery(event)
|
||||
const limitValue = Number.parseInt((query.limit as string) ?? '', 10)
|
||||
const limit = Number.isFinite(limitValue) ? Math.min(Math.max(limitValue, 1), 500) : undefined
|
||||
|
||||
const parsedQuery = parseQuerySegment(query.query as string | undefined)
|
||||
|
||||
return await fetchTableData(table, {
|
||||
parsedQuery,
|
||||
limit,
|
||||
filters: {
|
||||
id: (query.id as string) || undefined,
|
||||
createdFrom: (query.created_from as string) || undefined,
|
||||
createdTo: (query.created_to as string) || undefined
|
||||
}
|
||||
})
|
||||
})
|
||||
9
nuxt4-app/server/api/data/index.get.ts
Normal file
9
nuxt4-app/server/api/data/index.get.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { fetchAllData } from '../../services/table-service'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const requestedLimit = Number.parseInt((query.limit as string) ?? '', 10)
|
||||
const limit = Number.isFinite(requestedLimit) ? Math.min(Math.max(requestedLimit, 1), 100) : 25
|
||||
|
||||
return await fetchAllData(limit)
|
||||
})
|
||||
12
nuxt4-app/server/api/metadata/[table]/[id].get.ts
Normal file
12
nuxt4-app/server/api/metadata/[table]/[id].get.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { fetchTableRecordMetadata } from '../../../services/table-service'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const table = event.context.params?.table
|
||||
const id = event.context.params?.id
|
||||
|
||||
if (!table || !id) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Tabla o id no especificados' })
|
||||
}
|
||||
|
||||
return await fetchTableRecordMetadata(table, id)
|
||||
})
|
||||
15
nuxt4-app/server/api/metadata/[table]/index.get.ts
Normal file
15
nuxt4-app/server/api/metadata/[table]/index.get.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { parseQuerySegment } from '../../../services/query-parser'
|
||||
import { fetchTableMetadata } from '../../../services/table-service'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const table = event.context.params?.table
|
||||
|
||||
if (!table) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Tabla no especificada' })
|
||||
}
|
||||
|
||||
const query = getQuery(event)
|
||||
const parsedQuery = parseQuerySegment(query.query as string | undefined)
|
||||
|
||||
return await fetchTableMetadata(table, { parsedQuery })
|
||||
})
|
||||
5
nuxt4-app/server/api/metadata/index.get.ts
Normal file
5
nuxt4-app/server/api/metadata/index.get.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { fetchAllTablesMetadata } from '../../services/table-service'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
return await fetchAllTablesMetadata()
|
||||
})
|
||||
6
nuxt4-app/server/data-sources/anticipos/config.ts
Normal file
6
nuxt4-app/server/data-sources/anticipos/config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { TableConfig } from '../types'
|
||||
|
||||
export const anticiposConfig: TableConfig = {
|
||||
table: 'anticipos',
|
||||
primaryKey: 'id'
|
||||
}
|
||||
6
nuxt4-app/server/data-sources/asistencias/config.ts
Normal file
6
nuxt4-app/server/data-sources/asistencias/config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { TableConfig } from '../types'
|
||||
|
||||
export const asistenciasConfig: TableConfig = {
|
||||
table: 'asistencias',
|
||||
primaryKey: 'id'
|
||||
}
|
||||
6
nuxt4-app/server/data-sources/carretas/config.ts
Normal file
6
nuxt4-app/server/data-sources/carretas/config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { TableConfig } from '../types'
|
||||
|
||||
export const carretasConfig: TableConfig = {
|
||||
table: 'carretas',
|
||||
primaryKey: 'id'
|
||||
}
|
||||
6
nuxt4-app/server/data-sources/clientes/config.ts
Normal file
6
nuxt4-app/server/data-sources/clientes/config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { TableConfig } from '../types'
|
||||
|
||||
export const clientesConfig: TableConfig = {
|
||||
table: 'clientes',
|
||||
primaryKey: 'id'
|
||||
}
|
||||
6
nuxt4-app/server/data-sources/comercios/config.ts
Normal file
6
nuxt4-app/server/data-sources/comercios/config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { TableConfig } from '../types'
|
||||
|
||||
export const comerciosConfig: TableConfig = {
|
||||
table: 'comercios',
|
||||
primaryKey: 'id'
|
||||
}
|
||||
6
nuxt4-app/server/data-sources/cupones/config.ts
Normal file
6
nuxt4-app/server/data-sources/cupones/config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { TableConfig } from '../types'
|
||||
|
||||
export const cuponesConfig: TableConfig = {
|
||||
table: 'cupones',
|
||||
primaryKey: 'id'
|
||||
}
|
||||
6
nuxt4-app/server/data-sources/depositos/config.ts
Normal file
6
nuxt4-app/server/data-sources/depositos/config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { TableConfig } from '../types'
|
||||
|
||||
export const depositosConfig: TableConfig = {
|
||||
table: 'depositos',
|
||||
primaryKey: 'id'
|
||||
}
|
||||
32
nuxt4-app/server/data-sources/index.ts
Normal file
32
nuxt4-app/server/data-sources/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { anticiposConfig } from './anticipos/config'
|
||||
import { asistenciasConfig } from './asistencias/config'
|
||||
import { carretasConfig } from './carretas/config'
|
||||
import { clientesConfig } from './clientes/config'
|
||||
import { comerciosConfig } from './comercios/config'
|
||||
import { cuponesConfig } from './cupones/config'
|
||||
import { depositosConfig } from './depositos/config'
|
||||
import { ingresosConfig } from './ingresos/config'
|
||||
import { pagosAnticipoConfig } from './pagos_anticipo/config'
|
||||
import { rechazosConfig } from './rechazos/config'
|
||||
import { tareasRealizadasConfig } from './tareas_realizadas/config'
|
||||
import type { TableConfig, TableName } from './types'
|
||||
|
||||
export const tableConfigs: Record<TableName, TableConfig> = {
|
||||
anticipos: anticiposConfig,
|
||||
asistencias: asistenciasConfig,
|
||||
carretas: carretasConfig,
|
||||
clientes: clientesConfig,
|
||||
comercios: comerciosConfig,
|
||||
cupones: cuponesConfig,
|
||||
depositos: depositosConfig,
|
||||
ingresos: ingresosConfig,
|
||||
pagos_anticipo: pagosAnticipoConfig,
|
||||
rechazos: rechazosConfig,
|
||||
tareas_realizadas: tareasRealizadasConfig
|
||||
}
|
||||
|
||||
export const tableNames = Object.keys(tableConfigs) as TableName[]
|
||||
|
||||
export function getTableConfig(name: string): TableConfig | undefined {
|
||||
return tableConfigs[name as TableName]
|
||||
}
|
||||
6
nuxt4-app/server/data-sources/ingresos/config.ts
Normal file
6
nuxt4-app/server/data-sources/ingresos/config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { TableConfig } from '../types'
|
||||
|
||||
export const ingresosConfig: TableConfig = {
|
||||
table: 'ingresos',
|
||||
primaryKey: 'id'
|
||||
}
|
||||
6
nuxt4-app/server/data-sources/pagos_anticipo/config.ts
Normal file
6
nuxt4-app/server/data-sources/pagos_anticipo/config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { TableConfig } from '../types'
|
||||
|
||||
export const pagosAnticipoConfig: TableConfig = {
|
||||
table: 'pagos_anticipo',
|
||||
primaryKey: 'id'
|
||||
}
|
||||
6
nuxt4-app/server/data-sources/rechazos/config.ts
Normal file
6
nuxt4-app/server/data-sources/rechazos/config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { TableConfig } from '../types'
|
||||
|
||||
export const rechazosConfig: TableConfig = {
|
||||
table: 'rechazos',
|
||||
primaryKey: 'id'
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { TableConfig } from '../types'
|
||||
|
||||
export const tareasRealizadasConfig: TableConfig = {
|
||||
table: 'tareas_realizadas',
|
||||
primaryKey: 'id'
|
||||
}
|
||||
36
nuxt4-app/server/data-sources/types.ts
Normal file
36
nuxt4-app/server/data-sources/types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type TableName =
|
||||
| 'anticipos'
|
||||
| 'asistencias'
|
||||
| 'carretas'
|
||||
| 'clientes'
|
||||
| 'comercios'
|
||||
| 'cupones'
|
||||
| 'depositos'
|
||||
| 'ingresos'
|
||||
| 'pagos_anticipo'
|
||||
| 'rechazos'
|
||||
| 'tareas_realizadas'
|
||||
|
||||
export type TableTransform = {
|
||||
readonly serializeRow?: (row: Record<string, unknown>) => Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface TableConfig {
|
||||
readonly table: TableName
|
||||
readonly primaryKey?: string
|
||||
readonly defaultSelect?: string
|
||||
readonly transforms?: TableTransform
|
||||
}
|
||||
|
||||
export interface QueryFilter {
|
||||
field: string
|
||||
operator?: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'ilike'
|
||||
value: string | number | boolean
|
||||
}
|
||||
|
||||
export interface ParsedQuery {
|
||||
filters: QueryFilter[]
|
||||
limit?: number
|
||||
offset?: number
|
||||
orderBy?: { field: string; ascending?: boolean }
|
||||
}
|
||||
53
nuxt4-app/server/services/query-parser.ts
Normal file
53
nuxt4-app/server/services/query-parser.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { ParsedQuery, QueryFilter } from '../data-sources/types'
|
||||
|
||||
function isValidFilter(filter: Partial<QueryFilter>): filter is QueryFilter {
|
||||
return typeof filter.field === 'string' && filter.field.length > 0 && filter.value !== undefined
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string) {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const paddingNeeded = (4 - (normalized.length % 4)) % 4
|
||||
const padded = normalized + '='.repeat(paddingNeeded)
|
||||
return Buffer.from(padded, 'base64').toString('utf-8')
|
||||
}
|
||||
|
||||
export function parseQuerySegment(segment?: string | string[]): ParsedQuery | null {
|
||||
if (!segment) {
|
||||
return null
|
||||
}
|
||||
|
||||
const value = Array.isArray(segment) ? segment[0] : segment
|
||||
|
||||
try {
|
||||
const decoded = decodeBase64Url(value)
|
||||
const parsed = JSON.parse(decoded)
|
||||
|
||||
const result: ParsedQuery = {
|
||||
filters: []
|
||||
}
|
||||
|
||||
if (Array.isArray(parsed.filters)) {
|
||||
result.filters = parsed.filters.filter(isValidFilter)
|
||||
}
|
||||
|
||||
if (parsed.limit && Number.isInteger(parsed.limit) && parsed.limit > 0) {
|
||||
result.limit = Math.min(parsed.limit, 500)
|
||||
}
|
||||
|
||||
if (parsed.offset && Number.isInteger(parsed.offset) && parsed.offset >= 0) {
|
||||
result.offset = parsed.offset
|
||||
}
|
||||
|
||||
if (parsed.orderBy && typeof parsed.orderBy.field === 'string') {
|
||||
result.orderBy = {
|
||||
field: parsed.orderBy.field,
|
||||
ascending: parsed.orderBy.ascending !== false
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse query segment', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
34
nuxt4-app/server/services/query-runner.ts
Normal file
34
nuxt4-app/server/services/query-runner.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ParsedQuery } from '../data-sources/types'
|
||||
|
||||
type PostgrestOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'ilike'
|
||||
|
||||
export function applyParsedQuery(builder: any, parsed: ParsedQuery | null) {
|
||||
if (!parsed) {
|
||||
return builder
|
||||
}
|
||||
|
||||
for (const filter of parsed.filters) {
|
||||
const operator: PostgrestOperator = (filter.operator ?? 'eq') as PostgrestOperator
|
||||
|
||||
if (typeof builder[operator] === 'function') {
|
||||
const value = operator === 'like' || operator === 'ilike' ? String(filter.value) : filter.value
|
||||
builder = builder[operator](filter.field, value)
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.orderBy && typeof builder.order === 'function') {
|
||||
builder = builder.order(parsed.orderBy.field, {
|
||||
ascending: parsed.orderBy.ascending !== false
|
||||
})
|
||||
}
|
||||
|
||||
if (parsed.limit && parsed.offset !== undefined && typeof builder.range === 'function') {
|
||||
const from = parsed.offset
|
||||
const to = parsed.offset + parsed.limit - 1
|
||||
builder = builder.range(from, to)
|
||||
} else if (parsed.limit && typeof builder.limit === 'function') {
|
||||
builder = builder.limit(parsed.limit)
|
||||
}
|
||||
|
||||
return builder
|
||||
}
|
||||
256
nuxt4-app/server/services/table-service.ts
Normal file
256
nuxt4-app/server/services/table-service.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { getTableConfig, tableNames } from '../data-sources'
|
||||
import type { ParsedQuery, TableConfig } from '../data-sources/types'
|
||||
import { getSupabaseClient } from '../utils/supabase'
|
||||
import { applyParsedQuery } from './query-runner'
|
||||
|
||||
type GenericObject = Record<string, unknown>
|
||||
|
||||
function isRecord(value: unknown): value is GenericObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
interface MetadataOptions {
|
||||
parsedQuery?: ParsedQuery | null
|
||||
}
|
||||
|
||||
interface DataFilters {
|
||||
id?: string
|
||||
createdFrom?: string
|
||||
createdTo?: string
|
||||
}
|
||||
|
||||
interface DataOptions {
|
||||
parsedQuery?: ParsedQuery | null
|
||||
filters?: DataFilters
|
||||
limit?: number
|
||||
}
|
||||
|
||||
function serializeRow(row: unknown, config: TableConfig): GenericObject | null {
|
||||
if (!isRecord(row) || ('error' in row && (row as any).error === true)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const transform = config.transforms?.serializeRow
|
||||
return transform ? transform(row) : row
|
||||
}
|
||||
|
||||
export async function fetchAllTablesMetadata() {
|
||||
const results = await Promise.allSettled(
|
||||
tableNames.map((name) => fetchTableMetadata(name))
|
||||
)
|
||||
|
||||
return results
|
||||
.filter((result): result is PromiseFulfilledResult<Awaited<ReturnType<typeof fetchTableMetadata>>> =>
|
||||
result.status === 'fulfilled'
|
||||
)
|
||||
.map((result) => result.value)
|
||||
}
|
||||
|
||||
export async function fetchTableMetadata(tableName: string, options?: MetadataOptions) {
|
||||
const config = getTableConfig(tableName)
|
||||
|
||||
if (!config) {
|
||||
throw createError({ statusCode: 404, statusMessage: `Tabla ${tableName} no encontrada` })
|
||||
}
|
||||
|
||||
const supabase = getSupabaseClient()
|
||||
|
||||
const baseSelect = config.defaultSelect ?? '*'
|
||||
|
||||
const countPromise = supabase
|
||||
.from(config.table)
|
||||
.select(baseSelect, { head: true, count: 'exact' })
|
||||
const samplePromise = applyParsedQuery(
|
||||
supabase.from(config.table).select(baseSelect),
|
||||
options?.parsedQuery ?? null
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const earliestPromise = supabase
|
||||
.from(config.table)
|
||||
.select('created_at')
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(1)
|
||||
|
||||
const latestPromise = supabase
|
||||
.from(config.table)
|
||||
.select('created_at')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
|
||||
const [{ count, error: countError }, { data: sampleData, error: sampleError }, earliest, latest] =
|
||||
await Promise.all([countPromise, samplePromise, earliestPromise, latestPromise])
|
||||
|
||||
if (countError) {
|
||||
throw createError({ statusCode: 500, statusMessage: countError.message })
|
||||
}
|
||||
|
||||
if (sampleError) {
|
||||
throw createError({ statusCode: 500, statusMessage: sampleError.message })
|
||||
}
|
||||
|
||||
const sampleSet = Array.isArray(sampleData) ? sampleData : []
|
||||
const sampleRow = serializeRow(sampleSet[0] ?? null, config)
|
||||
const columnNames = sampleRow ? Object.keys(sampleRow) : []
|
||||
const approxRowSize = sampleRow ? JSON.stringify(sampleRow).length : 0
|
||||
const approxSizeBytes = count && approxRowSize ? approxRowSize * count : null
|
||||
|
||||
return {
|
||||
table: config.table,
|
||||
primaryKey: config.primaryKey ?? 'id',
|
||||
rowCount: count ?? 0,
|
||||
approxSizeBytes,
|
||||
columns: columnNames,
|
||||
createdAtRange: {
|
||||
from: earliest.data?.[0]?.created_at ?? null,
|
||||
to: latest.data?.[0]?.created_at ?? null
|
||||
},
|
||||
sampleRow,
|
||||
lastRefreshed: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchTableRecordMetadata(tableName: string, id: string) {
|
||||
const config = getTableConfig(tableName)
|
||||
|
||||
if (!config) {
|
||||
throw createError({ statusCode: 404, statusMessage: `Tabla ${tableName} no encontrada` })
|
||||
}
|
||||
|
||||
const supabase = getSupabaseClient()
|
||||
const primaryKey = config.primaryKey ?? 'id'
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from(config.table)
|
||||
.select(config.defaultSelect ?? '*')
|
||||
.eq(primaryKey, id)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
throw createError({ statusCode: 500, statusMessage: error.message })
|
||||
}
|
||||
|
||||
if (!data || (typeof data === 'object' && data !== null && 'error' in data)) {
|
||||
if (data && typeof data === 'object' && 'message' in data) {
|
||||
throw createError({ statusCode: 500, statusMessage: String((data as any).message) })
|
||||
}
|
||||
throw createError({ statusCode: 404, statusMessage: `Registro ${id} no encontrado` })
|
||||
}
|
||||
|
||||
return {
|
||||
table: config.table,
|
||||
id,
|
||||
metadata: serializeRow(data, config)
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchTableRecord(tableName: string, id: string) {
|
||||
const config = getTableConfig(tableName)
|
||||
|
||||
if (!config) {
|
||||
throw createError({ statusCode: 404, statusMessage: `Tabla ${tableName} no encontrada` })
|
||||
}
|
||||
|
||||
const supabase = getSupabaseClient()
|
||||
const primaryKey = config.primaryKey ?? 'id'
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from(config.table)
|
||||
.select(config.defaultSelect ?? '*')
|
||||
.eq(primaryKey, id)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
throw createError({ statusCode: 500, statusMessage: error.message })
|
||||
}
|
||||
|
||||
if (!data || (typeof data === 'object' && data !== null && 'error' in data)) {
|
||||
if (data && typeof data === 'object' && 'message' in data) {
|
||||
throw createError({ statusCode: 500, statusMessage: String((data as any).message) })
|
||||
}
|
||||
throw createError({ statusCode: 404, statusMessage: `Registro ${id} no encontrado` })
|
||||
}
|
||||
|
||||
return serializeRow(data, config)
|
||||
}
|
||||
|
||||
export async function fetchAllData(limitPerTable = 100) {
|
||||
const supabase = getSupabaseClient()
|
||||
|
||||
const tablePromises = tableNames.map(async (name) => {
|
||||
const config = getTableConfig(name)!
|
||||
const { data, error, count } = await supabase
|
||||
.from(config.table)
|
||||
.select(config.defaultSelect ?? '*', { count: 'exact' })
|
||||
.limit(limitPerTable)
|
||||
|
||||
if (error) {
|
||||
throw createError({ statusCode: 500, statusMessage: error.message })
|
||||
}
|
||||
|
||||
const rows = Array.isArray(data) ? data : []
|
||||
|
||||
return {
|
||||
table: config.table,
|
||||
count: count ?? 0,
|
||||
limit: limitPerTable,
|
||||
records: rows
|
||||
.map((row) => serializeRow(row, config))
|
||||
.filter((row): row is GenericObject => row !== null)
|
||||
}
|
||||
})
|
||||
|
||||
return Promise.all(tablePromises)
|
||||
}
|
||||
|
||||
export async function fetchTableData(tableName: string, options?: DataOptions) {
|
||||
const config = getTableConfig(tableName)
|
||||
|
||||
if (!config) {
|
||||
throw createError({ statusCode: 404, statusMessage: `Tabla ${tableName} no encontrada` })
|
||||
}
|
||||
|
||||
const supabase = getSupabaseClient()
|
||||
const primaryKey = config.primaryKey ?? 'id'
|
||||
const limit = options?.limit ?? 100
|
||||
|
||||
let query = supabase
|
||||
.from(config.table)
|
||||
.select(config.defaultSelect ?? '*', { count: 'exact' })
|
||||
|
||||
query = applyParsedQuery(query as never, options?.parsedQuery ?? null) as never
|
||||
|
||||
if (options?.filters?.id) {
|
||||
query = query.eq(primaryKey, options.filters.id)
|
||||
}
|
||||
|
||||
if (options?.filters?.createdFrom) {
|
||||
query = query.gte('created_at', options.filters.createdFrom)
|
||||
}
|
||||
|
||||
if (options?.filters?.createdTo) {
|
||||
query = query.lte('created_at', options.filters.createdTo)
|
||||
}
|
||||
|
||||
if (!options?.parsedQuery?.limit) {
|
||||
query = query.limit(limit)
|
||||
}
|
||||
|
||||
const { data, error, count } = await query
|
||||
|
||||
if (error) {
|
||||
throw createError({ statusCode: 500, statusMessage: error.message })
|
||||
}
|
||||
|
||||
const rows = Array.isArray(data) ? data : []
|
||||
const records = rows
|
||||
.map((row) => serializeRow(row, config))
|
||||
.filter((row): row is GenericObject => row !== null)
|
||||
|
||||
return {
|
||||
table: config.table,
|
||||
count: count ?? records.length,
|
||||
limit,
|
||||
records
|
||||
}
|
||||
}
|
||||
28
nuxt4-app/server/utils/supabase.ts
Normal file
28
nuxt4-app/server/utils/supabase.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
let cachedClient: SupabaseClient | null = null
|
||||
|
||||
export function getSupabaseClient(): SupabaseClient {
|
||||
if (cachedClient) {
|
||||
return cachedClient
|
||||
}
|
||||
|
||||
const {
|
||||
supabase: { url, serviceRoleKey }
|
||||
} = useRuntimeConfig()
|
||||
|
||||
if (!url || !serviceRoleKey) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage:
|
||||
'Supabase credentials are missing. Please set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY.'
|
||||
})
|
||||
}
|
||||
|
||||
cachedClient = createClient(url, serviceRoleKey, {
|
||||
auth: { persistSession: false },
|
||||
db: { schema: 'public' }
|
||||
})
|
||||
|
||||
return cachedClient
|
||||
}
|
||||
18
nuxt4-app/tsconfig.json
Normal file
18
nuxt4-app/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.server.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.shared.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user