mejoras de ui UX demasido intensas
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
{{ props.records.length }} clientes registrados
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UButton
|
||||
:label="dateFormat === 'short' ? 'Fecha Larga' : 'Fecha Corta'"
|
||||
color="neutral"
|
||||
@@ -15,6 +16,13 @@
|
||||
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,9 +251,86 @@ 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) => ({
|
||||
// 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()
|
||||
@@ -199,8 +344,9 @@ const tableColumns = computed((): TableColumn<Record<string, unknown>>[] => {
|
||||
onClick: () => tableColumn.toggleSorting(tableColumn.getIsSorted() === 'asc')
|
||||
})
|
||||
},
|
||||
cell: ({ row }) => formatCellValue(row.getValue(column), column, row.original as ClienteRecord)
|
||||
}))
|
||||
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 {
|
||||
|
||||
@@ -199,11 +199,11 @@
|
||||
<span class="text-[10px] text-[var(--brand-text-muted)] uppercase font-semibold">Leyenda:</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-2 h-2 rounded-full bg-[#c08040]"></div>
|
||||
<span class="text-[10px] text-[var(--brand-text-muted)]">Total completo</span>
|
||||
<span class="text-[10px] text-[var(--brand-text-muted)]">Total completo de la cosecha</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-2 h-2 rounded-full bg-blue-400"></div>
|
||||
<span class="text-[10px] text-[var(--brand-text-muted)]">Acumulado hasta hoy (día {{ diaActualRelativo }})</span>
|
||||
<span class="text-[10px] text-[var(--brand-text-muted)]">Acumulado hasta la fecha</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -224,18 +224,18 @@
|
||||
{{ formatTotal(cosecha.total) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="cosecha.totalALaFecha !== null"
|
||||
v-if="cosecha.totalALaFecha !== null && cosecha.totalALaFecha > 0"
|
||||
class="text-[10px] text-blue-400 font-medium"
|
||||
:title="`Acumulado hasta el día ${diaActualRelativo} (equivalente a hoy)`"
|
||||
title="Acumulado hasta la fecha actual"
|
||||
>
|
||||
↗ {{ formatTotal(cosecha.totalALaFecha) }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-[10px] text-gray-500 italic"
|
||||
title="Esta cosecha aún no ha llegado a este día del año"
|
||||
title="Sin datos hasta la fecha [actual]"
|
||||
>
|
||||
Sin datos
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,10 +272,6 @@
|
||||
:key="`${cosecha.id}-${dia}`"
|
||||
class="border-r border-b transition-all cursor-pointer relative"
|
||||
:class="[
|
||||
// Marcador del día actual
|
||||
dia - 1 === diaActualRelativo
|
||||
? 'border-l-2 border-l-blue-400'
|
||||
: '',
|
||||
// Rango seleccionado (naranja)
|
||||
isInSelectedRange(dia - 1)
|
||||
? 'ring-2 ring-[#c08040] border-[#c08040] z-10'
|
||||
@@ -295,16 +291,17 @@
|
||||
@mouseenter="showTooltip($event, cosecha, dia - 1)"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
<!-- Overlay gris para días ya transcurridos -->
|
||||
<div
|
||||
v-if="isDiaPasado(cosecha.id, dia - 1)"
|
||||
class="absolute inset-0 bg-gray-400/15 pointer-events-none"
|
||||
/>
|
||||
|
||||
<!-- Marcador visual para primera celda seleccionada -->
|
||||
<div
|
||||
v-if="primerDiaSeleccionado === dia - 1 && rangoSeleccionado === null"
|
||||
class="absolute inset-0 bg-blue-500/30 pointer-events-none"
|
||||
/>
|
||||
<!-- Indicador del día actual -->
|
||||
<div
|
||||
v-if="dia - 1 === diaActualRelativo"
|
||||
class="absolute left-0 top-0 bottom-0 w-0.5 bg-blue-400 pointer-events-none z-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -318,11 +315,11 @@
|
||||
<span class="text-[10px] text-[var(--brand-text-muted)] uppercase font-semibold">Leyenda:</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-2 h-2 rounded-full bg-[#c08040]"></div>
|
||||
<span class="text-[10px] text-[var(--brand-text-muted)]">Total completo</span>
|
||||
<span class="text-[10px] text-[var(--brand-text-muted)]">Total completo de la cosecha</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-2 h-2 rounded-full bg-blue-400"></div>
|
||||
<span class="text-[10px] text-[var(--brand-text-muted)]">Acumulado hasta hoy (día {{ diaActualRelativo }})</span>
|
||||
<span class="text-[10px] text-[var(--brand-text-muted)]">Acumulado hasta la fecha</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -343,18 +340,18 @@
|
||||
{{ formatTotal(cosecha.total) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="cosecha.totalALaFecha !== null"
|
||||
v-if="cosecha.totalALaFecha !== null && cosecha.totalALaFecha > 0"
|
||||
class="text-[10px] text-blue-400 font-medium"
|
||||
:title="`Acumulado hasta el día ${diaActualRelativo} (equivalente a hoy)`"
|
||||
title="Acumulado hasta la fecha actual"
|
||||
>
|
||||
↗ {{ formatTotal(cosecha.totalALaFecha) }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-[10px] text-gray-500 italic"
|
||||
title="Esta cosecha aún no ha llegado a este día del año"
|
||||
title="Sin datos hasta la fecha actual"
|
||||
>
|
||||
Sin datos
|
||||
{{ formatTotal(cosecha.totalALaFecha) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -392,10 +389,6 @@
|
||||
:key="`${cosecha.id}-${dia}`"
|
||||
class="relative border-b cursor-pointer group"
|
||||
:class="[
|
||||
// Marcador del día actual
|
||||
dia - 1 === diaActualRelativo
|
||||
? 'border-l-2 border-l-blue-400'
|
||||
: '',
|
||||
// Rango seleccionado (naranja)
|
||||
isInSelectedRange(dia - 1)
|
||||
? 'border-[#c08040] border-2 bg-[#c08040]/10'
|
||||
@@ -411,6 +404,12 @@
|
||||
@mouseenter="showTooltip($event, cosecha, dia - 1)"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
<!-- Overlay gris para días ya transcurridos -->
|
||||
<div
|
||||
v-if="isDiaPasado(cosecha.id, dia - 1)"
|
||||
class="absolute inset-0 bg-gray-400/15 pointer-events-none"
|
||||
/>
|
||||
|
||||
<!-- Barra horizontal -->
|
||||
<div
|
||||
class="absolute left-0 top-1/2 -translate-y-1/2 transition-all group-hover:opacity-90"
|
||||
@@ -420,11 +419,6 @@
|
||||
backgroundColor: getCosechaColor(cosecha.id)
|
||||
}"
|
||||
/>
|
||||
<!-- Indicador del día actual -->
|
||||
<div
|
||||
v-if="dia - 1 === diaActualRelativo"
|
||||
class="absolute left-0 top-0 bottom-0 w-0.5 bg-blue-400 pointer-events-none z-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -636,25 +630,45 @@ const tooltipX = ref(0)
|
||||
const tooltipY = ref(0)
|
||||
const tooltipData = ref({ cosecha: '', dia: 0, valor: '', fecha: '' })
|
||||
|
||||
// Calcular el día actual relativo desde el inicio del año de cosecha (8 de septiembre)
|
||||
const diaActualRelativo = computed(() => {
|
||||
// Día y mes actual para comparar
|
||||
const fechaActual = computed(() => {
|
||||
const hoy = new Date()
|
||||
// Normalizar a medianoche para comparaciones
|
||||
hoy.setHours(0, 0, 0, 0)
|
||||
return {
|
||||
dia: hoy.getDate(),
|
||||
mes: hoy.getMonth(), // 0-11
|
||||
fecha: hoy,
|
||||
formatted: hoy.toLocaleDateString('es-HN', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
})
|
||||
|
||||
// Fecha de inicio del año de cosecha actual (8 de septiembre del año correspondiente)
|
||||
let anioInicioCosecha = hoy.getFullYear()
|
||||
const inicioCosechaEsteAnio = new Date(anioInicioCosecha, 8, 8) // 8 de septiembre
|
||||
// Calcular cuántos días han pasado desde el 7 de septiembre de este año
|
||||
const diasTranscurridosDesdeInicio = computed(() => {
|
||||
const hoy = new Date()
|
||||
hoy.setHours(0, 0, 0, 0)
|
||||
|
||||
// Si aún no hemos llegado al 8 de septiembre de este año, el inicio fue el año pasado
|
||||
if (hoy < inicioCosechaEsteAnio) {
|
||||
anioInicioCosecha--
|
||||
// 7 de septiembre del año actual
|
||||
const inicioDelAnio = new Date(hoy.getFullYear(), 8, 7) // mes 8 = septiembre (0-indexed)
|
||||
inicioDelAnio.setHours(0, 0, 0, 0)
|
||||
|
||||
// Si todavía no llegamos al 7 de septiembre de este año, usar el del año pasado
|
||||
if (hoy < inicioDelAnio) {
|
||||
inicioDelAnio.setFullYear(inicioDelAnio.getFullYear() - 1)
|
||||
}
|
||||
|
||||
const inicioCosecha = new Date(anioInicioCosecha, 8, 8) // 8 de septiembre
|
||||
const diaRelativo = Math.floor((hoy.getTime() - inicioCosecha.getTime()) / (1000 * 60 * 60 * 24))
|
||||
// Calcular diferencia en días
|
||||
const diffMs = hoy.getTime() - inicioDelAnio.getTime()
|
||||
const diffDias = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
return diaRelativo
|
||||
return diffDias
|
||||
})
|
||||
|
||||
// Función para verificar si un día ya pasó (basado en días desde el inicio, no en año)
|
||||
function isDiaPasado(cosechaId: string, diaIndex: number): boolean {
|
||||
return diaIndex <= diasTranscurridosDesdeInicio.value
|
||||
}
|
||||
|
||||
// Calcular datos por cosecha usando vista_resumen_ingresos
|
||||
const datosCosechas = computed(() => {
|
||||
return props.cosechasSeleccionadas.map(cosechaId => {
|
||||
@@ -747,13 +761,25 @@ const datosCosechas = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// Calcular total acumulado hasta la fecha actual (día relativo de hoy)
|
||||
// Calcular total acumulado hasta la fecha actual (mismo día/mes pero del año de cada cosecha)
|
||||
let totalALaFecha: number | null = null
|
||||
|
||||
// Solo calcular si el día actual está dentro del rango de datos disponibles
|
||||
if (diaActualRelativo.value >= 0 && diaActualRelativo.value < valoresPorDia.length) {
|
||||
// Obtener el año de inicio de esta cosecha
|
||||
const anioInicioCosecha = fechaInicio.getFullYear()
|
||||
|
||||
// Construir la fecha objetivo: mismo día y mes de hoy, pero del año de inicio de esta cosecha
|
||||
// Ej: Si hoy es 1 oct 2025 y la cosecha inició en sep 2023, usamos 1 oct 2023
|
||||
const fechaObjetivoCosecha = new Date(anioInicioCosecha, fechaActual.value.mes, fechaActual.value.dia)
|
||||
|
||||
// Calcular el día relativo desde el inicio de la cosecha hasta esa fecha objetivo
|
||||
const diaObjetivo = Math.floor((fechaObjetivoCosecha.getTime() - fechaInicio.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
// Acumular desde el inicio de la cosecha hasta la fecha objetivo
|
||||
totalALaFecha = 0
|
||||
for (let i = 0; i <= diaActualRelativo.value; i++) {
|
||||
if (diaObjetivo >= 0) {
|
||||
// Sumar todos los días desde el inicio hasta la fecha objetivo (o hasta donde haya datos)
|
||||
const limiteSuperior = Math.min(diaObjetivo, valoresPorDia.length - 1)
|
||||
for (let i = 0; i <= limiteSuperior; i++) {
|
||||
totalALaFecha += valoresPorDia[i] || 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
{{ props.records.length }} registros filtrados
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UButton
|
||||
:label="dateFormat === 'short' ? 'Fecha Larga' : 'Fecha Corta'"
|
||||
color="neutral"
|
||||
@@ -16,6 +17,7 @@
|
||||
@click="toggleDateFormat"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Table Controls -->
|
||||
@@ -46,13 +48,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-cyan-400/20 [&>tr>th]:text-white [&>tr>th]:font-semibold'
|
||||
thead: 'bg-cyan-400/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'
|
||||
}"
|
||||
/>
|
||||
|
||||
@@ -100,9 +108,20 @@ import type { IngresoRecord } from '~/composables/useIngresosMetrics'
|
||||
interface ClienteRecord {
|
||||
id: number
|
||||
name: string
|
||||
cedula?: number
|
||||
ubicacion?: string
|
||||
telefono?: string
|
||||
grupo_estudio?: string
|
||||
empleado?: boolean
|
||||
avatar_url?: string
|
||||
idciat?: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface IngresoWithChildren extends IngresoRecord {
|
||||
children?: ClienteRecord[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
records: IngresoRecord[]
|
||||
clientes?: ClienteRecord[]
|
||||
@@ -111,23 +130,39 @@ interface Props {
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const UButton = resolveComponent('UButton')
|
||||
const UBadge = resolveComponent('UBadge')
|
||||
const UIcon = resolveComponent('UIcon')
|
||||
const UTooltip = resolveComponent('UTooltip')
|
||||
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 UBadge = resolveComponent('UBadge')
|
||||
const UIcon = resolveComponent('UIcon')
|
||||
const UTooltip = resolveComponent('UTooltip')
|
||||
const expanded = ref<Record<string, boolean>>({})
|
||||
|
||||
function toggleDateFormat() {
|
||||
dateFormat.value = dateFormat.value === 'short' ? 'long' : 'short'
|
||||
}
|
||||
|
||||
// Map records to include cliente as children
|
||||
const recordsWithChildren = computed((): IngresoWithChildren[] => {
|
||||
if (!props.clientes || props.clientes.length === 0) {
|
||||
return props.records as IngresoWithChildren[]
|
||||
}
|
||||
|
||||
return props.records.map(ingreso => {
|
||||
const cliente = props.clientes?.find(c => c.id === ingreso.cliente_id)
|
||||
return {
|
||||
...ingreso,
|
||||
children: cliente ? [cliente] : []
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 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(() => {
|
||||
@@ -144,7 +179,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() {
|
||||
@@ -159,7 +194,7 @@ function previousPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Seleccionar columnas importantes manualmente en lugar de todas las propiedades
|
||||
// Seleccionar columnas importantes manualmente
|
||||
const selectedColumns = [
|
||||
'id',
|
||||
'created_at',
|
||||
@@ -187,7 +222,7 @@ const selectedColumns = [
|
||||
'tara',
|
||||
]
|
||||
|
||||
// Generate table columns only for selected fields
|
||||
// Generate table columns
|
||||
const tableColumns = computed((): TableColumn<Record<string, unknown>>[] => {
|
||||
if (!limitedRecords.value.length) return []
|
||||
|
||||
@@ -197,7 +232,73 @@ const tableColumns = computed((): TableColumn<Record<string, unknown>>[] => {
|
||||
// Solo usar columnas que existen en el primer registro
|
||||
const availableColumns = selectedColumns.filter(col => col in firstRow)
|
||||
|
||||
return availableColumns.map((column: string) => ({
|
||||
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-cyan-100',
|
||||
onClick: () => tableColumn.toggleSorting(tableColumn.getIsSorted() === 'asc')
|
||||
})
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const original = row.original as any
|
||||
const isCliente = 'name' in original && !('tipo' in original)
|
||||
const isIngreso = '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.clientes && props.clientes.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
|
||||
isIngreso && h(UBadge, {
|
||||
label: `I-${row.getValue('id')}`,
|
||||
color: 'primary',
|
||||
variant: 'subtle',
|
||||
size: 'md',
|
||||
class: 'rounded-full'
|
||||
}),
|
||||
isCliente && h(UBadge, {
|
||||
label: `C-${row.getValue('id')}`,
|
||||
color: 'warning',
|
||||
variant: 'subtle',
|
||||
size: 'md',
|
||||
class: 'rounded-full'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Otras columnas normales
|
||||
return {
|
||||
accessorKey: column,
|
||||
header: ({ column: tableColumn }) => {
|
||||
const isSorted = tableColumn.getIsSorted()
|
||||
@@ -211,8 +312,9 @@ const tableColumns = computed((): TableColumn<Record<string, unknown>>[] => {
|
||||
onClick: () => tableColumn.toggleSorting(tableColumn.getIsSorted() === 'asc')
|
||||
})
|
||||
},
|
||||
cell: ({ row }) => formatCellValue(row.getValue(column), column)
|
||||
}))
|
||||
cell: ({ row }) => formatCellValue(row.getValue(column), column, row.original as any, row.depth)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Column visibility dropdown items
|
||||
@@ -236,20 +338,49 @@ const columnVisibilityItems = computed((): any[] => {
|
||||
})
|
||||
|
||||
|
||||
function formatCellValue(value: unknown, column: string): any {
|
||||
if (value === null || value === undefined) {
|
||||
function formatCellValue(value: unknown, column: string, row: any, depth: number): any {
|
||||
// Detectar si es cliente (child row)
|
||||
const isCliente = 'name' in row && !('tipo' in row)
|
||||
const isIngreso = 'tipo' in row
|
||||
|
||||
// Si es una fila de cliente expandida, mostrar info del cliente
|
||||
if (isCliente && depth > 0) {
|
||||
if (column === 'created_at' || column === 'tipo' || column === 'estado') {
|
||||
// Mostrar información del cliente en columnas relevantes
|
||||
if (column === 'created_at') {
|
||||
return h('div', { class: 'flex flex-col gap-1' }, [
|
||||
h('span', { class: 'font-semibold text-yellow-500' }, row.name),
|
||||
row.ubicacion && h('span', { class: 'text-xs text-gray-400' }, `📍 ${row.ubicacion}`),
|
||||
row.telefono && h('span', { class: 'text-xs text-gray-400' }, `📞 ${row.telefono}`)
|
||||
])
|
||||
}
|
||||
if (column === 'tipo' && row.cedula) {
|
||||
return h(UBadge, {
|
||||
label: String(row.cedula),
|
||||
color: 'neutral',
|
||||
variant: 'soft',
|
||||
size: 'sm',
|
||||
class: 'font-mono'
|
||||
})
|
||||
}
|
||||
if (column === 'estado' && row.empleado !== undefined) {
|
||||
return row.empleado
|
||||
? h(UIcon, {
|
||||
name: 'i-lucide-briefcase',
|
||||
class: 'text-purple-600 w-5 h-5'
|
||||
})
|
||||
: h(UIcon, {
|
||||
name: 'i-lucide-user',
|
||||
class: 'text-gray-400 w-5 h-5'
|
||||
})
|
||||
}
|
||||
}
|
||||
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'
|
||||
})
|
||||
// Valores null/undefined
|
||||
if (value === null || value === undefined) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
// comercio_id - badge con formato C-#### en cyan
|
||||
|
||||
@@ -58,6 +58,17 @@
|
||||
|
||||
<!-- Main Content -->
|
||||
<template v-else>
|
||||
<!-- Metadatos de Resumen de Ingresos -->
|
||||
<UCard class="brand-card border border-transparent">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-database" class="size-5 text-[#c08040]" />
|
||||
<h3 class="text-base font-semibold text-[var(--brand-text)]">Fuente de Datos: Resumen Diario de Ingresos</h3>
|
||||
</div>
|
||||
</template>
|
||||
<MetadatosCard v-if="resumenIngresosMetadata" :metadata="resumenIngresosMetadata" :compact="true" />
|
||||
</UCard>
|
||||
|
||||
<!-- Selector de Cosechas -->
|
||||
<UCard class="brand-card border border-transparent">
|
||||
<template #header>
|
||||
@@ -105,32 +116,6 @@
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Metadatos de Resumen de Ingresos -->
|
||||
<div v-if="cosechasSeleccionadas.length > 0" class="grid grid-cols-1 gap-5">
|
||||
<UCard class="brand-card border border-transparent">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-database" class="size-5 text-[#c08040]" />
|
||||
<h3 class="text-base font-semibold text-[var(--brand-text)]">Fuente de Datos: Resumen Diario de Ingresos</h3>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<MetadatosCard v-if="resumenIngresosMetadata" :metadata="resumenIngresosMetadata" :compact="true" />
|
||||
<div class="text-xs text-[var(--brand-text-muted)] p-3 rounded-lg bg-[var(--brand-bg-secondary)] border border-[var(--brand-border)]">
|
||||
<p class="font-semibold mb-2">Métricas utilizadas (registros diarios):</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-2">
|
||||
<li><strong>total_peso_seco</strong> (qq)</li>
|
||||
<li><strong>peso_neto_uva</strong> (qq)</li>
|
||||
<li><strong>peso_neto_verde</strong> (qq)</li>
|
||||
<li><strong>sacos_total_dia</strong></li>
|
||||
<li><strong>Lempiras por tipo:</strong> uva, verde, mojado+oreado (combinados)</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-[10px] italic">Todas las cosechas inician el 8 de septiembre y terminan el 7 de septiembre del siguiente año.</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Vista Heatmap -->
|
||||
<div v-if="cosechasSeleccionadas.length > 0 && pageSections.heatmap">
|
||||
<ComparativaCosechasHeatmap :ingresos="resumenIngresos" :cosechas-seleccionadas="cosechasSeleccionadas" />
|
||||
@@ -377,7 +362,7 @@ const estilosGraficasDefault = {
|
||||
|
||||
// Cargar configuración desde cookies
|
||||
function cargarConfiguracionDesdeCookies() {
|
||||
const cookie = useCookie('estilos-graficas', {
|
||||
const cookie = useCookie<typeof estilosGraficasDefault>('estilos-graficas', {
|
||||
maxAge: 60 * 60 * 24 * 365, // 1 año
|
||||
sameSite: 'lax'
|
||||
})
|
||||
@@ -399,7 +384,7 @@ const estilosGraficas = ref(cargarConfiguracionDesdeCookies())
|
||||
|
||||
// Guardar en cookies cuando cambie la configuración
|
||||
watch(estilosGraficas, (newValue) => {
|
||||
const cookie = useCookie('estilos-graficas', {
|
||||
const cookie = useCookie<typeof estilosGraficasDefault>('estilos-graficas', {
|
||||
maxAge: 60 * 60 * 24 * 365, // 1 año
|
||||
sameSite: 'lax'
|
||||
})
|
||||
@@ -425,7 +410,7 @@ const cosechasDefiniciones = [
|
||||
{ id: 'cosecha-23-24', label: 'Cosecha 23-24', periodo: '8 Sep 2023 - 7 Sep 2024', fechaInicio: '2023-09-08', fechaFin: '2024-09-07' },
|
||||
{ id: 'cosecha-24-25', label: 'Cosecha 24-25', periodo: '8 Sep 2024 - 7 Sep 2025', fechaInicio: '2024-09-08', fechaFin: '2025-09-07' },
|
||||
{ id: 'cosecha-25-26', label: 'Cosecha 25-26', periodo: '8 Sep 2025 - Hoy', fechaInicio: '2025-09-08', fechaFin: new Date().toISOString().split('T')[0] }
|
||||
]
|
||||
] as const satisfies readonly { id: string; label: string; periodo: string; fechaInicio: string; fechaFin: string }[]
|
||||
|
||||
// Calcular cuántos registros tiene cada cosecha
|
||||
const registrosPorCosecha = computed(() => {
|
||||
@@ -453,8 +438,12 @@ const cosechasDisponibles = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
// Cosechas seleccionadas (por defecto las más recientes con datos)
|
||||
const cosechasSeleccionadas = ref<string[]>([])
|
||||
// Cosechas seleccionadas (persistir en cookies)
|
||||
const cosechasSeleccionadas = useCookie<string[]>('comparativa-cosechas-seleccionadas', {
|
||||
default: () => [],
|
||||
maxAge: 60 * 60 * 24 * 365, // 1 año
|
||||
sameSite: 'lax'
|
||||
})
|
||||
|
||||
// Watch para filtrar cosechas deshabilitadas y seleccionar las más recientes con datos
|
||||
watch(cosechasDisponibles, (disponibles) => {
|
||||
|
||||
@@ -294,44 +294,33 @@
|
||||
/>
|
||||
</div>
|
||||
<div ref="viewSelectorRef" class="flex flex-col gap-3">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="grid grid-cols-2 gap-12">
|
||||
<button
|
||||
v-for="option in viewOptions"
|
||||
:key="option.value"
|
||||
@click="selectedView = option.value"
|
||||
:class="[
|
||||
'relative inline-flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-all duration-200',
|
||||
'relative inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border transition-all duration-200',
|
||||
selectedView === option.value
|
||||
? `${option.borderColor} ${option.bgColor} shadow-lg ${option.shadowColor} scale-105`
|
||||
: 'border-gray-600/30 bg-gray-700/20 hover:bg-gray-700/30 hover:scale-102'
|
||||
? `${option.borderColor} ${option.bgColor} shadow-lg ${option.shadowColor}`
|
||||
: 'border-gray-600/30 bg-gray-700/20 hover:bg-gray-700/30'
|
||||
]"
|
||||
>
|
||||
<!-- Gradient background for combined views when selected -->
|
||||
<div
|
||||
v-if="option.color === 'gradient' && selectedView === option.value"
|
||||
:class="[
|
||||
'absolute inset-0 rounded-lg opacity-20 bg-gradient-to-r',
|
||||
option.gradient
|
||||
]"
|
||||
/>
|
||||
|
||||
<UIcon
|
||||
:name="option.icon"
|
||||
:class="[
|
||||
'relative z-10',
|
||||
'relative z-10 w-5 h-5',
|
||||
selectedView === option.value && option.color === 'cyan' ? 'text-cyan-400' :
|
||||
selectedView === option.value && option.color === 'yellow' ? 'text-yellow-500' :
|
||||
selectedView === option.value && option.color === 'gradient' ? 'text-white' :
|
||||
'text-gray-400'
|
||||
]"
|
||||
/>
|
||||
|
||||
<span
|
||||
:class="[
|
||||
'relative z-10 font-medium text-sm whitespace-nowrap',
|
||||
'relative z-10 font-medium text-base whitespace-nowrap',
|
||||
selectedView === option.value && option.color === 'cyan' ? 'text-cyan-400' :
|
||||
selectedView === option.value && option.color === 'yellow' ? 'text-yellow-500' :
|
||||
selectedView === option.value && option.color === 'gradient' ? 'text-white' :
|
||||
'text-gray-400'
|
||||
]"
|
||||
>
|
||||
@@ -341,13 +330,11 @@
|
||||
<!-- Badge con contador -->
|
||||
<span
|
||||
:class="[
|
||||
'relative z-10 inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full text-xs font-bold transition-all',
|
||||
'relative z-10 inline-flex items-center justify-center min-w-[24px] h-6 px-2 rounded-full text-xs font-bold transition-all',
|
||||
selectedView === option.value && option.color === 'cyan' ? 'bg-cyan-500/30 text-cyan-300 border border-cyan-400/50' :
|
||||
selectedView === option.value && option.color === 'yellow' ? 'bg-yellow-500/30 text-yellow-300 border border-yellow-400/50' :
|
||||
selectedView === option.value && option.color === 'gradient' ? 'bg-white/20 text-white border border-white/30' :
|
||||
option.color === 'cyan' ? 'bg-cyan-500/20 text-cyan-400/80 border border-cyan-500/30' :
|
||||
option.color === 'yellow' ? 'bg-yellow-500/20 text-yellow-400/80 border border-yellow-500/30' :
|
||||
option.color === 'gradient' ? 'bg-gradient-to-r from-cyan-500/20 to-yellow-500/20 text-gray-300 border border-gray-500/30' :
|
||||
'bg-gray-500/20 text-gray-400 border border-gray-500/30'
|
||||
]"
|
||||
>
|
||||
@@ -355,49 +342,22 @@
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toggle for clientes without ingresos -->
|
||||
<div v-if="selectedView === 'clientes-ingresos'" class="flex items-center gap-2 px-2">
|
||||
<UCheckbox
|
||||
v-model="includeClientesWithoutIngresos"
|
||||
label="Incluir clientes sin ingresos"
|
||||
/>
|
||||
<span class="text-xs text-[var(--brand-text-muted)]">
|
||||
({{ includeClientesWithoutIngresos ? 'Mostrando todos los clientes' : 'Solo clientes con ingresos' }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Single view: Ingresos -->
|
||||
<!-- Vista de Ingresos (con expansión de clientes) -->
|
||||
<IngresosVistaTablaIngresos
|
||||
v-if="selectedView === 'ingresos-only'"
|
||||
v-if="selectedView === 'ingresos'"
|
||||
:records="ingresosFiltrados"
|
||||
:clientes="clientes"
|
||||
/>
|
||||
|
||||
<!-- Single view: Clientes -->
|
||||
<!-- Vista de Clientes (con expansión de ingresos) -->
|
||||
<ClientesVistaTablaClientes
|
||||
v-else-if="selectedView === 'clientes-only'"
|
||||
v-else-if="selectedView === 'clientes'"
|
||||
:records="clientesFiltrados"
|
||||
/>
|
||||
|
||||
<!-- Combined view: Ingresos → Clientes -->
|
||||
<IngresosVistaTablaIngresosConClientes
|
||||
v-else-if="selectedView === 'ingresos-clientes'"
|
||||
:ingresos="ingresosFiltrados"
|
||||
:clientes="clientesFiltrados"
|
||||
primary-view="ingresos"
|
||||
/>
|
||||
|
||||
<!-- Combined view: Clientes → Ingresos -->
|
||||
<IngresosVistaTablaIngresosConClientes
|
||||
v-else-if="selectedView === 'clientes-ingresos'"
|
||||
:ingresos="ingresosFiltrados"
|
||||
:clientes="clientesFiltrados"
|
||||
primary-view="clientes"
|
||||
:include-clientes-without-ingresos="includeClientesWithoutIngresos"
|
||||
/>
|
||||
</UCard>
|
||||
|
||||
@@ -472,16 +432,13 @@ const showMetadatos = computed({
|
||||
set: (value) => { metadatosCollapsed.value = !value }
|
||||
})
|
||||
|
||||
// View modes with explicit hierarchy
|
||||
type ViewMode = 'ingresos-only' | 'clientes-only' | 'ingresos-clientes' | 'clientes-ingresos'
|
||||
const selectedView = ref<ViewMode>('ingresos-only')
|
||||
|
||||
// Toggle for including clients without ingresos in clientes-ingresos view
|
||||
const includeClientesWithoutIngresos = ref(false)
|
||||
// View modes - now only 2 options
|
||||
type ViewMode = 'ingresos' | 'clientes'
|
||||
const selectedView = ref<ViewMode>('ingresos')
|
||||
|
||||
const viewOptions = [
|
||||
{
|
||||
value: 'ingresos-only' as ViewMode,
|
||||
value: 'ingresos' as ViewMode,
|
||||
label: 'Ingresos',
|
||||
icon: 'i-lucide-trending-up',
|
||||
color: 'cyan',
|
||||
@@ -491,7 +448,7 @@ const viewOptions = [
|
||||
shadowColor: 'shadow-cyan-500/20'
|
||||
},
|
||||
{
|
||||
value: 'clientes-only' as ViewMode,
|
||||
value: 'clientes' as ViewMode,
|
||||
label: 'Clientes',
|
||||
icon: 'i-lucide-users',
|
||||
color: 'yellow',
|
||||
@@ -499,40 +456,16 @@ const viewOptions = [
|
||||
borderColor: 'border-yellow-500/50',
|
||||
bgColor: 'bg-yellow-500/10',
|
||||
shadowColor: 'shadow-yellow-500/20'
|
||||
},
|
||||
{
|
||||
value: 'ingresos-clientes' as ViewMode,
|
||||
label: 'Ingresos → Clientes',
|
||||
icon: 'i-lucide-git-branch',
|
||||
color: 'gradient',
|
||||
gradient: 'from-cyan-500 via-cyan-400 to-yellow-500',
|
||||
borderColor: 'border-cyan-500/50',
|
||||
bgColor: 'bg-gradient-to-r from-cyan-500/10 to-yellow-500/10',
|
||||
shadowColor: 'shadow-cyan-500/20'
|
||||
},
|
||||
{
|
||||
value: 'clientes-ingresos' as ViewMode,
|
||||
label: 'Clientes → Ingresos',
|
||||
icon: 'i-lucide-git-merge',
|
||||
color: 'gradient',
|
||||
gradient: 'from-yellow-500 via-yellow-400 to-cyan-500',
|
||||
borderColor: 'border-yellow-500/50',
|
||||
bgColor: 'bg-gradient-to-r from-yellow-500/10 to-cyan-500/10',
|
||||
shadowColor: 'shadow-yellow-500/20'
|
||||
}
|
||||
]
|
||||
|
||||
// Dynamic table title and description
|
||||
const tableTitle = computed(() => {
|
||||
switch (selectedView.value) {
|
||||
case 'ingresos-only':
|
||||
case 'ingresos':
|
||||
return 'Tabla de Ingresos'
|
||||
case 'clientes-only':
|
||||
case 'clientes':
|
||||
return 'Tabla de Clientes'
|
||||
case 'ingresos-clientes':
|
||||
return 'Vista Combinada: Ingresos con Clientes'
|
||||
case 'clientes-ingresos':
|
||||
return 'Vista Combinada: Clientes con Ingresos'
|
||||
default:
|
||||
return 'Tabla de Datos'
|
||||
}
|
||||
@@ -540,13 +473,10 @@ const tableTitle = computed(() => {
|
||||
|
||||
const tableTitleClass = computed(() => {
|
||||
switch (selectedView.value) {
|
||||
case 'ingresos-only':
|
||||
case 'ingresos':
|
||||
return 'text-cyan-400'
|
||||
case 'clientes-only':
|
||||
case 'clientes':
|
||||
return 'text-yellow-500'
|
||||
case 'ingresos-clientes':
|
||||
case 'clientes-ingresos':
|
||||
return 'bg-gradient-to-r from-cyan-400 to-yellow-500 bg-clip-text text-transparent'
|
||||
default:
|
||||
return 'brand-section-title'
|
||||
}
|
||||
@@ -554,20 +484,13 @@ const tableTitleClass = computed(() => {
|
||||
|
||||
const tableDescription = computed(() => {
|
||||
switch (selectedView.value) {
|
||||
case 'ingresos-only':
|
||||
return `Mostrando ${ingresosFiltrados.value.length} registros de ingresos`
|
||||
case 'clientes-only':
|
||||
return `Mostrando ${clientesFiltrados.value.length} clientes`
|
||||
case 'ingresos-clientes':
|
||||
return `${ingresosFiltrados.value.length} ingresos, cada uno con su cliente relacionado`
|
||||
case 'clientes-ingresos': {
|
||||
case 'ingresos':
|
||||
return `${ingresosFiltrados.value.length} ingresos (expandibles para ver cliente)`
|
||||
case 'clientes': {
|
||||
const clientesConIngresos = clientesFiltrados.value.filter(c =>
|
||||
ingresosFiltrados.value.some(i => i.cliente_id === c.id)
|
||||
).length
|
||||
if (includeClientesWithoutIngresos.value) {
|
||||
return `${clientesFiltrados.value.length} clientes (${clientesConIngresos} con ingresos, ${clientesFiltrados.value.length - clientesConIngresos} sin ingresos)`
|
||||
}
|
||||
return `${clientesConIngresos} clientes con ingresos relacionados`
|
||||
return `${clientesConIngresos} clientes con ingresos (expandibles para ver detalles)`
|
||||
}
|
||||
default:
|
||||
return 'Selecciona una vista'
|
||||
@@ -584,11 +507,6 @@ watch(selectedView, (newView, oldView) => {
|
||||
viewSelectorRef.value.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
})
|
||||
|
||||
// Reset the toggle when switching away from clientes-ingresos view
|
||||
if (oldView === 'clientes-ingresos' && newView !== 'clientes-ingresos') {
|
||||
includeClientesWithoutIngresos.value = false
|
||||
}
|
||||
})
|
||||
|
||||
interface ClienteRecord extends Record<string, unknown> {
|
||||
@@ -950,20 +868,13 @@ const hasVerdeData = computed(() => {
|
||||
// Función para obtener el conteo de registros según la vista
|
||||
function getViewCount(view: ViewMode): number {
|
||||
switch (view) {
|
||||
case 'ingresos-only':
|
||||
case 'ingresos':
|
||||
return ingresosFiltrados.value.length
|
||||
case 'clientes-only':
|
||||
return clientesFiltrados.value.length
|
||||
case 'ingresos-clientes':
|
||||
return ingresosFiltrados.value.length
|
||||
case 'clientes-ingresos':
|
||||
// Contar solo clientes con ingresos si el toggle está OFF
|
||||
if (!includeClientesWithoutIngresos.value) {
|
||||
case 'clientes':
|
||||
// Solo contar clientes que tienen ingresos en los datos filtrados
|
||||
return clientesFiltrados.value.filter(c =>
|
||||
ingresosFiltrados.value.some(i => i.cliente_id === c.id)
|
||||
).length
|
||||
}
|
||||
return clientesFiltrados.value.length
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user