Merge pull request 'feature/funcionalidades-click-derecho-facturas' (#451) from feature/funcionalidades-click-derecho-facturas into development

Reviewed-on: ADUANASOFT/anexo76#451
This commit is contained in:
2026-05-26 16:39:05 +00:00
17 changed files with 2598 additions and 4 deletions

View File

@@ -1,5 +1,5 @@
import logging
from typing import Dict, Any, Optional
from typing import Dict, Any, Literal, Optional
from core.config import settings
from core.database import get_core_db
@@ -255,6 +255,252 @@ def delete_invoice(
success = services.InvoiceService.delete(db, invoice_id, tenant_id, company_id)
return {"success": success}
@router.post("/invoices/{invoice_id}/copy", response_model=schemas.InvoiceHeaderResponse)
def copy_invoice(
invoice_id: int = Path(..., description="ID de la factura a copiar"),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
original = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
if not original:
raise HTTPException(status_code=404, detail="Factura no encontrada")
perm_base = get_invoice_permission_base(original.operation_type, original.invoice_type)
validate_access_to_resource(
db, company_id, current_user, required_permissions=[f"{perm_base}.create"]
)
try:
return services.InvoiceService.copy(db, invoice_id, tenant_id, company_id)
except HTTPException:
raise
except Exception as e:
logger.exception("copy_invoice failed: %s", e)
raise HTTPException(status_code=500, detail=f"Error al copiar factura: {str(e)}")
@router.post("/invoices/{invoice_id}/copy-header", response_model=schemas.InvoiceHeaderResponse)
def copy_invoice_header(
invoice_id: int = Path(..., description="ID de la factura"),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
original = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
if not original:
raise HTTPException(status_code=404, detail="Factura no encontrada")
perm_base = get_invoice_permission_base(original.operation_type, original.invoice_type)
validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.create"])
try:
return services.InvoiceService.copy_header_only(db, invoice_id, tenant_id, company_id)
except HTTPException:
raise
except Exception as e:
logger.exception("copy_invoice_header failed: %s", e)
raise HTTPException(status_code=500, detail=f"Error al copiar encabezado: {str(e)}")
@router.get("/invoices/{invoice_id}/export")
def export_invoice(
invoice_id: int = Path(..., description="ID de la factura"),
format: Literal['csv', 'xlsx', 'txt'] = Query(..., description="Formato: csv | xlsx | txt"),
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
invoice = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
if not invoice:
raise HTTPException(status_code=404, detail="Factura no encontrada")
perm_base = get_invoice_permission_base(invoice.operation_type, invoice.invoice_type)
validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.view"])
try:
return services.InvoiceService.export_items(db, invoice, format)
except HTTPException:
raise
except Exception as e:
logger.exception("export_invoice failed: %s", e)
raise HTTPException(status_code=500, detail=f"Error al exportar factura: {str(e)}")
@router.get("/invoices/{invoice_id}/interfaces/gm-transport")
def interface_gm_transport(
invoice_id: int = Path(..., description="ID de la factura"),
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
invoice = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
if not invoice:
raise HTTPException(status_code=404, detail="Factura no encontrada")
perm_base = get_invoice_permission_base(invoice.operation_type, invoice.invoice_type)
validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.view"])
try:
return services.InvoiceService.export_gm_transport(db, invoice)
except HTTPException:
raise
except Exception as e:
logger.exception("interface_gm_transport failed: %s", e)
raise HTTPException(status_code=500, detail=f"Error al generar interfaz GM Transport: {str(e)}")
@router.get("/invoices/{invoice_id}/interfaces/carta-porte")
def interface_carta_porte(
invoice_id: int = Path(..., description="ID de la factura"),
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
invoice = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
if not invoice:
raise HTTPException(status_code=404, detail="Factura no encontrada")
perm_base = get_invoice_permission_base(invoice.operation_type, invoice.invoice_type)
validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.view"])
try:
return services.InvoiceService.export_carta_porte(db, invoice)
except HTTPException:
raise
except Exception as e:
logger.exception("interface_carta_porte failed: %s", e)
raise HTTPException(status_code=500, detail=f"Error al generar interfaz Carta Porte: {str(e)}")
@router.get("/invoices/{invoice_id}/interfaces/tfc")
def interface_tfc(
invoice_id: int = Path(..., description="ID de la factura"),
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
invoice = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
if not invoice:
raise HTTPException(status_code=404, detail="Factura no encontrada")
perm_base = get_invoice_permission_base(invoice.operation_type, invoice.invoice_type)
validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.view"])
try:
return services.InvoiceService.export_tfc(db, invoice)
except HTTPException:
raise
except Exception as e:
logger.exception("interface_tfc failed: %s", e)
raise HTTPException(status_code=500, detail=f"Error al generar interfaz TFC: {str(e)}")
@router.get("/invoices/{invoice_id}/interfaces/carta-porte-consolidada")
def interface_carta_porte_consolidada(
invoice_id: int = Path(..., description="ID de la factura"),
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
invoice = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
if not invoice:
raise HTTPException(status_code=404, detail="Factura no encontrada")
perm_base = get_invoice_permission_base(invoice.operation_type, invoice.invoice_type)
validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.view"])
try:
return services.InvoiceService.export_carta_porte_consolidada(db, invoice)
except HTTPException:
raise
except Exception as e:
logger.exception("interface_carta_porte_consolidada failed: %s", e)
raise HTTPException(status_code=500, detail=f"Error al generar interfaz Carta Porte Consolidada: {str(e)}")
@router.get("/invoices/{invoice_id}/interfaces/aviso-cruce")
def interface_aviso_cruce(
invoice_id: int = Path(..., description="ID de la factura"),
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
invoice = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
if not invoice:
raise HTTPException(status_code=404, detail="Factura no encontrada")
perm_base = get_invoice_permission_base(invoice.operation_type, invoice.invoice_type)
validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.view"])
try:
return services.InvoiceService.export_aviso_cruce(db, invoice)
except HTTPException:
raise
except Exception as e:
logger.exception("interface_aviso_cruce failed: %s", e)
raise HTTPException(status_code=500, detail=f"Error al generar interfaz Aviso Cruce: {str(e)}")
@router.get("/invoices/{invoice_id}/interfaces/aaduanal-rs")
def interface_aaduanal_rs(
invoice_id: int = Path(..., description="ID de la factura"),
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
invoice = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
if not invoice:
raise HTTPException(status_code=404, detail="Factura no encontrada")
perm_base = get_invoice_permission_base(invoice.operation_type, invoice.invoice_type)
validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.view"])
try:
return services.InvoiceService.export_aaduanal_rs(db, invoice)
except HTTPException:
raise
except Exception as e:
logger.exception("interface_aaduanal_rs failed: %s", e)
raise HTTPException(status_code=500, detail=f"Error al generar interfaz AAduanal_RS: {str(e)}")
@router.get("/invoices/{invoice_id}/interfaces/caaarem")
def interface_caaarem(
invoice_id: int = Path(...),
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
invoice = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
if not invoice:
raise HTTPException(status_code=404, detail="Factura no encontrada")
perm_base = get_invoice_permission_base(invoice.operation_type, invoice.invoice_type)
validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.view"])
try:
return services.InvoiceService.export_caaarem(db, invoice)
except HTTPException:
raise
except Exception as e:
logger.exception("interface_caaarem failed: %s", e)
raise HTTPException(status_code=500, detail=f"Error al generar interfaz CAAAREM: {str(e)}")
@router.get("/invoices/{invoice_id}/interfaces/cp-genesis")
def interface_cp_genesis(
invoice_id: int = Path(...),
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
invoice = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
if not invoice:
raise HTTPException(status_code=404, detail="Factura no encontrada")
perm_base = get_invoice_permission_base(invoice.operation_type, invoice.invoice_type)
validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.view"])
try:
return services.InvoiceService.export_cp_genesis(db, invoice)
except HTTPException:
raise
except Exception as e:
logger.exception("interface_cp_genesis failed: %s", e)
raise HTTPException(status_code=500, detail=f"Error al generar interfaz Carta Porte Genesis: {str(e)}")
# --- RUTA DE LISTADO (FILTROS) ---

File diff suppressed because it is too large Load Diff

View File

@@ -44,6 +44,7 @@ pylint==4.0.2
# reportes
Jinja2==3.1.6
pdfkit==1.0.0
openpyxl==3.1.5
# Desarrollo en seguno plano
celery==5.3.6

View File

@@ -596,5 +596,50 @@ export const invoicesApi = {
return api.getBlob(
`/v1/a76/factura-cove/invoices/${invoiceId}/cove/acuse?${params.toString()}`
);
},
copyInvoice: (invoiceId: number, companyId: number) => {
const params = new URLSearchParams({ company_id: companyId.toString() });
return api.post<Invoice>(`/v1/a76/invoices/${invoiceId}/copy?${params.toString()}`, {});
},
copyInvoiceHeader: (invoiceId: number, companyId: number) => {
const params = new URLSearchParams({ company_id: companyId.toString() });
return api.post<Invoice>(`/v1/a76/invoices/${invoiceId}/copy-header?${params.toString()}`, {});
},
exportItems: (invoiceId: number, companyId: number, format: 'csv' | 'xlsx' | 'txt'): Promise<Blob> => {
const params = new URLSearchParams({ company_id: companyId.toString(), format });
return api.getBlob(`/v1/a76/invoices/${invoiceId}/export?${params.toString()}`);
},
downloadGmTransport: (invoiceId: number, companyId: number): Promise<Blob> => {
const params = new URLSearchParams({ company_id: companyId.toString() });
return api.getBlob(`/v1/a76/invoices/${invoiceId}/interfaces/gm-transport?${params.toString()}`);
},
downloadCartaPorte: (invoiceId: number, companyId: number): Promise<Blob> => {
const params = new URLSearchParams({ company_id: companyId.toString() });
return api.getBlob(`/v1/a76/invoices/${invoiceId}/interfaces/carta-porte?${params.toString()}`);
},
downloadTfc: (invoiceId: number, companyId: number): Promise<Blob> => {
const params = new URLSearchParams({ company_id: companyId.toString() });
return api.getBlob(`/v1/a76/invoices/${invoiceId}/interfaces/tfc?${params.toString()}`);
},
downloadCartaPorteConsolidada: (invoiceId: number, companyId: number): Promise<Blob> => {
const params = new URLSearchParams({ company_id: companyId.toString() });
return api.getBlob(`/v1/a76/invoices/${invoiceId}/interfaces/carta-porte-consolidada?${params.toString()}`);
},
downloadAvisoCruce: (invoiceId: number, companyId: number): Promise<Blob> => {
const params = new URLSearchParams({ company_id: companyId.toString() });
return api.getBlob(`/v1/a76/invoices/${invoiceId}/interfaces/aviso-cruce?${params.toString()}`);
},
downloadAaduanalRs: (invoiceId: number, companyId: number): Promise<Blob> => {
const params = new URLSearchParams({ company_id: companyId.toString() });
return api.getBlob(`/v1/a76/invoices/${invoiceId}/interfaces/aaduanal-rs?${params.toString()}`);
},
downloadCaaarem: (invoiceId: number, companyId: number): Promise<Blob> => {
const params = new URLSearchParams({ company_id: companyId.toString() });
return api.getBlob(`/v1/a76/invoices/${invoiceId}/interfaces/caaarem?${params.toString()}`);
},
downloadCpGenesis: (invoiceId: number, companyId: number): Promise<Blob> => {
const params = new URLSearchParams({ company_id: companyId.toString() });
return api.getBlob(`/v1/a76/invoices/${invoiceId}/interfaces/cp-genesis?${params.toString()}`);
}
};

View File

@@ -14,6 +14,7 @@
// Props para selección
selectedIds?: number[];
onRowClick?: (row: TData) => void;
onContextMenu?: (event: MouseEvent, row: TData) => void;
compact?: boolean;
sorting?: import("@tanstack/table-core").SortingState;
onSortingChange?: (sorting: import("@tanstack/table-core").SortingState) => void;
@@ -27,6 +28,7 @@
loadMore,
selectedIds = [],
onRowClick,
onContextMenu,
compact = false,
sorting = [],
onSortingChange
@@ -211,6 +213,7 @@
class="group/inv-list cursor-pointer {row.getIsSelected() ? 'catalog-table-row-selected' : 'catalog-table-row'}"
onclick={() => onRowClick && onRowClick(row.original)}
ondblclick={() => handleRowDoubleClick(row)}
oncontextmenu={(e) => { e.preventDefault(); onContextMenu?.(e, row.original); }}
>
{#each visibleCells as cell (cell.id)}
{@const colId = cell.column.id}

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
portalProps,
class: className,
...restProps
}: ContextMenuPrimitive.ContentProps & {
portalProps?: ContextMenuPrimitive.PortalProps;
} = $props();
</script>
<ContextMenuPrimitive.Portal {...portalProps}>
<ContextMenuPrimitive.Content
bind:ref
data-slot="context-menu-content"
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-context-menu-content-available-height) origin-(--bits-context-menu-content-transform-origin) z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md outline-none",
className
)}
{...restProps}
/>
</ContextMenuPrimitive.Portal>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let { ...restProps }: ContextMenuPrimitive.GroupProps = $props();
</script>
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...restProps} />

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
inset,
variant = "default",
...restProps
}: ContextMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: "default" | "destructive";
} = $props();
</script>
<ContextMenuPrimitive.Item
bind:ref
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ContextMenuPrimitive.GroupHeadingProps & { inset?: boolean } = $props();
</script>
<ContextMenuPrimitive.GroupHeading
bind:ref
data-slot="context-menu-label"
data-inset={inset}
class={cn("text-muted-foreground px-2 py-1.5 text-xs font-medium data-[inset]:pl-8", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let { ...restProps }: ContextMenuPrimitive.RootProps = $props();
</script>
<ContextMenuPrimitive.Root {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
...restProps
}: ContextMenuPrimitive.SeparatorProps = $props();
</script>
<ContextMenuPrimitive.Separator
bind:ref
data-slot="context-menu-separator"
class={cn("bg-border -mx-1 my-1 h-px", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
...restProps
}: ContextMenuPrimitive.SubContentProps = $props();
</script>
<ContextMenuPrimitive.SubContent
bind:ref
data-slot="context-menu-sub-content"
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-context-menu-content-transform-origin) z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: ContextMenuPrimitive.SubTriggerProps & { inset?: boolean } = $props();
</script>
<ContextMenuPrimitive.SubTrigger
bind:ref
data-slot="context-menu-sub-trigger"
data-inset={inset}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground outline-hidden [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="ml-auto size-4" />
</ContextMenuPrimitive.SubTrigger>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let { ...restProps }: ContextMenuPrimitive.SubProps = $props();
</script>
<ContextMenuPrimitive.Sub {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let { ...restProps }: ContextMenuPrimitive.TriggerProps = $props();
</script>
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...restProps} />

View File

@@ -0,0 +1,31 @@
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import Content from "./context-menu-content.svelte";
import Group from "./context-menu-group.svelte";
import Item from "./context-menu-item.svelte";
import Label from "./context-menu-label.svelte";
import Separator from "./context-menu-separator.svelte";
import Sub from "./context-menu-sub.svelte";
import SubContent from "./context-menu-sub-content.svelte";
import SubTrigger from "./context-menu-sub-trigger.svelte";
import Trigger from "./context-menu-trigger.svelte";
import Root from "./context-menu-root.svelte";
const CheckboxItem = ContextMenuPrimitive.CheckboxItem;
const RadioGroup = ContextMenuPrimitive.RadioGroup;
const RadioItem = ContextMenuPrimitive.RadioItem;
export {
Root,
Trigger,
Content,
Item,
Label,
Separator,
Group,
Sub,
SubTrigger,
SubContent,
CheckboxItem,
RadioGroup,
RadioItem,
};

View File

@@ -20,6 +20,7 @@
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as RadioGroup from '$lib/components/ui/radio-group';
import type { PageData } from './$types';
import { browser } from '$app/environment';
import { getAccessTokenFromDocument } from '$lib/access-token-cookie-browser';
@@ -54,7 +55,14 @@
ArrowRightLeft,
Database,
ChevronUp,
Mail
Mail,
Copy,
Clipboard,
FileOutput,
Truck,
Route,
FileSpreadsheet,
Building2
} from 'lucide-svelte';
import DetailsDialog from '$lib/components/dashboard/invoices/details-dialog.svelte';
import DeleteDialog from '$lib/components/dashboard/invoices/delete-dialog.svelte';
@@ -95,6 +103,9 @@
let isDownloadModalOpen = $state(false);
let isTransferenciaModalOpen = $state(false);
let isRevertConfirmOpen = $state(false);
let exportItemsDialogOpen = $state(false);
let exportItemsInvoice = $state<Invoice | null>(null);
let exportItemsFormat = $state<'csv' | 'xlsx' | 'txt'>('csv');
// Efecto reactivo para actualizar filtros cuando cambian los query parameters en la URL
$effect(() => {
@@ -241,17 +252,27 @@
// Estado para selección de filas (múltiple)
let selectedInvoiceIds = $state<number[]>([]);
// Estado para menú contextual (clic derecho en fila)
let contextMenuOpen = $state(false);
let contextMenuPosition = $state({ x: 0, y: 0 });
let contextMenuInvoice = $state<Invoice | null>(null);
let contextMenuEl = $state<HTMLDivElement | null>(null);
// Estado para los diálogos de acciones
let showDetailsDialog = $state(false);
let showDeleteDialog = $state(false);
let reportesMenuOpen = $state(false);
let masAccionesMenuOpen = $state(false);
let copiasInterfacesMenuOpen = $state(false);
$effect(() => {
if (reportesMenuOpen) masAccionesMenuOpen = false;
if (reportesMenuOpen) { masAccionesMenuOpen = false; copiasInterfacesMenuOpen = false; }
});
$effect(() => {
if (masAccionesMenuOpen) reportesMenuOpen = false;
if (masAccionesMenuOpen) { reportesMenuOpen = false; copiasInterfacesMenuOpen = false; }
});
$effect(() => {
if (copiasInterfacesMenuOpen) { reportesMenuOpen = false; masAccionesMenuOpen = false; }
});
function handleRowClick(invoice: Invoice) {
@@ -265,6 +286,72 @@
}
}
async function handleRowContextMenu(event: MouseEvent, invoice: Invoice) {
if (!selectedInvoiceIds.includes(invoice.id)) {
selectedInvoiceIds = [invoice.id];
}
contextMenuInvoice = invoice;
contextMenuPosition = { x: event.clientX, y: event.clientY };
contextMenuOpen = true;
await tick();
if (contextMenuEl) {
const { innerWidth, innerHeight } = window;
const rect = contextMenuEl.getBoundingClientRect();
let { x, y } = contextMenuPosition;
if (x + rect.width > innerWidth) x = innerWidth - rect.width - 8;
if (y + rect.height > innerHeight) y = innerHeight - rect.height - 8;
if (x < 8) x = 8;
if (y < 8) y = 8;
contextMenuPosition = { x, y };
}
}
async function handleCopyToClipboard(invoice: Invoice, fromContextMenu = true) {
const parts = [
invoice.invoice_number,
invoice.invoice_date,
invoice.operation_type?.toUpperCase(),
invoice.compliance_mx?.pedimento?.pedimento_number
].filter(Boolean);
await navigator.clipboard.writeText(parts.join(' | '));
toast.success('Factura copiada al portapapeles');
if (fromContextMenu) contextMenuOpen = false;
}
function openExportItemsDialog(inv: Invoice) {
exportItemsInvoice = inv;
exportItemsFormat = 'csv';
exportItemsDialogOpen = true;
}
function handleExportItemsConfirm() {
if (!exportItemsInvoice) return;
const inv = exportItemsInvoice;
const ext = exportItemsFormat;
downloadBlob(
invoicesApi.exportItems(inv.id, companyStore.activeCompany!.id, ext),
`${inv.invoice_number ?? 'factura'}_export.${ext}`
);
exportItemsDialogOpen = false;
exportItemsInvoice = null;
}
async function downloadBlob(blobPromise: Promise<Blob>, filename: string) {
try {
const blob = await blobPromise;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch {
toast.error('Error al descargar el archivo');
}
}
const selectedInvoice = $derived(
selectedInvoiceIds.length === 1 ? allItems.find((i) => i.id === selectedInvoiceIds[0]) : null
);
@@ -1614,6 +1701,7 @@
{loadMore}
selectedIds={selectedInvoiceIds}
onRowClick={handleRowClick}
onContextMenu={handleRowContextMenu}
{sorting}
onSortingChange={(newSorting) => (sorting = newSorting)}
/>
@@ -1810,6 +1898,121 @@
</AlertDialog.Content>
</AlertDialog.Root>
{#if contextMenuOpen && contextMenuInvoice}
<!-- Capa de cierre del menú contextual -->
<button
type="button"
class="fixed inset-0 z-40 cursor-default border-0 bg-transparent p-0 [&:focus-visible]:outline-none"
aria-label="Cerrar menú"
onclick={() => { contextMenuOpen = false; contextMenuInvoice = null; }}
oncontextmenu={(e) => { e.preventDefault(); contextMenuOpen = false; contextMenuInvoice = null; }}
></button>
<!-- Menú contextual de fila -->
<div
bind:this={contextMenuEl}
class="fixed z-50 min-w-56 rounded-md border bg-popover p-1 text-sm shadow-md"
style={`top: ${contextMenuPosition.y}px; left: ${contextMenuPosition.x}px;`}
>
<p class="truncate px-2 py-1 text-xs font-medium text-muted-foreground">{contextMenuInvoice.invoice_number ?? 'Factura'}</p>
<div class="-mx-1 my-1 h-px bg-border"></div>
{#snippet cmItem(icon: any, label: string, action: () => void)}
{@const Icon = icon}
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground"
onclick={() => {
// Capturar antes de nullificar el estado
action();
contextMenuOpen = false;
contextMenuInvoice = null;
}}
>
<Icon class="h-4 w-4 shrink-0" />
<span>{label}</span>
</button>
{/snippet}
{@render cmItem(Copy, 'Copiar Factura', () => {
invoicesApi.copyInvoice(contextMenuInvoice!.id, companyStore.activeCompany!.id)
.then((res) => {
if (res.error) { toast.error(res.error); return; }
toast.success('Factura copiada');
reloadData();
});
})}
{@render cmItem(Download, 'Exportar Partidas', () => {
openExportItemsDialog(contextMenuInvoice!);
})}
{@render cmItem(ArrowRightLeft, 'Copiar Encabezado a SCAII', () => {
const invId = contextMenuInvoice!.id;
invoicesApi.copyInvoiceHeader(invId, companyStore.activeCompany!.id)
.then((res) => {
if (res.error) { toast.error(res.error); return; }
toast.success('Encabezado copiado');
reloadData();
});
})}
<div class="-mx-1 my-1 h-px bg-border"></div>
{@render cmItem(Route, 'Interfaz Carta Porte', () => {
const inv = contextMenuInvoice!;
downloadBlob(
invoicesApi.downloadCartaPorte(inv.id, companyStore.activeCompany!.id),
`${inv.invoice_number ?? 'factura'}_carta_porte.csv`
);
})}
{@render cmItem(Truck, 'Interfaz GM Transport', () => {
const inv = contextMenuInvoice!;
downloadBlob(
invoicesApi.downloadGmTransport(inv.id, companyStore.activeCompany!.id),
`${inv.invoice_number ?? 'factura'}_gm_transport.csv`
);
})}
{@render cmItem(Truck, 'Interfaz TFC', () => {
const inv = contextMenuInvoice!;
downloadBlob(
invoicesApi.downloadTfc(inv.id, companyStore.activeCompany!.id),
`${inv.invoice_number ?? 'factura'}_tfc.csv`
);
})}
{@render cmItem(FileText, 'Interfaz Aviso Traslado', () => {
const inv = contextMenuInvoice!;
downloadBlob(
invoicesApi.downloadAvisoCruce(inv.id, companyStore.activeCompany!.id),
`${inv.invoice_number ?? 'factura'}_aviso_cruce.csv`
);
})}
{@render cmItem(Route, 'Interfaz Carta Porte Consolidada', () => {
const inv = contextMenuInvoice!;
downloadBlob(
invoicesApi.downloadCartaPorteConsolidada(inv.id, companyStore.activeCompany!.id),
`${inv.invoice_number ?? 'factura'}_carta_porte_consolidada.csv`
);
})}
{@render cmItem(Building2, 'Interfaz CAAAREM', () => {
const inv = contextMenuInvoice!;
downloadBlob(
invoicesApi.downloadCaaarem(inv.id, companyStore.activeCompany!.id),
`${inv.invoice_number ?? 'factura'}_caaarem.csv`
);
})}
{@render cmItem(ArrowRightLeft, 'Interfaz AAduanal_RS', () => {
const inv = contextMenuInvoice!;
downloadBlob(
invoicesApi.downloadAaduanalRs(inv.id, companyStore.activeCompany!.id),
`${inv.invoice_number ?? 'factura'}_aaduanal_rs.csv`
);
})}
{@render cmItem(Route, 'Interfaz Carta Porte Genesis', () => {
const inv = contextMenuInvoice!;
downloadBlob(
invoicesApi.downloadCpGenesis(inv.id, companyStore.activeCompany!.id),
`${inv.invoice_number ?? 'factura'}_cp_genesis.csv`
);
})}
<div class="-mx-1 my-1 h-px bg-border"></div>
{@render cmItem(Clipboard, 'Copiar Factura (Portapapeles)', () => handleCopyToClipboard(contextMenuInvoice!))}
</div>
{/if}
{#if showVuSubmenu}
<!-- Capa para cerrar el submenú al hacer click fuera -->
<button
@@ -2088,6 +2291,186 @@
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
<!-- Dropdown: Copias e Interfaces -->
<DropdownMenu.Root
open={copiasInterfacesMenuOpen}
onOpenChange={(v) => { copiasInterfacesMenuOpen = v; }}
>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
size="sm"
disabled={selectedInvoiceIds.length !== 1}
data-footer-action="copias-interfaces"
>
<Copy class="mr-2 h-4 w-4" />
Copias / Interfaces
<ChevronUp class="ml-2 h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
align="end"
class="max-h-[480px] w-60 overflow-y-auto"
data-invoice-footer-dropdown="copias-interfaces"
>
<DropdownMenu.Group>
<DropdownMenu.Label>Copias</DropdownMenu.Label>
<DropdownMenu.Item
onclick={() => {
if (!selectedInvoice) return;
const invId = selectedInvoice.id;
copiasInterfacesMenuOpen = false;
invoicesApi.copyInvoice(invId, companyStore.activeCompany!.id)
.then((res) => {
if (res.error) { toast.error(res.error); return; }
toast.success('Factura copiada');
reloadData();
});
}}
>
<Copy class="mr-2 h-4 w-4" />
Copiar Factura
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => {
if (!selectedInvoice) return;
copiasInterfacesMenuOpen = false;
openExportItemsDialog(selectedInvoice);
}}
>
<Download class="mr-2 h-4 w-4" />
Exportar Partidas
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => {
if (!selectedInvoice) return;
const invId = selectedInvoice.id;
copiasInterfacesMenuOpen = false;
invoicesApi.copyInvoiceHeader(invId, companyStore.activeCompany!.id)
.then((res) => {
if (res.error) { toast.error(res.error); return; }
toast.success('Encabezado copiado');
reloadData();
});
}}
>
<ArrowRightLeft class="mr-2 h-4 w-4" />
Copiar Encabezado a SCAII
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => { copiasInterfacesMenuOpen = false; if (selectedInvoice) handleCopyToClipboard(selectedInvoice, false); }}
>
<Clipboard class="mr-2 h-4 w-4" />
Copiar Factura (Portapapeles)
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Label>Interfaces</DropdownMenu.Label>
<DropdownMenu.Item onclick={async () => {
if (!selectedInvoice) return;
const inv = selectedInvoice;
copiasInterfacesMenuOpen = false;
downloadBlob(
invoicesApi.downloadCartaPorte(inv.id, companyStore.activeCompany!.id),
`${inv.invoice_number ?? 'factura'}_carta_porte.csv`
);
}}>
<Route class="mr-2 h-4 w-4" />
Carta Porte
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => {
if (!selectedInvoice) return;
const inv = selectedInvoice;
copiasInterfacesMenuOpen = false;
downloadBlob(
invoicesApi.downloadGmTransport(inv.id, companyStore.activeCompany!.id),
`${inv.invoice_number ?? 'factura'}_gm_transport.csv`
);
}}>
<Truck class="mr-2 h-4 w-4" />
GM Transport
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => {
if (!selectedInvoice) return;
const inv = selectedInvoice;
copiasInterfacesMenuOpen = false;
downloadBlob(
invoicesApi.downloadTfc(inv.id, companyStore.activeCompany!.id),
`${inv.invoice_number ?? 'factura'}_tfc.csv`
);
}}>
<Truck class="mr-2 h-4 w-4" />
TFC
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => {
if (!selectedInvoice) return;
const inv = selectedInvoice;
copiasInterfacesMenuOpen = false;
downloadBlob(
invoicesApi.downloadAvisoCruce(inv.id, companyStore.activeCompany!.id),
`${inv.invoice_number ?? 'factura'}_aviso_cruce.csv`
);
}}>
<FileText class="mr-2 h-4 w-4" />
Aviso Traslado
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => {
if (!selectedInvoice) return;
const inv = selectedInvoice;
copiasInterfacesMenuOpen = false;
downloadBlob(
invoicesApi.downloadCartaPorteConsolidada(inv.id, companyStore.activeCompany!.id),
`${inv.invoice_number ?? 'factura'}_carta_porte_consolidada.csv`
);
}}>
<Route class="mr-2 h-4 w-4" />
Carta Porte Consolidada
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => {
if (!selectedInvoice) return;
const inv = selectedInvoice;
copiasInterfacesMenuOpen = false;
downloadBlob(
invoicesApi.downloadCaaarem(inv.id, companyStore.activeCompany!.id),
`${inv.invoice_number ?? 'factura'}_caaarem.csv`
);
}}>
<Building2 class="mr-2 h-4 w-4" />
CAAAREM
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => {
if (!selectedInvoice) return;
const inv = selectedInvoice;
copiasInterfacesMenuOpen = false;
downloadBlob(
invoicesApi.downloadAaduanalRs(inv.id, companyStore.activeCompany!.id),
`${inv.invoice_number ?? 'factura'}_aaduanal_rs.csv`
);
}}>
<ArrowRightLeft class="mr-2 h-4 w-4" />
AAduanal_RS
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => {
if (!selectedInvoice) return;
const inv = selectedInvoice;
copiasInterfacesMenuOpen = false;
downloadBlob(
invoicesApi.downloadCpGenesis(inv.id, companyStore.activeCompany!.id),
`${inv.invoice_number ?? 'factura'}_cp_genesis.csv`
);
}}>
<Route class="mr-2 h-4 w-4" />
Carta Porte Genesis
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
<div class="h-6 w-px bg-border"></div>
</div>
{/if}
@@ -2166,6 +2549,53 @@
<TransferenciaElectronicaModal bind:open={isTransferenciaModalOpen} invoice={selectedInvoice} />
{/if}
<Dialog.Root bind:open={exportItemsDialogOpen}>
<Dialog.Content class="sm:max-w-[320px]">
<Dialog.Header>
<Dialog.Title>Exportar a</Dialog.Title>
<Dialog.Description>
Selecciona el formato para
<span class="font-medium">{exportItemsInvoice?.invoice_number ?? 'la factura'}</span>.
</Dialog.Description>
</Dialog.Header>
<div class="py-4">
<RadioGroup.Root
value={exportItemsFormat}
onValueChange={(v) => (exportItemsFormat = v as 'csv' | 'xlsx' | 'txt')}
class="flex flex-col gap-3"
>
<div class="flex cursor-pointer items-center space-x-2">
<RadioGroup.Item value="csv" id="fmt-csv" />
<Label for="fmt-csv" class="cursor-pointer">CSV</Label>
</div>
<div class="flex cursor-pointer items-center space-x-2">
<RadioGroup.Item value="xlsx" id="fmt-xlsx" />
<Label for="fmt-xlsx" class="cursor-pointer">Excel (.xlsx)</Label>
</div>
<div class="flex cursor-pointer items-center space-x-2">
<RadioGroup.Item value="txt" id="fmt-txt" />
<Label for="fmt-txt" class="cursor-pointer">Texto plano (.txt)</Label>
</div>
</RadioGroup.Root>
</div>
<Dialog.Footer>
<Button
variant="outline"
onclick={() => {
exportItemsDialogOpen = false;
exportItemsInvoice = null;
}}
>
Cancelar
</Button>
<Button onclick={handleExportItemsConfirm}>
<Download class="mr-2 h-4 w-4" />
Descargar
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
{#if showDetailsDialog && selectedInvoice}
<DetailsDialog invoice={selectedInvoice} onClose={() => (showDetailsDialog = false)} />
{/if}