Enhance invoice processing and validation logic

- Updated `InvoiceService` to accurately reflect the number of line items in `party_count` for invoices.
- Modified weight calculations in the create and update validators to ensure consistent handling of weight types, now using lowercase comparison for "KGS".
- Adjusted invoice data structure in the frontend to include `total_items` and refined logistics handling.
- Improved table rendering in the invoice edit component for better sticky header behavior and item visibility.

These changes aim to improve data integrity and user experience in invoice management.
This commit is contained in:
2026-03-19 14:22:21 -05:00
parent 264188e112
commit cf56ca3a68
13 changed files with 197 additions and 80 deletions

View File

@@ -232,13 +232,14 @@ export interface Invoice {
download_substance?: boolean | null;
download_class?: boolean | null;
download_def?: boolean | null;
total_items?: number | null;
payment_terms?: string | null;
handling_fees?: number | null;
option_iv18?: string | null;
enajenation_goods?: boolean | null;
compliance_mx?: InvoiceComplianceMx | null;
financials?: InvoiceFinancials | null;
logistics?: InvoiceLogistics[];
logistics?: InvoiceLogistics | null;
details?: InvoiceSalesDetails[];
collections?: InvoiceCollections[];
// Client-side only properties

View File

@@ -6,7 +6,19 @@ import { getInvoiceTypeColor } from "$lib/utils";
function formatDate(date?: string | null): string {
if (!date) return '-';
return new Date(date).toLocaleDateString('es-MX', {
// Avoid timezone shifts (e.g. showing one day earlier) by parsing as local date.
const raw = String(date);
const ymd = raw.includes('T') ? raw.split('T')[0] : raw;
const parts = ymd.split('-').map(Number);
if (parts.length === 3 && parts.every((n) => Number.isFinite(n))) {
const [year, month, day] = parts;
return new Date(year, month - 1, day).toLocaleDateString('es-MX', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
}
return new Date(raw).toLocaleDateString('es-MX', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
@@ -243,7 +255,10 @@ export function createColumns(
accessorKey: "total_items",
header: "Total Partidas",
cell: ({ row }) => {
const totalItems = row.original.details?.length || 0;
const totalItems = (row.original as Invoice & { total_items?: number | null }).total_items
?? row.original.party_count
?? row.original.details?.length
?? 0;
const itemsSnippet = createRawSnippet<[{ total: number }]>((getTotal) => {
const { total } = getTotal();
@@ -291,7 +306,7 @@ export function createColumns(
accessorKey: "logistics.weight_type",
header: "Tipo Peso",
cell: ({ row }) => {
const weightType = row.original.logistics?.[0]?.weight_type;
const weightType = row.original.logistics?.weight_type;
const weightSnippet = createRawSnippet<[{ type?: string | null }]>((getType) => {
const { type } = getType();
@@ -303,28 +318,6 @@ export function createColumns(
return renderSnippet(weightSnippet, { type: weightType });
}
},
{
accessorKey: "status",
header: "Actualizado",
cell: ({ row }) => {
// status can be 'processed' | 'pending' | 'reversed' (string) or legacy boolean
const s = row.original.status;
const isprocessed = s === "processed" || s === true;
const processedSnippet = createRawSnippet<[{ isprocessed?: boolean | null }]>((getprocessed) => {
const { isprocessed } = getprocessed();
const colorClass = isprocessed ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
const label = isprocessed ? 'Sí' : 'No';
return {
render: () =>
`<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${colorClass}">
${label}
</span>`
};
});
return renderSnippet(processedSnippet, { isprocessed });
}
},
{
accessorKey: "compliance_mx.is_mixed",
header: "Mixto",

View File

@@ -83,9 +83,21 @@
<Table.Root>
<Table.Header class="bg-background">
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
{@const headerList = headerGroup.headers}
{@const lastHeaderColId = headerList[headerList.length - 1]?.column.id}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head>
{#each headerList as header (header.id)}
{@const colId = header.column.id}
<Table.Head
class={[
colId === 'select' &&
'sticky left-0 z-40 min-w-[2.75rem] border-r border-border/70 bg-background shadow-[4px_0_12px_-6px_rgba(0,0,0,0.12)] dark:shadow-[4px_0_12px_-6px_rgba(0,0,0,0.35)]',
colId === lastHeaderColId &&
'sticky right-0 z-40 border-l border-border/70 bg-background shadow-[-4px_0_12px_-6px_rgba(0,0,0,0.12)] dark:shadow-[-4px_0_12px_-6px_rgba(0,0,0,0.35)]'
]
.filter(Boolean)
.join(' ')}
>
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
@@ -99,15 +111,30 @@
</Table.Header>
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
{@const visibleCells = row.getVisibleCells()}
{@const lastCellColId = visibleCells[visibleCells.length - 1]?.column.id}
<Table.Row
data-state={row.getIsSelected() && 'selected'}
class="cursor-pointer transition-colors {row.getIsSelected()
class="group/inv-list cursor-pointer transition-colors {row.getIsSelected()
? 'bg-gray-300 dark:bg-gray-600'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'}"
onclick={() => onRowClick && onRowClick(row.original)}
>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell>
{#each visibleCells as cell (cell.id)}
{@const colId = cell.column.id}
<Table.Cell
class={[
colId === 'select' &&
'sticky left-0 z-30 min-w-[2.75rem] border-r border-border/70 shadow-[4px_0_12px_-6px_rgba(0,0,0,0.12)] dark:shadow-[4px_0_12px_-6px_rgba(0,0,0,0.35)]',
colId === lastCellColId &&
'sticky right-0 z-30 border-l border-border/70 shadow-[-4px_0_12px_-6px_rgba(0,0,0,0.12)] dark:shadow-[-4px_0_12px_-6px_rgba(0,0,0,0.35)]',
row.getIsSelected()
? 'bg-gray-300 dark:bg-gray-600'
: 'bg-background group-hover/inv-list:bg-gray-100 dark:group-hover/inv-list:bg-gray-700'
]
.filter(Boolean)
.join(' ')}
>
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</Table.Cell>
{/each}

View File

@@ -69,7 +69,7 @@
editingItem.fa_data.omit_annex31 = false;
}
if (editingItem.fa_data.discharge === undefined) {
editingItem.fa_data.discharge = false;
editingItem.fa_data.discharge = true;
}
}
});
@@ -271,7 +271,7 @@
<div class="space-y-1.5">
<Label class="text-xs font-medium text-muted-foreground">Genera Descarga?</Label>
<RadioGroup
value={editingItem.fa_data?.discharge === true ? 'si' : 'no'}
value={editingItem.fa_data?.discharge === false ? 'no' : 'si'}
onValueChange={(v) => {
editingItem.fa_data = editingItem.fa_data || {};
editingItem.fa_data.discharge = v === 'si';
@@ -364,7 +364,7 @@
<div class="space-y-1.5">
<Label class="text-xs font-medium text-muted-foreground">Genera Descarga?</Label>
<RadioGroup
value={editingItem.fa_data?.discharge === true ? 'si' : 'no'}
value={editingItem.fa_data?.discharge === false ? 'no' : 'si'}
onValueChange={(v) => {
editingItem.fa_data = editingItem.fa_data || {};
editingItem.fa_data.discharge = v === 'si';

View File

@@ -102,10 +102,12 @@
const invoiceSystem = $derived(invoice?.system || 'scaii');
const itemVisibility = $derived.by(() => getVisibility(invoiceType, operationType));
const showCrTrackingHeader = $derived(itemVisibility.showCrTrackingHeader);
const showTrackingHeaderColumns = $derived(operationType !== 1 && showCrTrackingHeader);
/** Debe coincidir con el orden de celdas en cada rama del tbody (exportación ≠ importación genérica ≠ REP). */
const emptyStateColspan = $derived.by(() => {
if (operationType === 1) return 11;
return showTrackingHeaderColumns ? 12 : 10;
if (showCrTrackingHeader) return 12;
if (invoiceType === 'REP' || invoiceType === 'REPAR') return 13;
return 10;
});
const invoiceLabel = $derived.by(() => {
if (invoice?.invoice_number) return `Factura ${invoice.invoice_number}`;
@@ -333,7 +335,7 @@
search_line: undefined,
search_type: undefined,
movement_type_import: undefined,
down_equipment: false,
own_equipment: false,
omit_annex31: false
},
series: []
@@ -1011,6 +1013,28 @@
// Cerrar el sheet
showItemSheet = false;
}
/** Sticky sin bordes extra (evitan desalinear thead/tbody en tablas auto-layout). */
const STICKY_LINE_HEAD =
'sticky left-0 z-40 bg-background shadow-[3px_0_8px_-4px_rgba(0,0,0,0.12)] dark:shadow-[3px_0_8px_-4px_rgba(0,0,0,0.35)]';
const STICKY_ACTIONS_HEAD =
'sticky right-0 z-40 w-[104px] min-w-[104px] bg-background text-right shadow-[-3px_0_8px_-4px_rgba(0,0,0,0.12)] dark:shadow-[-3px_0_8px_-4px_rgba(0,0,0,0.35)]';
function stickyLineCellClass(itemId: string) {
const focused = focusedLine?.id === itemId;
return [
'sticky left-0 z-30 shadow-[3px_0_8px_-4px_rgba(0,0,0,0.12)] dark:shadow-[3px_0_8px_-4px_rgba(0,0,0,0.35)]',
focused ? 'bg-muted' : 'bg-background group-hover/item-row:bg-muted/50'
].join(' ');
}
function stickyActionsCellClass(itemId: string) {
const focused = focusedLine?.id === itemId;
return [
'sticky right-0 z-30 w-[104px] min-w-[104px] text-right shadow-[-3px_0_8px_-4px_rgba(0,0,0,0.12)] dark:shadow-[-3px_0_8px_-4px_rgba(0,0,0,0.35)]',
focused ? 'bg-muted' : 'bg-background group-hover/item-row:bg-muted/50'
].join(' ');
}
</script>
<div class="grid grid-cols-4 grid-rows-1 gap-3">
@@ -1052,20 +1076,54 @@
<Table.Root>
<Table.Header class="bg-background">
<Table.Row>
<Table.Head>Línea</Table.Head>
{#if showTrackingHeaderColumns}
<Table.Head class={STICKY_LINE_HEAD}>Línea</Table.Head>
{#if operationType === 1}
<Table.Head>Factura Impo</Table.Head>
<Table.Head>Línea</Table.Head>
<Table.Head>P/S</Table.Head>
<Table.Head>Cant. Importada</Table.Head>
<Table.Head>Clase</Table.Head>
<Table.Head>Número Parte</Table.Head>
<Table.Head>Descripción</Table.Head>
<Table.Head>Contiene Subpartida</Table.Head>
<Table.Head>Partida Principal</Table.Head>
<Table.Head class={STICKY_ACTIONS_HEAD}>Acciones</Table.Head>
{:else if showCrTrackingHeader}
<Table.Head>Factura Impo</Table.Head>
<Table.Head>Línea</Table.Head>
<Table.Head>P/S</Table.Head>
<Table.Head>Clase</Table.Head>
<Table.Head>Descripcion Clase</Table.Head>
<Table.Head>Cant. Importada</Table.Head>
<Table.Head>U.M.</Table.Head>
<Table.Head>Preferencia</Table.Head>
<Table.Head>Contiene Subpartida</Table.Head>
<Table.Head>Partida Principal</Table.Head>
<Table.Head class={STICKY_ACTIONS_HEAD}>Acciones</Table.Head>
{:else if invoiceType === 'REP' || invoiceType === 'REPAR'}
<Table.Head>Factura Impo</Table.Head>
<Table.Head>Línea</Table.Head>
<Table.Head>P/S</Table.Head>
<Table.Head>Clase</Table.Head>
<Table.Head>Número Parte</Table.Head>
<Table.Head>Descripcion Clase</Table.Head>
<Table.Head>Cant. Importada</Table.Head>
<Table.Head>U.M.</Table.Head>
<Table.Head>Preferencia</Table.Head>
<Table.Head>Contiene Subpartida</Table.Head>
<Table.Head>Partida Principal</Table.Head>
<Table.Head class={STICKY_ACTIONS_HEAD}>Acciones</Table.Head>
{:else}
<Table.Head>P/S</Table.Head>
<Table.Head>Clase</Table.Head>
<Table.Head>Descripcion Clase</Table.Head>
<Table.Head>Cant. Importada</Table.Head>
<Table.Head>U.M.</Table.Head>
<Table.Head>Preferencia</Table.Head>
<Table.Head>Contiene Subpartida</Table.Head>
<Table.Head>Partida Principal</Table.Head>
<Table.Head class={STICKY_ACTIONS_HEAD}>Acciones</Table.Head>
{/if}
<Table.Head>P/S</Table.Head>
<Table.Head>Clase</Table.Head>
<Table.Head>Descripcion Clase</Table.Head>
<Table.Head>Cant. Importada</Table.Head>
<Table.Head>U.M.</Table.Head>
<Table.Head>Preferencia</Table.Head>
<Table.Head>Contiene Subpartida</Table.Head>
<Table.Head>Partida Principal</Table.Head>
<Table.Head class="w-[120px] text-right">Acciones</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
@@ -1082,13 +1140,13 @@
{#each displayedItems as item (item.id)}
<Table.Row
onclick={() => handleRowClick(item)}
class="cursor-pointer transition-colors hover:bg-muted/50 {focusedLine?.id ===
class="group/item-row cursor-pointer transition-colors hover:bg-muted/50 {focusedLine?.id ===
item.id
? 'bg-muted ring-1 ring-primary/20 ring-inset'
: ''}"
>
{#if operationType === 1}
<Table.Cell>{item.line_number}</Table.Cell>
<Table.Cell class={stickyLineCellClass(item.id)}>{item.line_number}</Table.Cell>
<Table.Cell>{item.fa_data?.search_invoice || '-'}</Table.Cell>
<Table.Cell>{item.fa_data?.search_line || '-'}</Table.Cell>
<Table.Cell>{item.is_subitem ? 'S' : 'P'}</Table.Cell>
@@ -1104,7 +1162,7 @@
<Table.Cell>{item.fa_data?.contains_subitems ? 'Sí' : 'No'}</Table.Cell>
<Table.Cell>{item.warehouse || '-'}</Table.Cell>
{:else if showCrTrackingHeader}
<Table.Cell>{item.line_number}</Table.Cell>
<Table.Cell class={stickyLineCellClass(item.id)}>{item.line_number}</Table.Cell>
<Table.Cell>{item.fa_data?.search_invoice || '-'}</Table.Cell>
<Table.Cell>{item.fa_data?.search_line || '-'}</Table.Cell>
<Table.Cell>{item.is_subitem ? 'S' : 'P'}</Table.Cell>
@@ -1121,7 +1179,7 @@
<Table.Cell>{item.fa_data?.contains_subitems ? 'Sí' : 'No'}</Table.Cell>
<Table.Cell>{item.warehouse || '-'}</Table.Cell>
{:else if invoiceType === 'REP' || invoiceType === 'REPAR'}
<Table.Cell>{item.line_number}</Table.Cell>
<Table.Cell class={stickyLineCellClass(item.id)}>{item.line_number}</Table.Cell>
<Table.Cell>{item.fa_data?.search_invoice || '-'}</Table.Cell>
<Table.Cell>{item.fa_data?.search_line || '-'}</Table.Cell>
<Table.Cell>{item.is_subitem ? 'S' : 'P'}</Table.Cell>
@@ -1139,7 +1197,7 @@
<Table.Cell>{item.fa_data?.contains_subitems ? 'Sí' : 'No'}</Table.Cell>
<Table.Cell>{item.warehouse || '-'}</Table.Cell>
{:else}
<Table.Cell>{item.line_number}</Table.Cell>
<Table.Cell class={stickyLineCellClass(item.id)}>{item.line_number}</Table.Cell>
<Table.Cell>{item.is_subitem ? 'S' : 'P'}</Table.Cell>
<Table.Cell>{item.class_code || '-'}</Table.Cell>
<Table.Cell>{item.class_description || '-'}</Table.Cell>
@@ -1149,12 +1207,28 @@
<Table.Cell>{item.fa_data?.contains_subitems ? 'Sí' : 'No'}</Table.Cell>
<Table.Cell>{item.warehouse || '-'}</Table.Cell>
{/if}
<Table.Cell class="text-right">
<Table.Cell class={stickyActionsCellClass(item.id)}>
<div class="flex justify-end gap-2">
<Button size="icon" variant="ghost" onclick={() => handleEdit(item)}>
<Button
size="icon"
variant="ghost"
type="button"
onclick={(e) => {
e.stopPropagation();
handleEdit(item);
}}
>
<Pencil class="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" onclick={() => handleDelete(item)}>
<Button
size="icon"
variant="ghost"
type="button"
onclick={(e) => {
e.stopPropagation();
handleDelete(item);
}}
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</div>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
@@ -652,8 +653,13 @@
}
});
function invoicesListPath(): string {
const q = get(page).url.search;
return q ? `/dashboard/invoices${q}` : '/dashboard/invoices';
}
function handleBack() {
goto('/dashboard/invoices');
goto(invoicesListPath());
}
function getOperationColor(type?: string | null): 'default' | 'secondary' {
@@ -1162,5 +1168,5 @@
{agentsCount}
{clientsCount}
onAccept={() => (showPrerequisitesModal = false)}
onCancel={() => goto('/dashboard/invoices')}
onCancel={() => goto(invoicesListPath())}
/>