Files
analiticaNucleo/nuxt4-app/app/components/clientes/VistaTablaClientes.vue
josedario87 aa76fea286 Refactor: Adaptar todos los componentes al sistema de temas
- Reemplazar colores hardcoded del tema café con variables --brand-*
  - #c08040 → var(--brand-primary-strong)
  - #d99a56 → var(--brand-primary)
  - #f0c07c → var(--brand-accent)
  - #1c140c → var(--brand-surface)
  - #3a2a16 → var(--brand-border)
  - #1b1209, #14100b → var(--brand-bg)

- Reemplazar colores de tipos de café con variables --coffee-*
  - #a855f7 → var(--coffee-uva)
  - #f97316 → var(--coffee-oreado)
  - #06b6d4 → var(--coffee-mojado)
  - #22c55e → var(--coffee-verde)

- Reemplazar clases gray-scale de Tailwind con variables de tema
  - text-gray-400, text-gray-500 → text-[var(--brand-text-muted)]
  - bg-gray-700/30 → bg-[var(--brand-surface)]

- Todos los componentes ahora responden dinámicamente a cambios de tema

Archivos adaptados:
- Páginas: error, informe-ingresos, panorama, explorer, metabase-debug, profile, notifications, settings
- Componentes de ingresos: GraficaSerieIngresos, GraficaSerieInversion, GraficaDinamicaPagadoDeposito, GraficaAcumuladoresUva, TotalesIngresoCompra, TotalesMonetarios, TotalesVerde, SecosVendidos, TopClientes, VistaTablaIngresos, VistaTablaIngresosConClientes, FiltrosActivos
- Componentes de comparativa: CosechasHeatmap, CosechasPorTipo, CosechasEvolucion, CosechasTotales
- Componentes de UI: ClienteSelector, DateRangeSelector, MetadatosCard, MaintenanceMode
- Componentes de auth: UserAvatar, UserMetadata
- Componentes de clientes: ClienteCard, VistaTablaClientes
- Componentes de rechazos: RechazoCard, RechazosRechazoCard, RechazosSubproductos
- Componentes de metabase: MetabaseCardDisplay, MetabaseCardsTable
2025-10-30 17:54:42 -06:00

550 lines
17 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-yellow-500">Vista Tabla de Clientes</h2>
<p class="text-sm text-[var(--brand-text-muted)] mt-1">
{{ props.records.length }} clientes registrados
</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"
/>
<UCheckbox
v-if="props.ingresos && props.ingresos.length > 0"
v-model="includeClientesWithoutIngresos"
label="Incluir sin ingresos"
size="sm"
/>
</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-yellow-500/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 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 ClienteWithChildren extends ClienteRecord {
children?: IngresoRecord[]
peso_seco?: number
peso_neto?: number
precio?: number
estado?: string
}
interface Props {
records: ClienteRecord[]
ingresos?: IngresoRecord[]
}
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')
const expanded = ref<Record<string, boolean>>({})
const includeClientesWithoutIngresos = ref(false)
function toggleDateFormat() {
dateFormat.value = dateFormat.value === 'short' ? 'long' : 'short'
}
// Map clientes to include ingresos as children and aggregate data
const recordsWithChildren = computed((): ClienteWithChildren[] => {
if (!props.ingresos || props.ingresos.length === 0) {
return props.records as ClienteWithChildren[]
}
const clientesData = props.records.map(cliente => {
const clienteIngresos = props.ingresos?.filter(i => i.cliente_id === cliente.id) || []
// Agregar campos agregados al cliente
const peso_seco = clienteIngresos.reduce((sum, i) => sum + (i.peso_seco || 0), 0)
const peso_neto = clienteIngresos.reduce((sum, i) => sum + (i.peso_neto || 0), 0)
// Precio promedio ponderado (por peso_neto)
const totalPrecioXPeso = clienteIngresos.reduce((sum, i) => sum + (i.precio * i.peso_neto), 0)
const precio = peso_neto > 0 ? totalPrecioXPeso / peso_neto : 0
// Estados únicos
const estados = [...new Set(clienteIngresos.map(i => i.estado))]
return {
...cliente,
peso_seco,
peso_neto,
precio,
estado: estados.length > 0 ? estados.join(', ') : '',
children: clienteIngresos
}
})
// Filter out clientes without ingresos if toggle is OFF
if (!includeClientesWithoutIngresos.value) {
return clientesData.filter(cliente => cliente.children && cliente.children.length > 0)
}
return clientesData
})
// 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)
})
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--
}
}
// Columnas seleccionadas
const selectedColumns = [
'id',
'name',
'cedula',
'telefono',
'ubicacion',
'grupo_estudio',
'empleado',
'idciat',
'peso_seco',
'peso_neto',
'precio',
'estado',
'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 => {
// Siempre incluir columnas de cliente base
const baseColumns = ['id', 'name', 'cedula', 'telefono', 'ubicacion', 'grupo_estudio', 'empleado', 'idciat', 'created_at', 'updated_at']
if (baseColumns.includes(col)) return true
// Incluir columnas agregadas si hay ingresos
if (props.ingresos && props.ingresos.length > 0 && ['peso_seco', 'peso_neto', 'precio', 'estado'].includes(col)) {
return true
}
return 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-yellow-100',
onClick: () => tableColumn.toggleSorting(tableColumn.getIsSorted() === 'asc')
})
},
cell: ({ row }) => {
const original = row.original as any
const isIngreso = 'tipo' in original
const isCliente = 'name' in original && !('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.ingresos && props.ingresos.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
isCliente && h(UBadge, {
label: `C-${row.getValue('id')}`,
color: 'primary',
variant: 'subtle',
size: 'md',
class: 'rounded-full'
}),
isIngreso && h(UBadge, {
label: `I-${row.getValue('id')}`,
color: 'info',
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-yellow-100',
onClick: () => tableColumn.toggleSorting(tableColumn.getIsSorted() === 'asc')
})
},
cell: ({ row }) => formatCellValue(row.getValue(column), column, row.original as any, row.depth)
}
})
})
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 ingreso (child row)
const isIngreso = 'tipo' in row
const isCliente = 'name' in row && !('tipo' in row)
// Si es una fila de ingreso expandida, mostrar info del ingreso
if (isIngreso && depth > 0) {
if (column === 'name') {
return h('div', { class: 'flex items-center gap-2' }, [
h('span', {
class: row.tipo === 'uva' ? 'text-purple-400' :
row.tipo === 'oreado' ? 'text-orange-400' :
row.tipo === 'mojado' ? 'text-blue-400' : 'text-green-400'
}, row.tipo?.toUpperCase())
])
}
if (column === 'peso_seco' && typeof row.peso_seco === 'number') {
return h('div', { class: 'text-right font-medium text-cyan-400' }, row.peso_seco.toFixed(2))
}
if (column === 'peso_neto' && typeof row.peso_neto === 'number') {
return h('div', { class: 'text-right font-medium text-cyan-400' }, row.peso_neto.toFixed(2))
}
if (column === 'precio' && typeof row.precio === 'number') {
const formatted = new Intl.NumberFormat('es-HN', {
style: 'currency',
currency: 'HNL',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(row.precio).replace('HNL', 'L')
return h('div', { class: 'text-right font-medium text-cyan-400' }, formatted)
}
if (column === 'estado' && typeof row.estado === 'string') {
return h('span', {
class: row.estado === 'pagado'
? 'inline-flex items-center gap-1 px-2 py-0.5 rounded bg-green-500/20 text-green-300 border border-green-400/30 text-xs'
: 'inline-flex items-center gap-1 px-2 py-0.5 rounded bg-yellow-500/20 text-yellow-300 border border-yellow-400/30 text-xs'
}, row.estado === 'pagado' ? '✓ Pagado' : '⏳ Pendiente')
}
if (column === 'created_at' && typeof row.created_at === 'string') {
const date = new Date(row.created_at)
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)
}
return '—'
}
if (value === null || value === undefined) {
return '—'
}
// 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' && isCliente) {
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-[var(--brand-text-muted)]'
}),
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-[var(--brand-text-muted)] 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'
})
}
// Campos agregados de ingresos
if (column === 'peso_seco' && typeof value === 'number' && depth === 0) {
return h('div', { class: 'text-right font-medium text-yellow-500' }, value.toFixed(2))
}
if (column === 'peso_neto' && typeof value === 'number' && depth === 0) {
return h('div', { class: 'text-right font-medium text-yellow-500' }, value.toFixed(2))
}
if (column === 'precio' && typeof value === 'number' && depth === 0) {
const formatted = new Intl.NumberFormat('es-HN', {
style: 'currency',
currency: 'HNL',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value).replace('HNL', 'L')
return h('div', { class: 'text-right font-medium text-yellow-500' }, formatted)
}
if (column === 'estado' && typeof value === 'string' && depth === 0) {
const estados = value.split(', ')
return h('div', { class: 'flex flex-wrap gap-1' }, estados.map((estado: string) =>
h('span', {
class: estado === 'pagado'
? 'inline-flex items-center gap-1 px-2 py-0.5 rounded bg-green-500/20 text-green-300 border border-green-400/30 text-xs'
: 'inline-flex items-center gap-1 px-2 py-0.5 rounded bg-yellow-500/20 text-yellow-300 border border-yellow-400/30 text-xs'
}, estado === 'pagado' ? '✓ Pagado' : '⏳ Pendiente')
))
}
// 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>