database view dinamico y personalizado completo
This commit is contained in:
334
nuxt4-app/app/components/clientes/VistaTablaClientes.vue
Normal file
334
nuxt4-app/app/components/clientes/VistaTablaClientes.vue
Normal file
@@ -0,0 +1,334 @@
|
||||
<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 Clientes</h2>
|
||||
<p class="text-sm text-[var(--brand-text-muted)] mt-1">
|
||||
{{ props.records.length }} clientes registrados
|
||||
</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'
|
||||
|
||||
interface ClienteRecord extends Record<string, unknown> {
|
||||
id: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
name: string
|
||||
cedula?: number
|
||||
ubicacion?: string
|
||||
grupo_estudio?: string
|
||||
empleado?: boolean
|
||||
avatar_url?: string
|
||||
telefono?: string
|
||||
idciat?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
records: ClienteRecord[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const UButton = resolveComponent('UButton')
|
||||
const UBadge = resolveComponent('UBadge')
|
||||
const UIcon = resolveComponent('UIcon')
|
||||
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')
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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--
|
||||
}
|
||||
}
|
||||
|
||||
// Columnas seleccionadas
|
||||
const selectedColumns = [
|
||||
'id',
|
||||
'name',
|
||||
'cedula',
|
||||
'telefono',
|
||||
'ubicacion',
|
||||
'grupo_estudio',
|
||||
'empleado',
|
||||
'idciat',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
]
|
||||
|
||||
const tableColumns = computed((): TableColumn<Record<string, unknown>>[] => {
|
||||
if (!limitedRecords.value.length) return []
|
||||
|
||||
const firstRow = limitedRecords.value[0]
|
||||
if (!firstRow) return []
|
||||
|
||||
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, row.original as ClienteRecord)
|
||||
}))
|
||||
})
|
||||
|
||||
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: ClienteRecord): any {
|
||||
if (value === null || value === undefined) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
// ID column - badge con formato C-####
|
||||
if (column === 'id' && typeof value === 'number') {
|
||||
return h(UBadge, {
|
||||
label: `C-${value}`,
|
||||
color: 'primary',
|
||||
variant: 'subtle',
|
||||
size: 'md',
|
||||
class: 'rounded-full'
|
||||
})
|
||||
}
|
||||
|
||||
// idciat - badge con formato CIAT-####
|
||||
if (column === 'idciat' && typeof value === 'number') {
|
||||
return h(UBadge, {
|
||||
label: `CIAT-${value}`,
|
||||
color: 'info',
|
||||
variant: 'subtle',
|
||||
size: 'md',
|
||||
class: 'rounded-full'
|
||||
})
|
||||
}
|
||||
|
||||
// name - con avatar si está disponible
|
||||
if (column === 'name' && typeof value === 'string') {
|
||||
return h('div', { class: 'flex items-center gap-2' }, [
|
||||
row.avatar_url ? h(UAvatar, {
|
||||
src: row.avatar_url,
|
||||
alt: value,
|
||||
size: 'xs'
|
||||
}) : h(UIcon, {
|
||||
name: 'i-lucide-user-circle',
|
||||
class: 'w-6 h-6 text-gray-400'
|
||||
}),
|
||||
h('span', { class: 'font-medium' }, value)
|
||||
])
|
||||
}
|
||||
|
||||
// empleado - icono booleano
|
||||
if (column === 'empleado' && typeof value === 'boolean') {
|
||||
return h(UIcon, {
|
||||
name: value ? 'i-lucide-briefcase' : 'i-lucide-user',
|
||||
class: value ? 'text-purple-600 w-5 h-5' : 'text-gray-400 w-5 h-5'
|
||||
})
|
||||
}
|
||||
|
||||
// cedula - formato especial
|
||||
if (column === 'cedula' && typeof value === 'number') {
|
||||
return h(UBadge, {
|
||||
label: String(value),
|
||||
color: 'neutral',
|
||||
variant: 'soft',
|
||||
size: 'sm',
|
||||
class: 'font-mono'
|
||||
})
|
||||
}
|
||||
|
||||
// telefono - con icono
|
||||
if (column === 'telefono' && typeof value === 'string') {
|
||||
return h('div', { class: 'flex items-center gap-1.5' }, [
|
||||
h(UIcon, {
|
||||
name: 'i-lucide-phone',
|
||||
class: 'w-4 h-4 text-green-600'
|
||||
}),
|
||||
h('span', { class: 'font-mono text-sm' }, value)
|
||||
])
|
||||
}
|
||||
|
||||
// ubicacion - con icono
|
||||
if (column === 'ubicacion' && typeof value === 'string') {
|
||||
return h('div', { class: 'flex items-center gap-1.5' }, [
|
||||
h(UIcon, {
|
||||
name: 'i-lucide-map-pin',
|
||||
class: 'w-4 h-4 text-red-500'
|
||||
}),
|
||||
h('span', { class: 'text-sm' }, value)
|
||||
])
|
||||
}
|
||||
|
||||
// grupo_estudio - badge
|
||||
if (column === 'grupo_estudio' && typeof value === 'string') {
|
||||
return h(UBadge, {
|
||||
label: value,
|
||||
color: 'secondary',
|
||||
variant: 'subtle',
|
||||
size: 'sm'
|
||||
})
|
||||
}
|
||||
|
||||
// Formatear fechas
|
||||
if ((column === 'created_at' || column === 'updated_at') && 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)
|
||||
}
|
||||
}
|
||||
|
||||
return String(value).substring(0, 100)
|
||||
}
|
||||
</script>
|
||||
372
nuxt4-app/app/components/ingresos/VistaTablaIngresos.vue
Normal file
372
nuxt4-app/app/components/ingresos/VistaTablaIngresos.vue
Normal 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>
|
||||
@@ -2,11 +2,14 @@ import { computed } from 'vue'
|
||||
import type { ComputedRef } from 'vue'
|
||||
|
||||
export interface IngresoRecord {
|
||||
cliente_id: number
|
||||
estado: 'pagado' | 'pendiente'
|
||||
tipo: 'uva' | 'oreado' | 'mojado' | 'verde'
|
||||
peso_seco: number
|
||||
peso_neto: number
|
||||
precio: number
|
||||
created_at?: string
|
||||
[key: string]: any // Para permitir otras propiedades dinámicas
|
||||
}
|
||||
|
||||
export interface IngresosMetrics {
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-8">
|
||||
<!-- Loading State -->
|
||||
<UCard v-if="loading && !ingresosStore.hasData" class="brand-card border border-transparent">
|
||||
<div class="flex flex-col items-center justify-center gap-4 py-10 text-[var(--brand-text-muted)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex h-8 w-8 animate-spin rounded-full border-2 border-[#c08040] border-t-transparent align-middle" aria-hidden="true" />
|
||||
<span class="text-sm uppercase tracking-[0.3em]">Cargando datos...</span>
|
||||
|
||||
<div class="flex flex-col gap-8 p-6">
|
||||
<!-- Loading State -->
|
||||
<UCard v-if="loading && !ingresosStore.hasData" class="brand-card border border-transparent">
|
||||
<div class="flex flex-col items-center justify-center gap-4 py-10 text-[var(--brand-text-muted)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex h-8 w-8 animate-spin rounded-full border-2 border-[#c08040] border-t-transparent align-middle" aria-hidden="true" />
|
||||
<span class="text-sm uppercase tracking-[0.3em]">Cargando datos...</span>
|
||||
</div>
|
||||
<UProgress
|
||||
v-if="loadingProgress > 0"
|
||||
:model-value="loadingProgress"
|
||||
:max="100"
|
||||
size="sm"
|
||||
class="w-64"
|
||||
/>
|
||||
<span v-if="loadingProgress > 0" class="text-xs text-[var(--brand-text-muted)]">
|
||||
{{ Math.round(loadingProgress) }}%
|
||||
</span>
|
||||
</div>
|
||||
<UProgress
|
||||
v-if="loadingProgress > 0"
|
||||
:model-value="loadingProgress"
|
||||
:max="100"
|
||||
size="sm"
|
||||
class="w-64"
|
||||
/>
|
||||
<span v-if="loadingProgress > 0" class="text-xs text-[var(--brand-text-muted)]">
|
||||
{{ Math.round(loadingProgress) }}%
|
||||
</span>
|
||||
</UCard>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="rounded-lg border border-red-500/40 bg-red-500/18 p-4 text-sm text-red-200">
|
||||
<p>Error al cargar datos: {{ error }}</p>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="rounded-lg border border-red-500/40 bg-red-500/18 p-4 text-sm text-red-200">
|
||||
<p>Error al cargar datos: {{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<template v-else>
|
||||
<!-- Main Content -->
|
||||
<template v-else>
|
||||
<!-- Metadatos Cards de Ingresos y Clientes -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<MetadatosCard v-if="ingresosMetadata" :metadata="ingresosMetadata" />
|
||||
@@ -37,12 +38,27 @@
|
||||
<UCard class="brand-card border border-transparent">
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold brand-section-title">Filtros y Configuraciones</h2>
|
||||
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
|
||||
Filtros aplicados a ingresos por fecha y cliente
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold brand-section-title">Filtros y Configuraciones</h2>
|
||||
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
|
||||
Filtros aplicados a ingresos por fecha y cliente
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UCheckbox v-model="includeAnulados" label="Incluir anulados" @update:model-value="onToggleAnulados" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alerta roja cuando incluye anulados -->
|
||||
<UAlert
|
||||
v-if="includeAnulados"
|
||||
color="error"
|
||||
variant="solid"
|
||||
icon="i-lucide-alert-triangle"
|
||||
title="Incluir anulados activado"
|
||||
description="Los cálculos incluyen registros anulados. Esto puede afectar los resultados financieros."
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -85,6 +101,26 @@
|
||||
|
||||
<!-- Totales de Ingreso y Compra -->
|
||||
<IngresosTotalesIngresoCompra :metrics="ingresosMetrics" />
|
||||
|
||||
<!-- Vista Tabla según tab activo -->
|
||||
<UCard class="brand-card border border-transparent">
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold brand-section-title">{{ tableTitle }}</h2>
|
||||
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
|
||||
{{ tableDescription }}
|
||||
</p>
|
||||
</div>
|
||||
<div ref="tabsRef">
|
||||
<UTabs v-model="activeTab" :items="tabItems" :content="false" variant="link" size="sm" class="flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<IngresosVistaTablaIngresos v-if="activeTab === 'ingresos'" :records="ingresosFiltrados" />
|
||||
<ClientesVistaTablaClientes v-else-if="activeTab === 'clientes'" :records="clientesFiltrados" />
|
||||
</UCard>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -94,6 +130,7 @@ import { useTableDataStore } from '~/stores/tableDataFactory'
|
||||
import { useMetadataStore } from '~/stores/metadata'
|
||||
import { useIngresosMetrics } from '~/composables/useIngresosMetrics'
|
||||
import type { IngresoRecord } from '~/composables/useIngresosMetrics'
|
||||
import type { TabsItem } from '@nuxt/ui'
|
||||
|
||||
// Define page metadata
|
||||
definePageMeta({
|
||||
@@ -101,17 +138,74 @@ definePageMeta({
|
||||
title: 'Cuenta Cliente'
|
||||
})
|
||||
|
||||
// Tabs
|
||||
const activeTab = ref<'ingresos' | 'clientes'>('ingresos')
|
||||
|
||||
const tabItems: TabsItem[] = [
|
||||
{
|
||||
label: 'Ingresos',
|
||||
value: 'ingresos',
|
||||
icon: 'i-lucide-trending-up'
|
||||
},
|
||||
{
|
||||
label: 'Clientes',
|
||||
value: 'clientes',
|
||||
icon: 'i-lucide-users'
|
||||
}
|
||||
]
|
||||
|
||||
// Dynamic table title and description
|
||||
const tableTitle = computed(() => {
|
||||
return activeTab.value === 'ingresos' ? 'Tabla de Ingresos' : 'Tabla de Clientes'
|
||||
})
|
||||
|
||||
const tableDescription = computed(() => {
|
||||
if (activeTab.value === 'ingresos') {
|
||||
return `Mostrando ${ingresosFiltrados.value.length} registros de ingresos`
|
||||
} else {
|
||||
return `Mostrando ${clientesFiltrados.value.length} clientes`
|
||||
}
|
||||
})
|
||||
|
||||
// Ref for tabs element
|
||||
const tabsRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Watch for tab changes and scroll to keep the tabs in view
|
||||
watch(activeTab, () => {
|
||||
nextTick(() => {
|
||||
if (tabsRef.value) {
|
||||
tabsRef.value.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
interface ClienteRecord extends Record<string, unknown> {
|
||||
id: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
name: string
|
||||
cedula?: number
|
||||
ubicacion?: string
|
||||
grupo_estudio?: string
|
||||
empleado?: boolean
|
||||
avatar_url?: string
|
||||
telefono?: string
|
||||
idciat?: number
|
||||
}
|
||||
|
||||
// Initialize stores
|
||||
const ingresosStore = useTableDataStore<IngresoRecord>('ingresos')
|
||||
const clientesStore = useTableDataStore<any>('clientes')
|
||||
const clientesStore = useTableDataStore<ClienteRecord>('clientes')
|
||||
|
||||
// Reactive data from stores
|
||||
const ingresos = computed(() => ingresosStore.allRecords as IngresoRecord[])
|
||||
const clientes = computed(() => clientesStore.allRecords)
|
||||
const clientes = computed(() => clientesStore.allRecords as ClienteRecord[])
|
||||
|
||||
// -------------------------------
|
||||
// Filtros
|
||||
// -------------------------------
|
||||
const includeAnulados = ref(false)
|
||||
|
||||
type PresetValue =
|
||||
| '' | 'custom' | 'hoy' | 'semana' | 'mes' | 'ytd'
|
||||
| 'cosecha-20-21' | 'cosecha-21-22' | 'cosecha-22-23'
|
||||
@@ -122,6 +216,29 @@ const fechaDesde = ref<string | null>(null)
|
||||
const fechaHasta = ref<string | null>(null)
|
||||
const selectedClienteIds = ref<number[]>([])
|
||||
|
||||
async function onToggleAnulados(newValue: boolean | 'indeterminate') {
|
||||
if (newValue === true) {
|
||||
// Pedir confirmación al activar
|
||||
const confirmed = confirm(
|
||||
'⚠️ ADVERTENCIA\n\n' +
|
||||
'Está a punto de incluir registros ANULADOS en los cálculos.\n\n' +
|
||||
'Esto puede afectar significativamente los resultados financieros y métricas.\n\n' +
|
||||
'¿Está seguro de que desea continuar?'
|
||||
)
|
||||
|
||||
if (!confirmed) {
|
||||
// Si cancela, revertir el cambio
|
||||
includeAnulados.value = false
|
||||
console.log('User cancelled including anulados')
|
||||
} else {
|
||||
console.log('User confirmed including anulados')
|
||||
}
|
||||
} else {
|
||||
// Al desactivar, no pedir confirmación
|
||||
console.log('Anulados disabled')
|
||||
}
|
||||
}
|
||||
|
||||
const rangoLegible = computed(() => {
|
||||
if (!fechaDesde.value && !fechaHasta.value) return 'Sin filtro de fecha'
|
||||
const f = fechaDesde.value ?? '—'
|
||||
@@ -129,6 +246,12 @@ const rangoLegible = computed(() => {
|
||||
return `${f} → ${t}`
|
||||
})
|
||||
|
||||
function isAnulado(row: any): boolean {
|
||||
const estado = (row?.estado ?? '').toString().toLowerCase()
|
||||
const fechaAn = row?.fecha_anulado ?? null
|
||||
return estado === 'anulado' || !!fechaAn
|
||||
}
|
||||
|
||||
function isWithinDate(row: any, from?: string | null, to?: string | null): boolean {
|
||||
const created = row?.created_at ? new Date(row.created_at) : null
|
||||
if (!created || isNaN(created.getTime())) return false
|
||||
@@ -153,10 +276,17 @@ function isClienteSelected(clienteId: number): boolean {
|
||||
// Filtrados que alimentan los métricos
|
||||
const ingresosFiltrados = computed(() => {
|
||||
return (ingresos.value ?? [])
|
||||
.filter(r => (includeAnulados.value ? true : !isAnulado(r)))
|
||||
.filter(r => isWithinDate(r, fechaDesde.value, fechaHasta.value))
|
||||
.filter(r => isClienteSelected(r.cliente_id))
|
||||
})
|
||||
|
||||
const clientesFiltrados = computed((): ClienteRecord[] => {
|
||||
// Si hay clientes seleccionados, filtrar por ellos
|
||||
if (selectedClienteIds.value.length === 0) return clientes.value ?? []
|
||||
return (clientes.value ?? []).filter(c => selectedClienteIds.value.includes(c.id))
|
||||
})
|
||||
|
||||
// Métricos basados en filtrados
|
||||
const ingresosMetrics = useIngresosMetrics(ingresosFiltrados)
|
||||
|
||||
@@ -182,40 +312,41 @@ const clientesMetadata = computed(() => {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Cargar metadatos primero
|
||||
if (metadataStore.metadata.length === 0) {
|
||||
await (metadataStore as any).loadMetadata()
|
||||
}
|
||||
// if (metadataStore.metadata.length === 0) {
|
||||
// await (metadataStore as any).loadMetadata()
|
||||
// }
|
||||
|
||||
// Cache primero para UX
|
||||
await Promise.all([
|
||||
ingresosStore.loadFromCache(),
|
||||
clientesStore.loadFromCache()
|
||||
])
|
||||
// await Promise.all([
|
||||
// ingresosStore.loadFromCache(),
|
||||
// clientesStore.loadFromCache()
|
||||
// ])
|
||||
|
||||
// Si falta data, cargar en lotes
|
||||
if (!ingresosStore.hasData || !clientesStore.hasData) {
|
||||
loadingProgress.value = 0
|
||||
let ingresosProgress = 0
|
||||
let clientesProgress = 0
|
||||
// if (!ingresosStore.hasData || !clientesStore.hasData) {
|
||||
// loadingProgress.value = 0
|
||||
// let ingresosProgress = 0
|
||||
// let clientesProgress = 0
|
||||
|
||||
await Promise.all([
|
||||
ingresosStore.loadAllDataInBatches((progress) => {
|
||||
ingresosProgress = progress
|
||||
loadingProgress.value = (ingresosProgress + clientesProgress) / 2
|
||||
}),
|
||||
clientesStore.loadAllDataInBatches((progress) => {
|
||||
clientesProgress = progress
|
||||
loadingProgress.value = (ingresosProgress + clientesProgress) / 2
|
||||
})
|
||||
])
|
||||
// await Promise.all([
|
||||
// ingresosStore.loadAllDataInBatches((progress) => {
|
||||
// ingresosProgress = progress
|
||||
// loadingProgress.value = (ingresosProgress + clientesProgress) / 2
|
||||
// }),
|
||||
// clientesStore.loadAllDataInBatches((progress) => {
|
||||
// clientesProgress = progress
|
||||
// loadingProgress.value = (ingresosProgress + clientesProgress) / 2
|
||||
// })
|
||||
// ])
|
||||
|
||||
loadingProgress.value = 0
|
||||
}
|
||||
// loadingProgress.value = 0
|
||||
// }
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
} finally {
|
||||
// Default preset: cosecha 25-26
|
||||
selectedPreset.value = 'cosecha-25-26'
|
||||
includeAnulados.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user