database view dinamico y personalizado completo

This commit is contained in:
2025-09-30 20:16:53 -06:00
parent a1be886cb4
commit 3bbb20ea27
4 changed files with 895 additions and 55 deletions

View File

@@ -0,0 +1,372 @@
<template>
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-bold brand-section-title">Vista Tabla de Ingresos</h2>
<p class="text-sm text-[var(--brand-text-muted)] mt-1">
{{ props.records.length }} registros filtrados
</p>
</div>
<UButton
:label="dateFormat === 'short' ? 'Fecha Larga' : 'Fecha Corta'"
color="neutral"
variant="outline"
icon="i-lucide-calendar"
@click="toggleDateFormat"
/>
</div>
</template>
<!-- Table Controls -->
<div class="flex items-center gap-2 px-4 py-3.5 overflow-x-auto border-b border-[var(--brand-border)]">
<UInput
v-model="globalFilter"
class="max-w-sm min-w-[12ch]"
placeholder="Buscar en todos los campos..."
icon="i-lucide-search"
/>
<UDropdownMenu
v-if="table?.tableApi"
:items="columnVisibilityItems"
:content="{ align: 'end' }"
>
<UButton
label="Columnas"
color="neutral"
variant="outline"
trailing-icon="i-lucide-chevron-down"
class="ml-auto"
aria-label="Selector de columnas visibles"
/>
</UDropdownMenu>
</div>
<!-- Table Component -->
<UTable
ref="table"
:data="limitedRecords"
:columns="tableColumns"
:global-filter="globalFilter"
sticky
class="h-96"
/>
<!-- Table Footer -->
<template #footer>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<span class="text-sm text-[var(--brand-text-muted)]">
Mostrando {{ startRecord }} - {{ endRecord }} de {{ totalRecords }} registros
</span>
<div class="flex items-center gap-2">
<UButton
icon="i-lucide-chevron-left"
color="neutral"
variant="outline"
size="sm"
:disabled="currentPage === 1"
@click="previousPage"
/>
<span class="text-sm text-[var(--brand-text-muted)] px-2">
Página {{ currentPage }} de {{ totalPages }}
</span>
<UButton
icon="i-lucide-chevron-right"
color="neutral"
variant="outline"
size="sm"
:disabled="currentPage === totalPages"
@click="nextPage"
/>
</div>
</div>
</div>
</template>
</UCard>
</template>
<script setup lang="ts">
import { h, ref, computed, resolveComponent } from 'vue'
import { upperFirst } from 'scule'
import type { TableColumn } from '@nuxt/ui'
import type { IngresoRecord } from '~/composables/useIngresosMetrics'
interface Props {
records: IngresoRecord[]
}
const props = defineProps<Props>()
const UButton = resolveComponent('UButton')
const globalFilter = ref('')
const table = useTemplateRef<{ tableApi?: any }>('table')
const currentPage = ref(1)
const recordsPerPage = 100
const dateFormat = ref<'short' | 'long'>('short')
const UBadge = resolveComponent('UBadge')
const UIcon = resolveComponent('UIcon')
function toggleDateFormat() {
dateFormat.value = dateFormat.value === 'short' ? 'long' : 'short'
}
// Paginación
const totalRecords = computed(() => props.records.length)
const totalPages = computed(() => Math.ceil(totalRecords.value / recordsPerPage))
const startRecord = computed(() => {
if (totalRecords.value === 0) return 0
return (currentPage.value - 1) * recordsPerPage + 1
})
const endRecord = computed(() => {
const end = currentPage.value * recordsPerPage
return Math.min(end, totalRecords.value)
})
// Obtener registros de la página actual
const limitedRecords = computed(() => {
const start = (currentPage.value - 1) * recordsPerPage
const end = start + recordsPerPage
return (props.records || []).slice(start, end)
})
function nextPage() {
if (currentPage.value < totalPages.value) {
currentPage.value++
}
}
function previousPage() {
if (currentPage.value > 1) {
currentPage.value--
}
}
// Seleccionar columnas importantes manualmente en lugar de todas las propiedades
const selectedColumns = [
'id',
'created_at',
'tipo',
'estado',
'cliente_id',
'peso_neto',
'peso_seco',
'precio',
'humedad',
'anulado',
'creador_id',
'fecha_pagado',
'pagado_id',
'fecha_anulado',
'anulador_id',
'traido',
'lectorTarjeta',
'autoPeso',
'fecha_retencion',
'comercio_id',
'datos_ciat',
'sacos_total',
'peso_bruto',
'tara',
]
// Generate table columns only for selected fields
const tableColumns = computed((): TableColumn<Record<string, unknown>>[] => {
if (!limitedRecords.value.length) return []
const firstRow = limitedRecords.value[0]
if (!firstRow) return []
// Solo usar columnas que existen en el primer registro
const availableColumns = selectedColumns.filter(col => col in firstRow)
return availableColumns.map((column: string) => ({
accessorKey: column,
header: ({ column: tableColumn }) => {
const isSorted = tableColumn.getIsSorted()
return h(UButton, {
color: 'neutral',
variant: 'ghost',
label: upperFirst(column),
icon: isSorted ? (isSorted === 'asc' ? 'i-lucide-arrow-up-narrow-wide' : 'i-lucide-arrow-down-wide-narrow') : 'i-lucide-arrow-up-down',
class: '-mx-2.5',
onClick: () => tableColumn.toggleSorting(tableColumn.getIsSorted() === 'asc')
})
},
cell: ({ row }) => formatCellValue(row.getValue(column), column)
}))
})
// Column visibility dropdown items
const columnVisibilityItems = computed((): any[] => {
if (!table.value?.tableApi) return []
return table.value.tableApi
.getAllColumns()
.filter((column: any) => column.getCanHide())
.map((column: any) => ({
label: upperFirst(column.id),
type: 'checkbox' as const,
checked: column.getIsVisible(),
onUpdateChecked(checked: boolean) {
table.value?.tableApi?.getColumn(column.id)?.toggleVisibility(!!checked)
},
onSelect(e?: Event) {
e?.preventDefault()
}
}))
})
function formatCellValue(value: unknown, column: string): any {
if (value === null || value === undefined) {
return '—'
}
// ID column - badge con formato I-####
if (column === 'id' && typeof value === 'number') {
return h(UBadge, {
label: `I-${value}`,
color: 'primary',
variant: 'subtle',
size: 'md',
class: 'rounded-full'
})
}
// comercio_id - badge con formato C-#### en cyan
if (column === 'comercio_id' && typeof value === 'number') {
return h(UBadge, {
label: `C-${value}`,
color: 'info',
variant: 'subtle',
size: 'md',
class: 'rounded-full'
})
}
// tipo - badge con colores especiales y gradientes
if (column === 'tipo' && typeof value === 'string') {
const tipoConfig: Record<string, { class: string, icon: string }> = {
'uva': {
class: 'bg-gradient-to-r from-red-600 via-red-500 to-yellow-600 text-white font-semibold shadow-md',
icon: 'i-lucide-grape'
},
'verde': {
class: 'bg-gradient-to-r from-green-600 via-green-500 to-yellow-500 text-white font-semibold shadow-md',
icon: 'i-lucide-leaf'
},
'mojado': {
class: 'bg-gradient-to-r from-cyan-400 via-blue-300 to-stone-100 text-gray-800 font-semibold shadow-md',
icon: 'i-lucide-droplet'
},
'oreado': {
class: 'bg-gradient-to-r from-yellow-700 via-amber-300 to-stone-50 text-gray-800 font-semibold shadow-md',
icon: 'i-lucide-wind'
},
'seco': {
class: 'bg-gradient-to-r from-stone-400 to-stone-200 text-gray-800 font-semibold shadow-md',
icon: 'i-lucide-sun'
}
}
const config = tipoConfig[value.toLowerCase()] || { class: 'bg-gray-500 text-white', icon: 'i-lucide-circle' }
return h('div', { class: 'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full ' + config.class }, [
h(UIcon, { name: config.icon, class: 'w-4 h-4' }),
h('span', {}, upperFirst(value))
])
}
// estado - badge con colores y esquinas puntiagudas
if (column === 'estado' && typeof value === 'string') {
const estadoConfig: Record<string, { color: string, icon: string }> = {
'pendiente': { color: 'neutral', icon: 'i-lucide-clock' },
'pagado': { color: 'success', icon: 'i-lucide-check-circle' },
'anulado': { color: 'error', icon: 'i-lucide-x-circle' }
}
const config = estadoConfig[value.toLowerCase()] || { color: 'neutral', icon: 'i-lucide-circle' }
return h(UBadge, {
label: upperFirst(value),
color: config.color,
variant: 'subtle',
size: 'md',
leadingIcon: config.icon,
class: 'rounded-sm'
})
}
// Booleanos - iconos representativos
if (column === 'lectorTarjeta' && typeof value === 'boolean') {
return h(UIcon, {
name: value ? 'i-lucide-credit-card' : 'i-lucide-ban',
class: value ? 'text-green-600 w-5 h-5' : 'text-gray-400 w-5 h-5'
})
}
if (column === 'traido' && typeof value === 'boolean') {
return h(UIcon, {
name: value ? 'i-lucide-package-check' : 'i-lucide-package-x',
class: value ? 'text-blue-600 w-5 h-5' : 'text-gray-400 w-5 h-5'
})
}
if (column === 'autoPeso' && typeof value === 'boolean') {
return h(UIcon, {
name: value ? 'i-lucide-scale-3d' : 'i-lucide-minus-circle',
class: value ? 'text-purple-600 w-5 h-5' : 'text-gray-400 w-5 h-5'
})
}
if (column === 'anulado' && typeof value === 'boolean') {
return h(UIcon, {
name: value ? 'i-lucide-x-circle' : 'i-lucide-check-circle',
class: value ? 'text-red-600 w-5 h-5' : 'text-green-600 w-5 h-5'
})
}
// precio - formato lempiras con decimales y comas
if (column === 'precio' && typeof value === 'number') {
return `L. ${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
// humedad - formato porcentaje
if (column === 'humedad' && typeof value === 'number') {
return `${value.toFixed(2)}%`
}
// Formatear números con decimales (para otros campos numéricos)
if (typeof value === 'number') {
return value.toLocaleString('en-US', { maximumFractionDigits: 2 })
}
// Formatear fechas
if (typeof value === 'string' && value.includes('T')) {
try {
const date = new Date(value)
if (dateFormat.value === 'long') {
return date.toLocaleDateString('es-HN', {
day: 'numeric',
month: 'long',
year: 'numeric'
})
}
return date.toLocaleDateString('es-HN')
} catch {
return String(value)
}
}
// Para todo lo demás, convertir a string
return String(value).substring(0, 100)
}
</script>