607 lines
18 KiB
Vue
607 lines
18 KiB
Vue
<template>
|
|
<UCard class="brand-card border border-transparent">
|
|
<template #header>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h2 class="text-xl font-bold text-cyan-400">Vista Tabla de Ingresos</h2>
|
|
<p class="text-sm text-[var(--brand-text-muted)] mt-1">
|
|
{{ props.records.length }} registros filtrados
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<UButton
|
|
:label="dateFormat === 'short' ? 'Fecha Larga' : 'Fecha Corta'"
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-calendar"
|
|
@click="toggleDateFormat"
|
|
/>
|
|
</div>
|
|
</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"
|
|
v-model:expanded="expanded"
|
|
:data="limitedRecords"
|
|
:columns="tableColumns"
|
|
:global-filter="globalFilter"
|
|
:get-sub-rows="(row: any) => row.children"
|
|
sticky
|
|
class="h-96"
|
|
:ui="{
|
|
thead: 'bg-cyan-400/20 [&>tr>th]:text-white [&>tr>th]:font-semibold',
|
|
base: 'border-separate border-spacing-0',
|
|
tbody: '[&>tr]:last:[&>td]:border-b-0',
|
|
tr: 'group',
|
|
td: 'empty:p-0 group-has-[td:not(:empty)]:border-b border-default'
|
|
}"
|
|
/>
|
|
|
|
<!-- 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 ClienteRecord {
|
|
id: number
|
|
name: string
|
|
cedula?: number
|
|
ubicacion?: string
|
|
telefono?: string
|
|
grupo_estudio?: string
|
|
empleado?: boolean
|
|
avatar_url?: string
|
|
idciat?: number
|
|
[key: string]: any
|
|
}
|
|
|
|
interface IngresoWithChildren extends IngresoRecord {
|
|
children?: ClienteRecord[]
|
|
}
|
|
|
|
interface Props {
|
|
records: IngresoRecord[]
|
|
clientes?: ClienteRecord[]
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
|
|
const UButton = resolveComponent('UButton')
|
|
const UBadge = resolveComponent('UBadge')
|
|
const UIcon = resolveComponent('UIcon')
|
|
const UTooltip = resolveComponent('UTooltip')
|
|
const UAvatar = resolveComponent('UAvatar')
|
|
|
|
const globalFilter = ref('')
|
|
const table = useTemplateRef<{ tableApi?: any }>('table')
|
|
const currentPage = ref(1)
|
|
const recordsPerPage = 100
|
|
const dateFormat = ref<'short' | 'long'>('short')
|
|
const expanded = ref<Record<string, boolean>>({})
|
|
|
|
function toggleDateFormat() {
|
|
dateFormat.value = dateFormat.value === 'short' ? 'long' : 'short'
|
|
}
|
|
|
|
// Map records to include cliente as children
|
|
const recordsWithChildren = computed((): IngresoWithChildren[] => {
|
|
if (!props.clientes || props.clientes.length === 0) {
|
|
return props.records as IngresoWithChildren[]
|
|
}
|
|
|
|
return props.records.map(ingreso => {
|
|
const cliente = props.clientes?.find(c => c.id === ingreso.cliente_id)
|
|
return {
|
|
...ingreso,
|
|
children: cliente ? [cliente] : []
|
|
}
|
|
})
|
|
})
|
|
|
|
// Paginación
|
|
const totalRecords = computed(() => recordsWithChildren.value.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 recordsWithChildren.value.slice(start, end)
|
|
})
|
|
|
|
function nextPage() {
|
|
if (currentPage.value < totalPages.value) {
|
|
currentPage.value++
|
|
}
|
|
}
|
|
|
|
function previousPage() {
|
|
if (currentPage.value > 1) {
|
|
currentPage.value--
|
|
}
|
|
}
|
|
|
|
// Seleccionar columnas importantes manualmente
|
|
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
|
|
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) => {
|
|
// Columna ID especial con botón de expansión
|
|
if (column === 'id') {
|
|
return {
|
|
accessorKey: column,
|
|
header: ({ column: tableColumn }) => {
|
|
const isSorted = tableColumn.getIsSorted()
|
|
return h(UButton, {
|
|
color: 'neutral',
|
|
variant: 'ghost',
|
|
label: '#',
|
|
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 text-white hover:text-cyan-100',
|
|
onClick: () => tableColumn.toggleSorting(tableColumn.getIsSorted() === 'asc')
|
|
})
|
|
},
|
|
cell: ({ row }) => {
|
|
const original = row.original as any
|
|
const isCliente = 'name' in original && !('tipo' in original)
|
|
const isIngreso = 'tipo' in original
|
|
const isParent = row.depth === 0
|
|
|
|
return h(
|
|
'div',
|
|
{
|
|
style: {
|
|
paddingLeft: `${row.depth * 2}rem`
|
|
},
|
|
class: 'flex items-center gap-2'
|
|
},
|
|
[
|
|
// Botón de expansión solo en parent rows
|
|
isParent && props.clientes && props.clientes.length > 0 && h(UButton, {
|
|
color: 'neutral',
|
|
variant: 'outline',
|
|
size: 'xs',
|
|
icon: row.getIsExpanded() ? 'i-lucide-minus' : 'i-lucide-plus',
|
|
class: !row.getCanExpand() && 'invisible',
|
|
ui: {
|
|
base: 'p-0 rounded-sm',
|
|
leadingIcon: 'size-4'
|
|
},
|
|
onClick: row.getToggleExpandedHandler()
|
|
}),
|
|
// Badge de ID
|
|
isIngreso && h(UBadge, {
|
|
label: `I-${row.getValue('id')}`,
|
|
color: 'primary',
|
|
variant: 'subtle',
|
|
size: 'md',
|
|
class: 'rounded-full'
|
|
}),
|
|
isCliente && h(UBadge, {
|
|
label: `C-${row.getValue('id')}`,
|
|
color: 'warning',
|
|
variant: 'subtle',
|
|
size: 'md',
|
|
class: 'rounded-full'
|
|
})
|
|
]
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Otras columnas normales
|
|
return {
|
|
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 text-white hover:text-cyan-100',
|
|
onClick: () => tableColumn.toggleSorting(tableColumn.getIsSorted() === 'asc')
|
|
})
|
|
},
|
|
cell: ({ row }) => formatCellValue(row.getValue(column), column, row.original as any, row.depth)
|
|
}
|
|
})
|
|
})
|
|
|
|
// 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, row: any, depth: number): any {
|
|
// Detectar si es cliente (child row)
|
|
const isCliente = 'name' in row && !('tipo' in row)
|
|
const isIngreso = 'tipo' in row
|
|
|
|
// Si es una fila de cliente expandida, mostrar info del cliente
|
|
if (isCliente && depth > 0) {
|
|
if (column === 'created_at' || column === 'tipo' || column === 'estado') {
|
|
// Mostrar información del cliente en columnas relevantes
|
|
if (column === 'created_at') {
|
|
return h('div', { class: 'flex flex-col gap-1' }, [
|
|
h('span', { class: 'font-semibold text-yellow-500' }, row.name),
|
|
row.ubicacion && h('span', { class: 'text-xs text-gray-400' }, `📍 ${row.ubicacion}`),
|
|
row.telefono && h('span', { class: 'text-xs text-gray-400' }, `📞 ${row.telefono}`)
|
|
])
|
|
}
|
|
if (column === 'tipo' && row.cedula) {
|
|
return h(UBadge, {
|
|
label: String(row.cedula),
|
|
color: 'neutral',
|
|
variant: 'soft',
|
|
size: 'sm',
|
|
class: 'font-mono'
|
|
})
|
|
}
|
|
if (column === 'estado' && row.empleado !== undefined) {
|
|
return row.empleado
|
|
? h(UIcon, {
|
|
name: 'i-lucide-briefcase',
|
|
class: 'text-purple-600 w-5 h-5'
|
|
})
|
|
: h(UIcon, {
|
|
name: 'i-lucide-user',
|
|
class: 'text-gray-400 w-5 h-5'
|
|
})
|
|
}
|
|
}
|
|
return '—'
|
|
}
|
|
|
|
// Valores null/undefined
|
|
if (value === null || value === undefined) {
|
|
return '—'
|
|
}
|
|
|
|
// 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'
|
|
})
|
|
}
|
|
|
|
// created_at - fecha en bold y un poco más grande
|
|
if (column === 'created_at' && typeof value === 'string' && value.includes('T')) {
|
|
try {
|
|
const date = new Date(value)
|
|
const formattedDate = dateFormat.value === 'long'
|
|
? date.toLocaleDateString('es-HN', { day: 'numeric', month: 'long', year: 'numeric' })
|
|
: date.toLocaleDateString('es-HN')
|
|
return h('span', { class: 'font-bold text-base' }, formattedDate)
|
|
} catch {
|
|
return String(value)
|
|
}
|
|
}
|
|
|
|
// fecha_pagado - fecha en verde
|
|
if (column === 'fecha_pagado' && typeof value === 'string' && value.includes('T')) {
|
|
try {
|
|
const date = new Date(value)
|
|
const formattedDate = dateFormat.value === 'long'
|
|
? date.toLocaleDateString('es-HN', { day: 'numeric', month: 'long', year: 'numeric' })
|
|
: date.toLocaleDateString('es-HN')
|
|
return h('span', { class: 'text-green-500 font-semibold' }, formattedDate)
|
|
} catch {
|
|
return String(value)
|
|
}
|
|
}
|
|
|
|
// cliente_id - Badge neutral soft con ID y nombre truncado
|
|
if (column === 'cliente_id' && typeof value === 'number') {
|
|
const cliente = props.clientes?.find(c => c.id === value)
|
|
const clienteNombre = cliente?.name || 'Sin nombre'
|
|
const fullLabel = `${value} - ${clienteNombre}`
|
|
const shouldTruncate = fullLabel.length > 30
|
|
|
|
const badge = h(UBadge, {
|
|
color: 'neutral',
|
|
variant: 'soft',
|
|
size: 'sm',
|
|
class: shouldTruncate ? 'max-w-[220px]' : ''
|
|
}, {
|
|
default: () => h('span', {
|
|
class: shouldTruncate ? 'truncate block' : ''
|
|
}, fullLabel)
|
|
})
|
|
|
|
// Si está truncado, agregar tooltip con el texto completo
|
|
if (shouldTruncate) {
|
|
return h(UTooltip, {
|
|
text: fullLabel,
|
|
shortcuts: []
|
|
}, {
|
|
default: () => badge
|
|
})
|
|
}
|
|
|
|
return badge
|
|
}
|
|
|
|
// pagado_id - ID en verde
|
|
if (column === 'pagado_id' && typeof value === 'number') {
|
|
return h(UBadge, {
|
|
label: `P-${value}`,
|
|
color: 'success',
|
|
variant: 'subtle',
|
|
size: 'md',
|
|
class: 'rounded-full'
|
|
})
|
|
}
|
|
|
|
// fecha_anulado - fecha en rojo
|
|
if (column === 'fecha_anulado' && typeof value === 'string' && value.includes('T')) {
|
|
try {
|
|
const date = new Date(value)
|
|
const formattedDate = dateFormat.value === 'long'
|
|
? date.toLocaleDateString('es-HN', { day: 'numeric', month: 'long', year: 'numeric' })
|
|
: date.toLocaleDateString('es-HN')
|
|
return h('span', { class: 'text-red-500 font-semibold' }, formattedDate)
|
|
} catch {
|
|
return String(value)
|
|
}
|
|
}
|
|
|
|
// anulador_id - ID en rojo
|
|
if (column === 'anulador_id' && typeof value === 'number') {
|
|
return h(UBadge, {
|
|
label: `A-${value}`,
|
|
color: 'error',
|
|
variant: 'subtle',
|
|
size: 'md',
|
|
class: 'rounded-full'
|
|
})
|
|
}
|
|
|
|
// 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 (genérico para otras 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>
|