mejoras de ui UX demasido intensas
This commit is contained in:
@@ -8,13 +8,21 @@
|
||||
{{ 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 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>
|
||||
|
||||
@@ -46,13 +54,19 @@
|
||||
<!-- 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'
|
||||
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'
|
||||
}"
|
||||
/>
|
||||
|
||||
@@ -95,6 +109,7 @@
|
||||
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
|
||||
@@ -110,8 +125,17 @@ interface ClienteRecord extends Record<string, unknown> {
|
||||
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>()
|
||||
@@ -126,13 +150,53 @@ 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(() => props.records.length)
|
||||
const totalRecords = computed(() => recordsWithChildren.value.length)
|
||||
const totalPages = computed(() => Math.ceil(totalRecords.value / recordsPerPage))
|
||||
|
||||
const startRecord = computed(() => {
|
||||
@@ -148,7 +212,7 @@ const endRecord = computed(() => {
|
||||
const limitedRecords = computed(() => {
|
||||
const start = (currentPage.value - 1) * recordsPerPage
|
||||
const end = start + recordsPerPage
|
||||
return (props.records || []).slice(start, end)
|
||||
return recordsWithChildren.value.slice(start, end)
|
||||
})
|
||||
|
||||
function nextPage() {
|
||||
@@ -173,6 +237,10 @@ const selectedColumns = [
|
||||
'grupo_estudio',
|
||||
'empleado',
|
||||
'idciat',
|
||||
'peso_seco',
|
||||
'peso_neto',
|
||||
'precio',
|
||||
'estado',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
]
|
||||
@@ -183,24 +251,102 @@ const tableColumns = computed((): TableColumn<Record<string, unknown>>[] => {
|
||||
const firstRow = limitedRecords.value[0]
|
||||
if (!firstRow) return []
|
||||
|
||||
const availableColumns = selectedColumns.filter(col => col in firstRow)
|
||||
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
|
||||
|
||||
return availableColumns.map((column: string) => ({
|
||||
accessorKey: column,
|
||||
header: ({ column: tableColumn }) => {
|
||||
const isSorted = tableColumn.getIsSorted()
|
||||
// Incluir columnas agregadas si hay ingresos
|
||||
if (props.ingresos && props.ingresos.length > 0 && ['peso_seco', 'peso_neto', 'precio', 'estado'].includes(col)) {
|
||||
return true
|
||||
}
|
||||
|
||||
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 ClienteRecord)
|
||||
}))
|
||||
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[] => {
|
||||
@@ -222,20 +368,56 @@ const columnVisibilityItems = computed((): any[] => {
|
||||
}))
|
||||
})
|
||||
|
||||
function formatCellValue(value: unknown, column: string, row: ClienteRecord): any {
|
||||
if (value === null || value === undefined) {
|
||||
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 '—'
|
||||
}
|
||||
|
||||
// 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'
|
||||
})
|
||||
if (value === null || value === undefined) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
// idciat - badge con formato CIAT-####
|
||||
@@ -250,7 +432,7 @@ function formatCellValue(value: unknown, column: string, row: ClienteRecord): an
|
||||
}
|
||||
|
||||
// name - con avatar si está disponible
|
||||
if (column === 'name' && typeof value === 'string') {
|
||||
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,
|
||||
@@ -315,6 +497,36 @@ function formatCellValue(value: unknown, column: string, row: ClienteRecord): an
|
||||
})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
Reference in New Issue
Block a user