feat: Implement invoice management features including data table, dialogs for viewing, editing, and deleting invoices, and server-side loading of invoice data

This commit is contained in:
2025-12-11 11:30:44 -06:00
parent 76b9325713
commit e5f6162ffb
14 changed files with 2381 additions and 82 deletions

View File

@@ -0,0 +1,89 @@
/**
* Definición de columnas para la tabla de facturas
*/
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
import DataTableActions from './data-table-actions.svelte';
export function createColumns() {
return [
{
accessorKey: 'id',
header: 'ID',
cell: (info: any) => info.getValue(),
enableSorting: true
},
{
accessorKey: 'operation_type',
header: 'Tipo',
cell: (info: any) => {
const type = info.getValue();
return type === 'imp' ? 'Importación' : type === 'exp' ? 'Exportación' : '-';
}
},
{
accessorKey: 'invoice_number',
header: 'Número de Factura',
cell: (info: any) => info.getValue() || '-'
},
{
accessorKey: 'invoice_type',
header: 'Tipo Factura',
cell: (info: any) => info.getValue() || '-'
},
{
accessorKey: 'project_number',
header: 'Proyecto',
cell: (info: any) => info.getValue() || '-'
},
{
accessorKey: 'compliance_mx.pedimento',
header: 'Pedimento',
cell: (info: any) => {
const row = info.row.original;
return row.compliance_mx?.pedimento || '-';
}
},
{
accessorKey: 'invoice_date',
header: 'Fecha Factura',
cell: (info: any) => {
const date = info.getValue();
if (!date) return '-';
return new Date(date).toLocaleDateString('es-MX');
}
},
{
accessorKey: 'financials.value_mn',
header: 'Valor MN',
cell: (info: any) => {
const row = info.row.original;
const value = row.financials?.value_mn;
if (value === null || value === undefined) return '-';
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN'
}).format(value);
}
},
{
accessorKey: 'traffic_light_status',
header: 'Semáforo',
cell: (info: any) => info.getValue() || '-'
},
{
accessorKey: 'capture_date',
header: 'Fecha Captura',
cell: (info: any) => {
const date = info.getValue();
if (!date) return '-';
return new Date(date).toLocaleDateString('es-MX');
}
},
{
id: 'actions',
header: 'Acciones',
cell: (info: any) => DataTableActions,
enableSorting: false
}
];
}

View File

@@ -0,0 +1,677 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select";
import { invoicesApi, type Invoice, type CreateInvoiceData, type UpdateInvoiceData } from "$lib/api/dashboard/a76/invoices";
import { companyStore } from "$lib/stores/company.svelte";
import { LoaderCircle } from 'lucide-svelte';
import * as Tabs from "$lib/components/ui/tabs";
let {
open = $bindable(false),
item = $bindable<Invoice | null>(null),
onSuccess
}: {
open: boolean;
item?: Invoice | null;
onSuccess?: () => void;
} = $props();
let formData = $state({
// Header fields
operation_type: "imp" as "imp" | "exp",
invoice_type: "",
invoice_number: "",
project_number: "",
purchase_order: "",
related_doc_id: null as number | null,
invoice_date: "",
traffic_light_status: "",
observation_es: "",
observation_en: "",
comments_status: "",
cfdi_uuid: "",
path_pdf: "",
path_xml: "",
// Compliance MX fields
pedimento: "",
pedimento_code: "",
remesa: null as number | null,
aduana: "",
customs_broker_id: "",
provider_id: "",
sold_to_id: "",
shipped_to_id: "",
shipped_by_id: "",
is_mixed: false,
waste_type: "",
appendix_17: null as number | null,
edocument: "",
// Financials fields
currency: "MXN",
exchange_rate: null as number | null,
value_mn: null as number | null,
value_me: null as number | null,
customs_value_mn: null as number | null,
freight: null as number | null,
insurance: null as number | null,
iva_mn: null as number | null,
iva_factor: null as number | null,
total_quantity: null as number | null,
gross_weight: null as number | null,
net_weight: null as number | null,
bundle_count: null as number | null
});
let loading = $state(false);
let error = $state<string | null>(null);
// Actualizar formData cuando item cambia
$effect(() => {
if (item) {
formData = {
operation_type: item.operation_type || "imp",
invoice_type: item.invoice_type || "",
invoice_number: item.invoice_number || "",
project_number: item.project_number || "",
purchase_order: item.purchase_order || "",
related_doc_id: item.related_doc_id || null,
invoice_date: item.invoice_date || "",
traffic_light_status: item.traffic_light_status || "",
observation_es: item.observation_es || "",
observation_en: item.observation_en || "",
comments_status: item.comments_status || "",
cfdi_uuid: item.cfdi_uuid || "",
path_pdf: item.path_pdf || "",
path_xml: item.path_xml || "",
pedimento: item.compliance_mx?.pedimento || "",
pedimento_code: item.compliance_mx?.pedimento_code || "",
remesa: item.compliance_mx?.remesa || null,
aduana: item.compliance_mx?.aduana || "",
customs_broker_id: item.compliance_mx?.customs_broker_id || "",
provider_id: item.compliance_mx?.provider_id || "",
sold_to_id: item.compliance_mx?.sold_to_id || "",
shipped_to_id: item.compliance_mx?.shipped_to_id || "",
shipped_by_id: item.compliance_mx?.shipped_by_id || "",
is_mixed: item.compliance_mx?.is_mixed || false,
waste_type: item.compliance_mx?.waste_type || "",
appendix_17: item.compliance_mx?.appendix_17 || null,
edocument: item.compliance_mx?.edocument || "",
currency: item.financials?.currency || "MXN",
exchange_rate: item.financials?.exchange_rate || null,
value_mn: item.financials?.value_mn || null,
value_me: item.financials?.value_me || null,
customs_value_mn: item.financials?.customs_value_mn || null,
freight: item.financials?.freight || null,
insurance: item.financials?.insurance || null,
iva_mn: item.financials?.iva_mn || null,
iva_factor: item.financials?.iva_factor || null,
total_quantity: item.financials?.total_quantity || null,
gross_weight: item.financials?.gross_weight || null,
net_weight: item.financials?.net_weight || null,
bundle_count: item.financials?.bundle_count || null
};
} else {
resetForm();
}
});
const isEditing = $derived(!!item);
function resetForm() {
formData = {
operation_type: "imp",
invoice_type: "",
invoice_number: "",
project_number: "",
purchase_order: "",
related_doc_id: null,
invoice_date: "",
traffic_light_status: "",
observation_es: "",
observation_en: "",
comments_status: "",
cfdi_uuid: "",
path_pdf: "",
path_xml: "",
pedimento: "",
pedimento_code: "",
remesa: null,
aduana: "",
customs_broker_id: "",
provider_id: "",
sold_to_id: "",
shipped_to_id: "",
shipped_by_id: "",
is_mixed: false,
waste_type: "",
appendix_17: null,
edocument: "",
currency: "MXN",
exchange_rate: null,
value_mn: null,
value_me: null,
customs_value_mn: null,
freight: null,
insurance: null,
iva_mn: null,
iva_factor: null,
total_quantity: null,
gross_weight: null,
net_weight: null,
bundle_count: null
};
}
async function handleSubmit(e: Event) {
e.preventDefault();
if (!companyStore.activeCompany) {
error = "No hay compañía seleccionada";
return;
}
loading = true;
error = null;
try {
let response;
if (isEditing && item) {
const payload: UpdateInvoiceData = {
operation_type: formData.operation_type,
invoice_type: formData.invoice_type || null,
invoice_number: formData.invoice_number || null,
project_number: formData.project_number || null,
purchase_order: formData.purchase_order || null,
related_doc_id: formData.related_doc_id,
invoice_date: formData.invoice_date || null,
traffic_light_status: formData.traffic_light_status || null,
observation_es: formData.observation_es || null,
observation_en: formData.observation_en || null,
comments_status: formData.comments_status || null,
cfdi_uuid: formData.cfdi_uuid || null,
path_pdf: formData.path_pdf || null,
path_xml: formData.path_xml || null,
compliance_mx: {
pedimento: formData.pedimento || null,
pedimento_code: formData.pedimento_code || null,
remesa: formData.remesa,
aduana: formData.aduana || null,
customs_broker_id: formData.customs_broker_id || null,
provider_id: formData.provider_id || null,
sold_to_id: formData.sold_to_id || null,
shipped_to_id: formData.shipped_to_id || null,
shipped_by_id: formData.shipped_by_id || null,
is_mixed: formData.is_mixed,
waste_type: formData.waste_type || null,
appendix_17: formData.appendix_17,
edocument: formData.edocument || null
},
financials: {
currency: formData.currency || null,
exchange_rate: formData.exchange_rate,
value_mn: formData.value_mn,
value_me: formData.value_me,
customs_value_mn: formData.customs_value_mn,
freight: formData.freight,
insurance: formData.insurance,
iva_mn: formData.iva_mn,
iva_factor: formData.iva_factor,
total_quantity: formData.total_quantity,
gross_weight: formData.gross_weight,
net_weight: formData.net_weight,
bundle_count: formData.bundle_count
}
};
response = await invoicesApi.update(item.id, companyStore.activeCompany.id, payload);
} else {
const payload: CreateInvoiceData = {
operation_type: formData.operation_type,
invoice_type: formData.invoice_type || null,
invoice_number: formData.invoice_number || null,
project_number: formData.project_number || null,
purchase_order: formData.purchase_order || null,
related_doc_id: formData.related_doc_id,
invoice_date: formData.invoice_date || null,
traffic_light_status: formData.traffic_light_status || null,
observation_es: formData.observation_es || null,
observation_en: formData.observation_en || null,
comments_status: formData.comments_status || null,
cfdi_uuid: formData.cfdi_uuid || null,
path_pdf: formData.path_pdf || null,
path_xml: formData.path_xml || null,
compliance_mx: {
pedimento: formData.pedimento || null,
pedimento_code: formData.pedimento_code || null,
remesa: formData.remesa,
aduana: formData.aduana || null,
customs_broker_id: formData.customs_broker_id || null,
provider_id: formData.provider_id || null,
sold_to_id: formData.sold_to_id || null,
shipped_to_id: formData.shipped_to_id || null,
shipped_by_id: formData.shipped_by_id || null,
is_mixed: formData.is_mixed,
waste_type: formData.waste_type || null,
appendix_17: formData.appendix_17,
edocument: formData.edocument || null
},
financials: {
currency: formData.currency || null,
exchange_rate: formData.exchange_rate,
value_mn: formData.value_mn,
value_me: formData.value_me,
customs_value_mn: formData.customs_value_mn,
freight: formData.freight,
insurance: formData.insurance,
iva_mn: formData.iva_mn,
iva_factor: formData.iva_factor,
total_quantity: formData.total_quantity,
gross_weight: formData.gross_weight,
net_weight: formData.net_weight,
bundle_count: formData.bundle_count
}
};
response = await invoicesApi.create(companyStore.activeCompany.id, payload);
}
if (response.error) {
if (response.status === 401) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
error = response.error;
}
return;
}
// Éxito
open = false;
if (onSuccess) {
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : "Error al guardar";
console.error("Error saving:", e);
} finally {
loading = false;
}
}
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
resetForm();
error = null;
}
open = newOpen;
}
</script>
<Dialog.Root {open} onOpenChange={handleOpenChange}>
<Dialog.Content class="max-w-4xl max-h-[90vh] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>
{isEditing ? "Editar Factura" : "Nueva Factura"}
</Dialog.Title>
<Dialog.Description>
{isEditing
? "Modifica los datos de la factura"
: "Ingresa los datos de la nueva factura"}
</Dialog.Description>
</Dialog.Header>
<form onsubmit={handleSubmit} class="space-y-4">
<Tabs.Root value="general" class="w-full">
<Tabs.List class="grid w-full grid-cols-3">
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="compliance">Cumplimiento</Tabs.Trigger>
<Tabs.Trigger value="financials">Financieros</Tabs.Trigger>
</Tabs.List>
<!-- General Tab -->
<Tabs.Content value="general" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="operation_type">Tipo de Operación *</Label>
<Select.Root
type="single"
value={formData.operation_type}
onValueChange={(v: string) => {
if (v) formData.operation_type = v as "imp" | "exp";
}}
>
<Select.Trigger>
{formData.operation_type === 'imp' ? 'Importación' : formData.operation_type === 'exp' ? 'Exportación' : 'Seleccionar tipo'}
</Select.Trigger>
<Select.Content>
<Select.Item value="imp">Importación</Select.Item>
<Select.Item value="exp">Exportación</Select.Item>
</Select.Content>
</Select.Root>
</div> <div class="space-y-2">
<Label for="invoice_number">Número de Factura</Label>
<Input
id="invoice_number"
bind:value={formData.invoice_number}
placeholder="Número de factura"
/>
</div>
<div class="space-y-2">
<Label for="invoice_type">Tipo de Factura</Label>
<Input
id="invoice_type"
bind:value={formData.invoice_type}
placeholder="Tipo de factura"
/>
</div>
<div class="space-y-2">
<Label for="project_number">Número de Proyecto</Label>
<Input
id="project_number"
bind:value={formData.project_number}
placeholder="Número de proyecto"
/>
</div>
<div class="space-y-2">
<Label for="purchase_order">Orden de Compra</Label>
<Input
id="purchase_order"
bind:value={formData.purchase_order}
placeholder="Orden de compra"
/>
</div>
<div class="space-y-2">
<Label for="invoice_date">Fecha de Factura</Label>
<Input
id="invoice_date"
type="date"
bind:value={formData.invoice_date}
/>
</div>
<div class="space-y-2">
<Label for="traffic_light_status">Semáforo</Label>
<Input
id="traffic_light_status"
bind:value={formData.traffic_light_status}
placeholder="Estado del semáforo"
/>
</div>
<div class="space-y-2">
<Label for="cfdi_uuid">CFDI UUID</Label>
<Input
id="cfdi_uuid"
bind:value={formData.cfdi_uuid}
placeholder="UUID del CFDI"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4">
<div class="space-y-2">
<Label for="observation_es">Observaciones (Español)</Label>
<Input
id="observation_es"
bind:value={formData.observation_es}
placeholder="Observaciones en español"
/>
</div>
<div class="space-y-2">
<Label for="observation_en">Observaciones (Inglés)</Label>
<Input
id="observation_en"
bind:value={formData.observation_en}
placeholder="Observaciones en inglés"
/>
</div>
</div>
</Tabs.Content>
<!-- Compliance Tab -->
<Tabs.Content value="compliance" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="pedimento">Pedimento</Label>
<Input
id="pedimento"
bind:value={formData.pedimento}
placeholder="Número de pedimento"
/>
</div>
<div class="space-y-2">
<Label for="pedimento_code">Código de Pedimento</Label>
<Input
id="pedimento_code"
bind:value={formData.pedimento_code}
placeholder="R1, K1, etc."
/>
</div>
<div class="space-y-2">
<Label for="remesa">Remesa</Label>
<Input
id="remesa"
type="number"
bind:value={formData.remesa}
placeholder="Número de remesa"
/>
</div>
<div class="space-y-2">
<Label for="aduana">Aduana</Label>
<Input
id="aduana"
bind:value={formData.aduana}
placeholder="Código de aduana"
/>
</div>
<div class="space-y-2">
<Label for="customs_broker_id">Agente Aduanal</Label>
<Input
id="customs_broker_id"
bind:value={formData.customs_broker_id}
placeholder="ID del agente aduanal"
/>
</div>
<div class="space-y-2">
<Label for="provider_id">Proveedor</Label>
<Input
id="provider_id"
bind:value={formData.provider_id}
placeholder="ID del proveedor"
/>
</div>
<div class="space-y-2">
<Label for="edocument">E-Document</Label>
<Input
id="edocument"
bind:value={formData.edocument}
placeholder="Número de e-document"
/>
</div>
<div class="space-y-2 flex items-center gap-2 pt-8">
<input
id="is_mixed"
type="checkbox"
bind:checked={formData.is_mixed}
class="h-4 w-4"
/>
<Label for="is_mixed" class="!mt-0">Operación Mixta</Label>
</div>
</div>
</Tabs.Content>
<!-- Financials Tab -->
<Tabs.Content value="financials" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="currency">Moneda</Label>
<Input
id="currency"
bind:value={formData.currency}
placeholder="MXN, USD, etc."
/>
</div>
<div class="space-y-2">
<Label for="exchange_rate">Tipo de Cambio</Label>
<Input
id="exchange_rate"
type="number"
step="0.000001"
bind:value={formData.exchange_rate}
placeholder="Tipo de cambio"
/>
</div>
<div class="space-y-2">
<Label for="value_mn">Valor MN</Label>
<Input
id="value_mn"
type="number"
step="0.01"
bind:value={formData.value_mn}
placeholder="Valor en moneda nacional"
/>
</div>
<div class="space-y-2">
<Label for="value_me">Valor ME</Label>
<Input
id="value_me"
type="number"
step="0.01"
bind:value={formData.value_me}
placeholder="Valor en moneda extranjera"
/>
</div>
<div class="space-y-2">
<Label for="customs_value_mn">Valor Aduana MN</Label>
<Input
id="customs_value_mn"
type="number"
step="0.01"
bind:value={formData.customs_value_mn}
placeholder="Valor de aduana en MN"
/>
</div>
<div class="space-y-2">
<Label for="freight">Flete</Label>
<Input
id="freight"
type="number"
step="0.01"
bind:value={formData.freight}
placeholder="Costo de flete"
/>
</div>
<div class="space-y-2">
<Label for="insurance">Seguro</Label>
<Input
id="insurance"
type="number"
step="0.01"
bind:value={formData.insurance}
placeholder="Costo de seguro"
/>
</div>
<div class="space-y-2">
<Label for="iva_mn">IVA MN</Label>
<Input
id="iva_mn"
type="number"
step="0.01"
bind:value={formData.iva_mn}
placeholder="IVA en MN"
/>
</div>
<div class="space-y-2">
<Label for="total_quantity">Cantidad Total</Label>
<Input
id="total_quantity"
type="number"
step="0.01"
bind:value={formData.total_quantity}
placeholder="Cantidad total"
/>
</div>
<div class="space-y-2">
<Label for="gross_weight">Peso Bruto</Label>
<Input
id="gross_weight"
type="number"
step="0.01"
bind:value={formData.gross_weight}
placeholder="Peso bruto"
/>
</div>
<div class="space-y-2">
<Label for="net_weight">Peso Neto</Label>
<Input
id="net_weight"
type="number"
step="0.01"
bind:value={formData.net_weight}
placeholder="Peso neto"
/>
</div>
<div class="space-y-2">
<Label for="bundle_count">Número de Bultos</Label>
<Input
id="bundle_count"
type="number"
bind:value={formData.bundle_count}
placeholder="Número de bultos"
/>
</div>
</div>
</Tabs.Content>
</Tabs.Root>
{#if error}
<div class="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
{/if}
<Dialog.Footer>
<Button
type="button"
variant="outline"
onclick={() => (open = false)}
disabled={loading}
>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{/if}
{isEditing ? "Actualizar" : "Crear"}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Ellipsis, Eye, Pencil, Trash2 } from 'lucide-svelte';
interface Props {
invoice: Invoice;
}
let { invoice }: Props = $props();
function dispatchView() {
window.dispatchEvent(new CustomEvent('invoiceView', { detail: invoice }));
}
function dispatchEdit() {
window.dispatchEvent(new CustomEvent('invoiceEdit', { detail: invoice }));
}
function dispatchDelete() {
window.dispatchEvent(new CustomEvent('invoiceDelete', { detail: invoice }));
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
<span class="sr-only">Abrir menú</span>
<Ellipsis class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={dispatchView}>
<Eye class="mr-2 h-4 w-4" />
Ver Detalles
</DropdownMenu.Item>
<DropdownMenu.Item onclick={dispatchEdit}>
<Pencil class="mr-2 h-4 w-4" />
Editar
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={dispatchDelete} class="text-destructive">
<Trash2 class="mr-2 h-4 w-4" />
Eliminar
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>

View File

@@ -0,0 +1,131 @@
<script lang="ts" generics="TData, TValue">
import { onMount } from 'svelte';
import {
type ColumnDef,
getCoreRowModel
} from "@tanstack/table-core";
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
import * as Table from "$lib/components/ui/table/index.js";
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
loading: boolean;
hasMore: boolean;
loadMore: () => void;
};
let {
data,
columns,
loading,
hasMore,
loadMore
}: DataTableProps<TData, TValue> = $props();
const table = createSvelteTable({
get data() {
return data;
},
columns,
getCoreRowModel: getCoreRowModel()
});
let scrollContainer = $state<HTMLDivElement>();
let loadingTrigger = $state<HTMLDivElement>();
// Intersection Observer para detectar cuando el usuario llega al final
onMount(() => {
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
if (entry.isIntersecting && hasMore && !loading) {
loadMore();
}
},
{
root: scrollContainer,
threshold: 0.1
}
);
if (loadingTrigger) {
observer.observe(loadingTrigger);
}
return () => {
observer.disconnect();
};
});
</script>
<div class="w-full">
<div class="rounded-md border max-h-[600px] overflow-y-auto" bind:this={scrollContainer}>
<Table.Root>
<Table.Header class="sticky top-0 bg-background z-10">
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head>
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
</Table.Head>
{/each}
</Table.Row>
{/each}
</Table.Header>
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
<Table.Row data-state={row.getIsSelected() && "selected"}>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell>
{#if cell.column.id === 'actions'}
{@const cellDef = cell.column.columnDef.cell}
{#if cellDef && typeof cellDef === 'function'}
{@const Component = cellDef(cell.getContext())}
<Component invoice={cell.row.original} />
{/if}
{:else}
<FlexRender
content={cell.column.columnDef.cell}
context={cell.getContext()}
/>
{/if}
</Table.Cell>
{/each}
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={columns.length} class="h-24 text-center">
No hay resultados.
</Table.Cell>
</Table.Row>
{/each}
<!-- Loading Trigger - Se activa cuando es visible -->
{#if hasMore}
<Table.Row>
<Table.Cell colspan={columns.length} class="h-20 text-center">
<div bind:this={loadingTrigger}>
{#if loading}
<div class="flex items-center justify-center gap-2">
<div class="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
<span class="text-muted-foreground text-sm">Cargando más...</span>
</div>
{:else}
<div class="text-muted-foreground text-sm">
Desplázate para cargar más
</div>
{/if}
</div>
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
</div>

View File

@@ -0,0 +1,110 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as AlertDialog from "$lib/components/ui/alert-dialog";
import { invoicesApi, type Invoice } from "$lib/api/dashboard/a76/invoices";
import { companyStore } from "$lib/stores/company.svelte";
import { LoaderCircle } from 'lucide-svelte';
let {
open = $bindable(false),
item,
onSuccess
}: {
open: boolean;
item: Invoice | null;
onSuccess?: () => void;
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
async function handleDelete() {
if (!item || !companyStore.activeCompany) return;
loading = true;
error = null;
try {
const response = await invoicesApi.delete(item.id, companyStore.activeCompany.id);
if (response.error) {
error = response.error;
return;
}
// Éxito
open = false;
if (onSuccess) {
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : "Error al eliminar";
console.error("Error deleting:", e);
} finally {
loading = false;
}
}
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
error = null;
}
open = newOpen;
}
</script>
<AlertDialog.Root bind:open onOpenChange={handleOpenChange}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>¿Estás seguro?</AlertDialog.Title>
<AlertDialog.Description class="space-y-2">
<p>Esta acción no se puede deshacer. Se eliminará permanentemente esta factura:</p>
{#if item}
<div class="mt-2 rounded-lg bg-muted p-3 space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="font-medium">ID:</span>
<span class="font-semibold">{item.id}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="font-medium">Número de Factura:</span>
<code class="font-mono font-semibold">{item.invoice_number || 'N/A'}</code>
</div>
<div class="flex items-center justify-between text-sm">
<span class="font-medium">Tipo:</span>
<span class="text-xs">
{item.operation_type === 'imp' ? 'Importación' :
item.operation_type === 'exp' ? 'Exportación' : 'N/A'}
</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="font-medium">Proyecto:</span>
<span class="text-xs">{item.project_number || 'N/A'}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="font-medium">Pedimento:</span>
<span class="text-xs">{item.compliance_mx?.pedimento || 'N/A'}</span>
</div>
</div>
{/if}
{#if error}
<div class="mt-2 rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel disabled={loading}>Cancelar</AlertDialog.Cancel>
<AlertDialog.Action
onclick={handleDelete}
disabled={loading}
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{/if}
Eliminar
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>

View File

@@ -0,0 +1,435 @@
<script lang="ts">
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
import * as Dialog from '$lib/components/ui/dialog';
import * as Tabs from '$lib/components/ui/tabs';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
let {
open = $bindable(false),
invoice
}: {
open: boolean;
invoice: Invoice | null;
} = $props();
function formatDate(dateString: string | null | undefined): string {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('es-MX');
}
function formatCurrency(value: number | null | undefined): string {
if (value === null || value === undefined) return '-';
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN'
}).format(value);
}
function formatNumber(value: number | null | undefined): string {
if (value === null || value === undefined) return '-';
return new Intl.NumberFormat('es-MX').format(value);
}
</script>
<Dialog.Root {open} onOpenChange={(v) => (open = v)}>
<Dialog.Content class="max-w-5xl max-h-[90vh] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>Detalles de Factura #{invoice?.id}</Dialog.Title>
<Dialog.Description>
Información completa de la factura
</Dialog.Description>
</Dialog.Header>
{#if invoice}
<Tabs.Root value="general" class="w-full">
<Tabs.List class="grid w-full grid-cols-5">
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="compliance">Cumplimiento</Tabs.Trigger>
<Tabs.Trigger value="financials">Financieros</Tabs.Trigger>
<Tabs.Trigger value="logistics">Logística</Tabs.Trigger>
<Tabs.Trigger value="details">Detalles</Tabs.Trigger>
</Tabs.List>
<!-- General Tab -->
<Tabs.Content value="general" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm font-medium text-muted-foreground">Tipo de Operación</p>
<p class="text-base">
{#if invoice.operation_type === 'imp'}
<Badge>Importación</Badge>
{:else if invoice.operation_type === 'exp'}
<Badge variant="secondary">Exportación</Badge>
{:else}
-
{/if}
</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Número de Factura</p>
<p class="text-base">{invoice.invoice_number || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Tipo de Factura</p>
<p class="text-base">{invoice.invoice_type || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Número de Proyecto</p>
<p class="text-base">{invoice.project_number || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Orden de Compra</p>
<p class="text-base">{invoice.purchase_order || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Fecha de Factura</p>
<p class="text-base">{formatDate(invoice.invoice_date)}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Fecha de Captura</p>
<p class="text-base">{formatDate(invoice.capture_date)}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Semáforo</p>
<p class="text-base">{invoice.traffic_light_status || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">CFDI UUID</p>
<p class="text-xs break-all">{invoice.cfdi_uuid || '-'}</p>
</div> <div>
<p class="text-sm font-medium text-muted-foreground">Actualizado</p>
<p class="text-base">{invoice.is_updated ? 'Sí' : 'No'}</p>
</div>
<div class="col-span-2">
<p class="text-sm font-medium text-muted-foreground">Observaciones (ES)</p>
<p class="text-base">{invoice.observation_es || '-'}</p>
</div>
<div class="col-span-2">
<p class="text-sm font-medium text-muted-foreground">Observaciones (EN)</p>
<p class="text-base">{invoice.observation_en || '-'}</p>
</div>
<div class="col-span-2">
<p class="text-sm font-medium text-muted-foreground">Log de Proceso</p>
<p class="text-base">{invoice.process_log || '-'}</p>
</div>
</div>
</Tabs.Content>
<!-- Compliance Tab -->
<Tabs.Content value="compliance" class="space-y-4">
{#if invoice.compliance_mx}
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm font-medium text-muted-foreground">Pedimento</p>
<p class="text-base font-semibold">{invoice.compliance_mx.pedimento || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Código de Pedimento</p>
<p class="text-base">{invoice.compliance_mx.pedimento_code || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Remesa</p>
<p class="text-base">{invoice.compliance_mx.remesa || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Aduana</p>
<p class="text-base">{invoice.compliance_mx.aduana || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Agente Aduanal ID</p>
<p class="text-base">{invoice.compliance_mx.customs_broker_id || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Proveedor</p>
<p class="text-base">{invoice.compliance_mx.provider_id || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Vendido A</p>
<p class="text-base">{invoice.compliance_mx.sold_to_id || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Enviado A</p>
<p class="text-base">{invoice.compliance_mx.shipped_to_id || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Enviado Por</p>
<p class="text-base">{invoice.compliance_mx.shipped_by_id || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Operación Mixta</p>
<p class="text-base">{invoice.compliance_mx.is_mixed ? 'Sí' : 'No'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Tipo de Desperdicio</p>
<p class="text-base">{invoice.compliance_mx.waste_type || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Apéndice 17</p>
<p class="text-base">{invoice.compliance_mx.appendix_17 || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">E-Document</p>
<p class="text-base">{invoice.compliance_mx.edocument || '-'}</p>
</div>
<div class="col-span-2">
<p class="text-sm font-medium text-muted-foreground">Firma Electrónica</p>
<p class="text-xs break-all">{invoice.compliance_mx.electronic_signature || '-'}</p>
</div>
</div>
{:else}
<p class="text-muted-foreground">No hay información de cumplimiento disponible.</p>
{/if}
</Tabs.Content>
<!-- Financials Tab -->
<Tabs.Content value="financials" class="space-y-4">
{#if invoice.financials}
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm font-medium text-muted-foreground">Moneda</p>
<p class="text-base">{invoice.financials.currency || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Tipo de Cambio</p>
<p class="text-base">{formatNumber(invoice.financials.exchange_rate)}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Valor MN</p>
<p class="text-base font-semibold">{formatCurrency(invoice.financials.value_mn)}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Valor ME</p>
<p class="text-base font-semibold">{formatNumber(invoice.financials.value_me)}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Valor Aduana MN</p>
<p class="text-base">{formatCurrency(invoice.financials.customs_value_mn)}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Flete</p>
<p class="text-base">{formatCurrency(invoice.financials.freight)}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Seguro</p>
<p class="text-base">{formatCurrency(invoice.financials.insurance)}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">IVA MN</p>
<p class="text-base">{formatCurrency(invoice.financials.iva_mn)}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Factor IVA</p>
<p class="text-base">{formatNumber(invoice.financials.iva_factor)}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Cantidad Total</p>
<p class="text-base">{formatNumber(invoice.financials.total_quantity)}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Peso Bruto</p>
<p class="text-base">{formatNumber(invoice.financials.gross_weight)}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Peso Neto</p>
<p class="text-base">{formatNumber(invoice.financials.net_weight)}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Número de Bultos</p>
<p class="text-base">{invoice.financials.bundle_count || '-'}</p>
</div>
</div>
{:else}
<p class="text-muted-foreground">No hay información financiera disponible.</p>
{/if}
</Tabs.Content>
<!-- Logistics Tab -->
<Tabs.Content value="logistics" class="space-y-4">
{#if invoice.logistics && invoice.logistics.length > 0}
<div class="space-y-6">
{#each invoice.logistics as logistics, index}
<div class="border rounded-lg p-4">
<h4 class="font-semibold mb-3">Logística #{index + 1}</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm font-medium text-muted-foreground">Transportista</p>
<p class="text-base">{logistics.carrier_id || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Tipo de Transporte</p>
<p class="text-base">{logistics.transport_type || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Modo de Transporte</p>
<p class="text-base">{logistics.transport_mode || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Conductor</p>
<p class="text-base">{logistics.driver_name || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Número de Vehículo</p>
<p class="text-base">{logistics.vehicle_num || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Placa</p>
<p class="text-base">{logistics.license_plate || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Número de Sello</p>
<p class="text-base">{logistics.seal_number || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Guía</p>
<p class="text-base">{logistics.guide_number || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Fecha Entrada/Salida</p>
<p class="text-base">{formatDate(logistics.entry_exit_date)}</p>
</div>
</div>
</div>
{/each}
</div>
{:else}
<p class="text-muted-foreground">No hay información de logística disponible.</p>
{/if}
</Tabs.Content>
<!-- Details Tab -->
<Tabs.Content value="details" class="space-y-4">
{#if invoice.details && invoice.details.length > 0}
<div class="space-y-6">
<div>
<h4 class="font-semibold mb-3">Detalles de Venta</h4>
<div class="space-y-3">
{#each invoice.details as detail}
<div class="border rounded-lg p-4">
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm font-medium text-muted-foreground">Línea</p>
<p class="text-base">{detail.line_number}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Orden de Venta</p>
<p class="text-base">{detail.sales_order || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Descripción de Colores</p>
<p class="text-base">{detail.colors_description || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Código de Color</p>
<p class="text-base">{detail.square_color_code || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Bultos</p>
<p class="text-base">{detail.line_bundles || '-'}</p>
</div>
</div>
</div>
{/each}
</div>
</div>
{#if invoice.collections && invoice.collections.length > 0}
<div>
<h4 class="font-semibold mb-3">Cobranzas</h4>
<div class="space-y-3">
{#each invoice.collections as collection}
<div class="border rounded-lg p-4">
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm font-medium text-muted-foreground">Concepto</p>
<p class="text-base">{collection.concept || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Monto</p>
<p class="text-base">{formatCurrency(collection.amount)}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Fecha de Cobranza</p>
<p class="text-base">{formatDate(collection.collection_date)}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Cobrado</p>
<p class="text-base">{collection.is_collected ? 'Sí' : 'No'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Cobrador</p>
<p class="text-base">{collection.collector_user || '-'}</p>
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
{:else}
<p class="text-muted-foreground">No hay detalles de venta o cobranzas disponibles.</p>
{/if}
</Tabs.Content>
</Tabs.Root>
{/if}
<Dialog.Footer>
<Button variant="outline" onclick={() => (open = false)}>
Cerrar
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,4 +1,6 @@
import {
ArrowDownToLine,
ArrowUpFromLine,
BadgeCheck,
ChartPie,
Database,
@@ -314,6 +316,44 @@ export function getSidebarData(): SidebarData {
},
],
},
{
title: m["sidebar.import_invoices.title"](),
url: "#",
icon: ArrowDownToLine,
items: [
{
title: m["sidebar.import_invoices.temporary"](),
url: "/dashboard/invoices",
},
{
title: m["sidebar.import_invoices.definitive"](),
url: "/dashboard/invoices",
},
{
title: m["sidebar.import_invoices.mexican_purchases"](),
url: "/dashboard/invoices",
},
{
title: m["sidebar.import_invoices.regime_change"](),
url: "/dashboard/invoices",
}
],
},
{
title: m["sidebar.export_invoices.title"](),
url: "#",
icon: ArrowUpFromLine,
items: [
{
title: m["sidebar.export_invoices.exportation"](),
url: "/dashboard/invoices",
},
{
title: m["sidebar.export_invoices.repair"](),
url: "/dashboard/invoices",
},
],
},
{
title: m["sidebar.clients_and_providers"](),
url: "/dashboard/clients_and_providers",