395 lines
13 KiB
Vue
395 lines
13 KiB
Vue
<!-- 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)
|
|
|
|
// 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 (!props.includeClientesWithoutIngresos) {
|
|
return clientesData.filter(cliente => cliente.children && cliente.children.length > 0)
|
|
}
|
|
|
|
return clientesData
|
|
}
|
|
})
|
|
|
|
// Compute table UI for thead background
|
|
const tableUi = computed(() => {
|
|
const bgClass = props.primaryView === 'ingresos'
|
|
? 'bg-cyan-400/20'
|
|
: 'bg-yellow-500/20'
|
|
|
|
return {
|
|
thead: `${bgClass} [&>tr>th]:text-white [&>tr>th]:font-semibold`
|
|
}
|
|
})
|
|
|
|
const columns = computed((): TableColumn<IngresoWithChildren | ClienteWithChildren>[] => [
|
|
{
|
|
accessorKey: 'id',
|
|
header: () => h('span', { class: 'text-white font-semibold' }, '#'),
|
|
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: () => h('span', { class: 'text-white font-semibold' }, '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: () => h('span', { class: 'text-white font-semibold' }, '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 text-white font-semibold' }, 'Peso Seco (qq)'),
|
|
cell: ({ row }) => {
|
|
const original = row.original as any
|
|
const isIngreso = 'tipo' in original
|
|
const isCliente = 'name' in original && !('tipo' in original)
|
|
|
|
// Si es cliente parent con peso_seco agregado, mostrarlo
|
|
if (isCliente && row.depth === 0 && original.peso_seco !== undefined) {
|
|
const peso = Number.parseFloat(original.peso_seco || 0)
|
|
return h('div', { class: 'text-right font-medium text-yellow-500' }, peso.toFixed(2))
|
|
}
|
|
|
|
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 text-white font-semibold' }, 'Peso Neto (lb)'),
|
|
cell: ({ row }) => {
|
|
const original = row.original as any
|
|
const isIngreso = 'tipo' in original
|
|
const isCliente = 'name' in original && !('tipo' in original)
|
|
|
|
// Si es cliente parent con peso_neto agregado, mostrarlo
|
|
if (isCliente && row.depth === 0 && original.peso_neto !== undefined) {
|
|
const peso = Number.parseFloat(original.peso_neto || 0)
|
|
return h('div', { class: 'text-right font-medium text-yellow-500' }, peso.toFixed(2))
|
|
}
|
|
|
|
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 text-white font-semibold' }, 'Precio'),
|
|
cell: ({ row }) => {
|
|
const original = row.original as any
|
|
const isIngreso = 'tipo' in original
|
|
const isCliente = 'name' in original && !('tipo' in original)
|
|
|
|
// Si es cliente parent con precio agregado, mostrarlo
|
|
if (isCliente && row.depth === 0 && original.precio !== undefined) {
|
|
const precio = Number.parseFloat(original.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-yellow-500' }, formatted)
|
|
}
|
|
|
|
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: () => h('span', { class: 'text-white font-semibold' }, '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 (parent) - show aggregated estados
|
|
const isParent = row.depth === 0
|
|
|
|
if (isParent && original.estado) {
|
|
// Si tiene campo estado agregado, mostrarlo como badges múltiples
|
|
const estados = original.estado.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')
|
|
))
|
|
}
|
|
|
|
// Cliente row (child) - 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',
|
|
thead: tableUi.thead,
|
|
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>
|