cuenta-cliente tabla pro implementada

This commit is contained in:
2025-09-30 23:07:09 -06:00
parent d70be45e0d
commit 47e42ec985
2 changed files with 753 additions and 53 deletions

View File

@@ -0,0 +1,320 @@
<!-- nuxt4-app/app/components/ingresos/VistaTablaIngresosConClientes.vue -->
<script setup lang="ts">
import { h, resolveComponent } from 'vue'
import type { TableColumn } from '@nuxt/ui'
import type { IngresoRecord } from '~/composables/useIngresosMetrics'
const UButton = resolveComponent('UButton')
interface ClienteRecord {
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 IngresoWithChildren extends IngresoRecord {
children?: ClienteRecord[]
}
interface ClienteWithChildren extends ClienteRecord {
children?: IngresoRecord[]
}
interface Props {
ingresos: IngresoRecord[]
clientes: ClienteRecord[]
primaryView: 'ingresos' | 'clientes'
includeClientesWithoutIngresos?: boolean
}
const props = withDefaults(defineProps<Props>(), {
includeClientesWithoutIngresos: false
})
// Map data based on primary view
const data = computed<(IngresoWithChildren | ClienteWithChildren)[]>(() => {
if (props.primaryView === 'ingresos') {
// Ingresos as parent, cliente as child
return props.ingresos.map(ingreso => {
const cliente = props.clientes.find(c => c.id === ingreso.cliente_id)
return {
...ingreso,
children: cliente ? [cliente] : []
}
})
} else {
// Clientes as parent, ingresos as children
const clientesData = props.clientes.map(cliente => {
const clienteIngresos = props.ingresos.filter(i => i.cliente_id === cliente.id)
return {
...cliente,
children: clienteIngresos
}
})
// Filter out clientes without ingresos if toggle is OFF
if (!props.includeClientesWithoutIngresos) {
return clientesData.filter(cliente => cliente.children && cliente.children.length > 0)
}
return clientesData
}
})
const columns: TableColumn<IngresoWithChildren | ClienteWithChildren>[] = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => {
const original = row.original as any
const isIngreso = 'tipo' in original
const isCliente = 'name' in original && !('tipo' in original)
// Parent row always has button
const isParent = row.depth === 0
return h(
'div',
{
style: {
paddingLeft: `${row.depth * 2}rem`
},
class: 'flex items-center gap-2'
},
[
isParent && 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()
}),
h('span', {
class: isCliente ? 'text-yellow-500 font-semibold' : isIngreso ? 'text-cyan-400 font-semibold' : ''
}, String(row.getValue('id')))
]
)
}
},
{
accessorKey: 'created_at',
header: 'Fecha',
cell: ({ row }) => {
const created = row.getValue('created_at') as string | undefined
if (!created) return '—'
return new Date(created).toLocaleString('es-ES', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'name',
header: 'Info',
cell: ({ row }) => {
const original = row.original as any
const isCliente = 'name' in original && !('tipo' in original)
const isIngreso = 'tipo' in original
// If it's a cliente
if (isCliente) {
return h('div', { class: 'flex flex-col gap-1' }, [
h('span', { class: 'font-semibold text-yellow-500' }, original.name),
original.ubicacion && h('span', { class: 'text-xs text-gray-400' }, `📍 ${original.ubicacion}`),
original.telefono && h('span', { class: 'text-xs text-gray-400' }, `📞 ${original.telefono}`),
original.cedula && h('span', { class: 'text-xs text-gray-400' }, `🆔 ${original.cedula}`)
])
}
// If it's an ingreso
if (isIngreso) {
return h('div', { class: 'flex items-center gap-2' }, [
h('span', {
class: original.tipo === 'uva' ? 'text-purple-400' :
original.tipo === 'oreado' ? 'text-orange-400' :
original.tipo === 'mojado' ? 'text-blue-400' : 'text-green-400'
}, original.tipo?.toUpperCase()),
h('span', { class: 'text-xs text-gray-500' }, `Cliente ID: ${original.cliente_id}`)
])
}
return h('span', { class: 'text-gray-500' }, '—')
}
},
{
accessorKey: 'peso_seco',
header: () => h('div', { class: 'text-right' }, 'Peso Seco (qq)'),
cell: ({ row }) => {
const original = row.original as any
const isIngreso = 'tipo' in original
if (!isIngreso) return h('div', { class: 'text-right text-gray-500' }, '—')
const peso = Number.parseFloat(row.getValue('peso_seco') || 0)
return h('div', { class: 'text-right font-medium text-cyan-400' }, peso.toFixed(2))
}
},
{
accessorKey: 'peso_neto',
header: () => h('div', { class: 'text-right' }, 'Peso Neto (lb)'),
cell: ({ row }) => {
const original = row.original as any
const isIngreso = 'tipo' in original
if (!isIngreso) return h('div', { class: 'text-right text-gray-500' }, '—')
const peso = Number.parseFloat(row.getValue('peso_neto') || 0)
return h('div', { class: 'text-right font-medium text-cyan-400' }, peso.toFixed(2))
}
},
{
accessorKey: 'precio',
header: () => h('div', { class: 'text-right' }, 'Precio'),
cell: ({ row }) => {
const original = row.original as any
const isIngreso = 'tipo' in original
if (!isIngreso) return h('div', { class: 'text-right text-gray-500' }, '—')
const precio = Number.parseFloat(row.getValue('precio') || 0)
const formatted = new Intl.NumberFormat('es-HN', {
style: 'currency',
currency: 'HNL',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(precio).replace('HNL', 'L')
return h('div', { class: 'text-right font-medium text-cyan-400' }, formatted)
}
},
{
accessorKey: 'estado',
header: 'Estado',
cell: ({ row }) => {
const original = row.original as any
const isCliente = 'name' in original && !('tipo' in original)
const isIngreso = 'tipo' in original
if (isCliente) {
// Cliente row - show if empleado
return original.empleado
? h('span', { class: 'inline-flex items-center gap-1 px-2 py-0.5 rounded bg-blue-500/20 text-blue-300 border border-blue-400/30 text-xs' }, [
h('span', '💼'),
'Empleado'
])
: h('span', { class: 'text-gray-500 text-xs' }, '—')
}
if (isIngreso) {
const estado = row.getValue('estado') as string
return 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')
}
return h('span', { class: 'text-gray-500 text-xs' }, '—')
}
}
]
const expanded = ref<Record<string, boolean>>({})
// Pagination
const currentPage = ref(1)
const recordsPerPage = 50
const totalRecords = computed(() => data.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 paginatedData = computed(() => {
const start = (currentPage.value - 1) * recordsPerPage
const end = start + recordsPerPage
return data.value.slice(start, end)
})
function nextPage() {
if (currentPage.value < totalPages.value) {
currentPage.value++
}
}
function previousPage() {
if (currentPage.value > 1) {
currentPage.value--
}
}
</script>
<template>
<div class="flex flex-col gap-3">
<UTable
v-model:expanded="expanded"
:data="paginatedData"
:columns="columns"
:get-sub-rows="(row: any) => row.children"
sticky
class="flex-1"
:ui="{
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'
}"
/>
<!-- Pagination Footer -->
<div class="flex items-center justify-between px-4 py-3 border-t border-[var(--brand-border)]">
<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>