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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user