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

- 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:
2025-10-29 18:36:14 -06:00
parent 2ba8ff9b7e
commit 20f87e37fc
4 changed files with 398 additions and 10 deletions

View 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>

View File

@@ -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>

View 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'
})
}
})

View 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'
})
}
})