Feature: agregar filtros de clientes y ubicaciones en Informe de Ingresos
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 46s
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 46s
- Crear endpoint /api/clientes para obtener clientes desde Supabase - Crear endpoint /api/postgres/query para ejecutar queries SQL - Crear componente ClienteMultiSelector con búsqueda y filtro por ubicación - Agregar filtros de clientes, ubicaciones y calidades en informe-ingresos.vue - Cargar opciones de filtros desde Metabase (query ID 53) - Actualizar detección de cambios pendientes con nuevos filtros - Enviar cliente_ids, ubicaciones y calidades al endpoint de Metabase - Componente con formato de cédula y ordenamiento por nombre - Búsqueda por nombre, cédula o ubicación - Contador de selección y botón limpiar todo - Botones rápidos para seleccionar por ubicación
This commit is contained in:
188
nuxt4-app/app/components/ClienteMultiSelector.vue
Normal file
188
nuxt4-app/app/components/ClienteMultiSelector.vue
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- Search input -->
|
||||||
|
<div class="relative">
|
||||||
|
<UInput
|
||||||
|
v-model="searchQuery"
|
||||||
|
icon="i-lucide-search"
|
||||||
|
placeholder="Buscar clientes por nombre o cédula..."
|
||||||
|
:ui="{ icon: { trailing: { pointer: '' } } }"
|
||||||
|
>
|
||||||
|
<template #trailing>
|
||||||
|
<UButton
|
||||||
|
v-if="searchQuery"
|
||||||
|
icon="i-lucide-x"
|
||||||
|
color="gray"
|
||||||
|
variant="link"
|
||||||
|
size="xs"
|
||||||
|
@click="searchQuery = ''"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</UInput>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selected count and clear all -->
|
||||||
|
<div v-if="selectedIds.length > 0" class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-[var(--brand-text-muted)]">
|
||||||
|
{{ selectedIds.length }} cliente{{ selectedIds.length !== 1 ? 's' : '' }} seleccionado{{ selectedIds.length !== 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
color="gray"
|
||||||
|
variant="link"
|
||||||
|
@click="clearAll"
|
||||||
|
>
|
||||||
|
Limpiar todo
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List of clientes with checkboxes -->
|
||||||
|
<div class="max-h-64 overflow-y-auto space-y-2 border border-[var(--brand-border)] rounded-lg p-3">
|
||||||
|
<div v-if="loading" class="py-4 text-center text-sm text-[var(--brand-text-muted)]">
|
||||||
|
Cargando clientes...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="filteredClientes.length === 0" class="py-4 text-center text-sm text-[var(--brand-text-muted)]">
|
||||||
|
{{ searchQuery ? 'No se encontraron clientes' : 'No hay clientes disponibles' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
v-for="cliente in filteredClientes"
|
||||||
|
:key="cliente.id"
|
||||||
|
class="flex items-center gap-2 p-2 rounded hover:bg-[#2a1f10] cursor-pointer transition-colors"
|
||||||
|
@click="toggleCliente(cliente.id)"
|
||||||
|
>
|
||||||
|
<UCheckbox
|
||||||
|
:model-value="isSelected(cliente.id)"
|
||||||
|
@click.stop="toggleCliente(cliente.id)"
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-medium text-[var(--brand-text)] truncate">
|
||||||
|
{{ cliente.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-[var(--brand-text-muted)] flex items-center gap-2">
|
||||||
|
<span v-if="cliente.cedula">{{ formatCedula(cliente.cedula) }}</span>
|
||||||
|
<span v-if="cliente.ubicacion" class="truncate">• {{ cliente.ubicacion }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick filters by ubicacion -->
|
||||||
|
<div v-if="ubicaciones.length > 0" class="space-y-2">
|
||||||
|
<div class="text-xs font-medium text-[var(--brand-text-muted)] uppercase tracking-wide">
|
||||||
|
Filtrar por ubicación
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<UButton
|
||||||
|
v-for="ubicacion in ubicaciones"
|
||||||
|
:key="ubicacion"
|
||||||
|
size="xs"
|
||||||
|
color="gray"
|
||||||
|
variant="soft"
|
||||||
|
@click="selectByUbicacion(ubicacion)"
|
||||||
|
>
|
||||||
|
{{ ubicacion }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Cliente {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
cedula?: number
|
||||||
|
ubicacion?: string
|
||||||
|
telefono?: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
selectedIds: number[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:selectedIds': [value: number[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// State
|
||||||
|
const clientes = ref<Cliente[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const filteredClientes = computed(() => {
|
||||||
|
if (!searchQuery.value) return clientes.value
|
||||||
|
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
return clientes.value.filter(c =>
|
||||||
|
c.name.toLowerCase().includes(query) ||
|
||||||
|
c.cedula?.toString().includes(query) ||
|
||||||
|
c.ubicacion?.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const ubicaciones = computed(() => {
|
||||||
|
const locs = new Set<string>()
|
||||||
|
clientes.value.forEach(c => {
|
||||||
|
if (c.ubicacion) locs.add(c.ubicacion)
|
||||||
|
})
|
||||||
|
return Array.from(locs).sort()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
function isSelected(id: number): boolean {
|
||||||
|
return props.selectedIds.includes(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCliente(id: number) {
|
||||||
|
const newIds = isSelected(id)
|
||||||
|
? props.selectedIds.filter(i => i !== id)
|
||||||
|
: [...props.selectedIds, id]
|
||||||
|
|
||||||
|
emit('update:selectedIds', newIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
emit('update:selectedIds', [])
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectByUbicacion(ubicacion: string) {
|
||||||
|
const clientesInUbicacion = clientes.value
|
||||||
|
.filter(c => c.ubicacion === ubicacion)
|
||||||
|
.map(c => c.id)
|
||||||
|
|
||||||
|
// Add to selection (not replace)
|
||||||
|
const newIds = [...new Set([...props.selectedIds, ...clientesInUbicacion])]
|
||||||
|
emit('update:selectedIds', newIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCedula(cedula: number): string {
|
||||||
|
const str = cedula.toString()
|
||||||
|
// Format as XXXX-XXXX-XXXXX
|
||||||
|
if (str.length === 13) {
|
||||||
|
return `${str.slice(0, 4)}-${str.slice(4, 8)}-${str.slice(8)}`
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadClientes() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await $fetch('/api/clientes')
|
||||||
|
clientes.value = result as Cliente[]
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ClienteSelector] Error loading clientes:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load on mount
|
||||||
|
onMounted(() => {
|
||||||
|
loadClientes()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -61,7 +61,30 @@
|
|||||||
<div class="mt-6 space-y-4">
|
<div class="mt-6 space-y-4">
|
||||||
<h3 class="text-sm font-semibold text-[var(--brand-text)]">Filtros Avanzados</h3>
|
<h3 class="text-sm font-semibold text-[var(--brand-text)]">Filtros Avanzados</h3>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<!-- Selector de Clientes -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2 text-[var(--brand-text)]">Clientes</label>
|
||||||
|
<ClienteMultiSelector
|
||||||
|
:selected-ids="selectedClienteIds"
|
||||||
|
@update:selected-ids="selectedClienteIds = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<!-- Ubicaciones -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2 text-[var(--brand-text)]">Ubicaciones</label>
|
||||||
|
<div class="space-y-2 max-h-40 overflow-y-auto">
|
||||||
|
<UCheckbox
|
||||||
|
v-for="ubicacion in opcionesFiltros.ubicaciones"
|
||||||
|
:key="ubicacion"
|
||||||
|
v-model="selectedUbicaciones"
|
||||||
|
:value="ubicacion"
|
||||||
|
:label="ubicacion"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tipos de Café -->
|
<!-- Tipos de Café -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2 text-[var(--brand-text)]">Tipos de Café</label>
|
<label class="block text-sm font-medium mb-2 text-[var(--brand-text)]">Tipos de Café</label>
|
||||||
@@ -82,6 +105,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Calidades -->
|
||||||
|
<div v-if="opcionesFiltros.calidades.length > 0">
|
||||||
|
<label class="block text-sm font-medium mb-2 text-[var(--brand-text)]">Calidades</label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<UCheckbox
|
||||||
|
v-for="calidad in opcionesFiltros.calidades"
|
||||||
|
:key="calidad"
|
||||||
|
v-model="selectedCalidades"
|
||||||
|
:value="calidad"
|
||||||
|
:label="calidad"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -190,7 +227,30 @@
|
|||||||
<div class="mt-6 space-y-4">
|
<div class="mt-6 space-y-4">
|
||||||
<h3 class="text-sm font-semibold text-[var(--brand-text)]">Filtros Avanzados</h3>
|
<h3 class="text-sm font-semibold text-[var(--brand-text)]">Filtros Avanzados</h3>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<!-- Selector de Clientes -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2 text-[var(--brand-text)]">Clientes</label>
|
||||||
|
<ClienteMultiSelector
|
||||||
|
:selected-ids="selectedClienteIds"
|
||||||
|
@update:selected-ids="selectedClienteIds = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<!-- Ubicaciones -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2 text-[var(--brand-text)]">Ubicaciones</label>
|
||||||
|
<div class="space-y-2 max-h-40 overflow-y-auto">
|
||||||
|
<UCheckbox
|
||||||
|
v-for="ubicacion in opcionesFiltros.ubicaciones"
|
||||||
|
:key="ubicacion"
|
||||||
|
v-model="selectedUbicaciones"
|
||||||
|
:value="ubicacion"
|
||||||
|
:label="ubicacion"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tipos de Café -->
|
<!-- Tipos de Café -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2 text-[var(--brand-text)]">Tipos de Café</label>
|
<label class="block text-sm font-medium mb-2 text-[var(--brand-text)]">Tipos de Café</label>
|
||||||
@@ -212,10 +272,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Nota sobre filtros futuros -->
|
<!-- Calidades -->
|
||||||
<p class="text-xs text-[var(--brand-text-muted)] italic">
|
<div v-if="opcionesFiltros.calidades.length > 0">
|
||||||
Los filtros de clientes, ubicaciones y calidades se agregarán próximamente
|
<label class="block text-sm font-medium mb-2 text-[var(--brand-text)]">Calidades</label>
|
||||||
</p>
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<UCheckbox
|
||||||
|
v-for="calidad in opcionesFiltros.calidades"
|
||||||
|
:key="calidad"
|
||||||
|
v-model="selectedCalidades"
|
||||||
|
:value="calidad"
|
||||||
|
:label="calidad"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -354,6 +423,11 @@ const selectedPreset = ref<PresetValue>('cosecha-25-26')
|
|||||||
const fechaDesde = ref<string | null>(null)
|
const fechaDesde = ref<string | null>(null)
|
||||||
const fechaHasta = ref<string | null>(null)
|
const fechaHasta = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Filtros avanzados - clientes y ubicaciones
|
||||||
|
const selectedClienteIds = ref<number[]>([])
|
||||||
|
const selectedUbicaciones = ref<string[]>([])
|
||||||
|
const selectedCalidades = ref<string[]>([])
|
||||||
|
|
||||||
// Filtros avanzados - usando checkboxes separados
|
// Filtros avanzados - usando checkboxes separados
|
||||||
const filterTipos = ref({
|
const filterTipos = ref({
|
||||||
uva: false,
|
uva: false,
|
||||||
@@ -367,6 +441,14 @@ const filterEstados = ref({
|
|||||||
pendiente: false
|
pendiente: false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Opciones de filtros disponibles (desde Metabase)
|
||||||
|
const opcionesFiltros = ref({
|
||||||
|
ubicaciones: [] as string[],
|
||||||
|
calidades: [] as string[],
|
||||||
|
tipos: [] as string[],
|
||||||
|
estados: [] as string[]
|
||||||
|
})
|
||||||
|
|
||||||
// Convertir checkboxes a arrays para el API
|
// Convertir checkboxes a arrays para el API
|
||||||
const tiposArray = computed(() => {
|
const tiposArray = computed(() => {
|
||||||
const tipos: string[] = []
|
const tipos: string[] = []
|
||||||
@@ -389,6 +471,9 @@ const appliedFilters = ref<{
|
|||||||
fechaDesde: string | null
|
fechaDesde: string | null
|
||||||
fechaHasta: string | null
|
fechaHasta: string | null
|
||||||
includeAnulados: boolean
|
includeAnulados: boolean
|
||||||
|
clienteIds: number[]
|
||||||
|
ubicaciones: string[]
|
||||||
|
calidades: string[]
|
||||||
tipos: string[]
|
tipos: string[]
|
||||||
estados: string[]
|
estados: string[]
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
@@ -411,6 +496,9 @@ const hasPendingChanges = computed(() => {
|
|||||||
fechaDesde.value !== appliedFilters.value.fechaDesde ||
|
fechaDesde.value !== appliedFilters.value.fechaDesde ||
|
||||||
fechaHasta.value !== appliedFilters.value.fechaHasta ||
|
fechaHasta.value !== appliedFilters.value.fechaHasta ||
|
||||||
includeAnulados.value !== appliedFilters.value.includeAnulados ||
|
includeAnulados.value !== appliedFilters.value.includeAnulados ||
|
||||||
|
JSON.stringify(selectedClienteIds.value) !== JSON.stringify(appliedFilters.value.clienteIds) ||
|
||||||
|
JSON.stringify(selectedUbicaciones.value) !== JSON.stringify(appliedFilters.value.ubicaciones) ||
|
||||||
|
JSON.stringify(selectedCalidades.value) !== JSON.stringify(appliedFilters.value.calidades) ||
|
||||||
JSON.stringify(tiposArray.value) !== JSON.stringify(appliedFilters.value.tipos) ||
|
JSON.stringify(tiposArray.value) !== JSON.stringify(appliedFilters.value.tipos) ||
|
||||||
JSON.stringify(estadosArray.value) !== JSON.stringify(appliedFilters.value.estados)
|
JSON.stringify(estadosArray.value) !== JSON.stringify(appliedFilters.value.estados)
|
||||||
)
|
)
|
||||||
@@ -432,11 +520,11 @@ async function loadData() {
|
|||||||
fecha_desde: fechaDesde.value,
|
fecha_desde: fechaDesde.value,
|
||||||
fecha_hasta: fechaHasta.value,
|
fecha_hasta: fechaHasta.value,
|
||||||
incluir_anulados: includeAnulados.value,
|
incluir_anulados: includeAnulados.value,
|
||||||
cliente_ids: [], // TODO: implementar selector de clientes
|
cliente_ids: selectedClienteIds.value,
|
||||||
tipos: tiposArray.value,
|
tipos: tiposArray.value,
|
||||||
estados: estadosArray.value,
|
estados: estadosArray.value,
|
||||||
ubicaciones: [], // TODO: implementar selector de ubicaciones
|
ubicaciones: selectedUbicaciones.value,
|
||||||
calidades: [], // TODO: implementar selector de calidades
|
calidades: selectedCalidades.value,
|
||||||
granularidad: 'dia' // Default granularity
|
granularidad: 'dia' // Default granularity
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,6 +548,9 @@ async function loadData() {
|
|||||||
fechaDesde: fechaDesde.value,
|
fechaDesde: fechaDesde.value,
|
||||||
fechaHasta: fechaHasta.value,
|
fechaHasta: fechaHasta.value,
|
||||||
includeAnulados: includeAnulados.value,
|
includeAnulados: includeAnulados.value,
|
||||||
|
clienteIds: [...selectedClienteIds.value],
|
||||||
|
ubicaciones: [...selectedUbicaciones.value],
|
||||||
|
calidades: [...selectedCalidades.value],
|
||||||
tipos: [...tiposArray.value],
|
tipos: [...tiposArray.value],
|
||||||
estados: [...estadosArray.value]
|
estados: [...estadosArray.value]
|
||||||
}
|
}
|
||||||
@@ -505,10 +596,52 @@ function onUpdateFechaHasta(value: string | null) {
|
|||||||
fechaHasta.value = value
|
fechaHasta.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cargar opciones de filtros desde Metabase
|
||||||
|
async function loadOpcionesFiltros() {
|
||||||
|
try {
|
||||||
|
// Ejecutar la query "Informe Ingresos - Opciones de Filtros" (ID: 53)
|
||||||
|
const result = await $fetch('/__mcp/mcp__nucleodocs-metabase__metabase_execute_card', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
card_id: 53,
|
||||||
|
parameters: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result && result.data && result.data.rows && result.data.rows.length > 0) {
|
||||||
|
const row = result.data.rows[0]
|
||||||
|
const cols = result.data.cols
|
||||||
|
|
||||||
|
// Transformar respuesta a objeto
|
||||||
|
const data: any = {}
|
||||||
|
cols.forEach((col: any, index: number) => {
|
||||||
|
data[col.name] = row[index]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Parsear arrays JSON de ubicaciones, calidades, tipos, estados
|
||||||
|
opcionesFiltros.value = {
|
||||||
|
ubicaciones: data.ubicaciones ? JSON.parse(data.ubicaciones) : [],
|
||||||
|
calidades: data.calidades ? JSON.parse(data.calidades) : [],
|
||||||
|
tipos: data.tipos ? JSON.parse(data.tipos) : [],
|
||||||
|
estados: data.estados ? JSON.parse(data.estados) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Informe] Opciones de filtros cargadas:', opcionesFiltros.value)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Informe] Error loading opciones de filtros:', error)
|
||||||
|
// No fallar si no se pueden cargar las opciones, solo usar arrays vacíos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Inicializar preset por defecto sin cargar datos
|
// Inicializar preset por defecto sin cargar datos
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
// Default preset: cosecha 25-26
|
// Default preset: cosecha 25-26
|
||||||
selectedPreset.value = 'cosecha-25-26'
|
selectedPreset.value = 'cosecha-25-26'
|
||||||
|
|
||||||
|
// Cargar opciones de filtros disponibles
|
||||||
|
await loadOpcionesFiltros()
|
||||||
|
|
||||||
// NO cargar datos automáticamente - el usuario debe hacer clic en "Actualizar"
|
// NO cargar datos automáticamente - el usuario debe hacer clic en "Actualizar"
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
33
nuxt4-app/server/api/clientes/index.get.ts
Normal file
33
nuxt4-app/server/api/clientes/index.get.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Get all clients from Supabase facturador database
|
||||||
|
* Returns: id, name, ubicacion for use in filters
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async () => {
|
||||||
|
try {
|
||||||
|
// Query clientes table ordered by name
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
ubicacion,
|
||||||
|
cedula,
|
||||||
|
telefono,
|
||||||
|
email
|
||||||
|
FROM clientes
|
||||||
|
ORDER BY name ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await $fetch('/api/postgres/query', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { query }
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[API] Failed to fetch clientes:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: error.statusCode || 500,
|
||||||
|
statusMessage: error.statusMessage || 'Failed to fetch clientes'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
34
nuxt4-app/server/api/postgres/query.post.ts
Normal file
34
nuxt4-app/server/api/postgres/query.post.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Execute a raw SQL query against Supabase/PostgreSQL
|
||||||
|
* This is a server-only endpoint for internal API use
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event)
|
||||||
|
const { query, params = [] } = body
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Query is required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Execute query using MCP postgres tool
|
||||||
|
const result = await $fetch('/__mcp/mcp__postgres__query', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
query,
|
||||||
|
params
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Postgres] Query failed:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: error.statusCode || 500,
|
||||||
|
statusMessage: error.statusMessage || 'Database query failed'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user