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

@@ -1,6 +1,6 @@
from enum import Enum
from typing import Optional, List
from sqlalchemy import BigInteger, Date, ForeignKey, Integer, Numeric, String, Text, TIMESTAMP
from sqlalchemy import BigInteger, Boolean, Date, ForeignKey, Integer, Numeric, String, Text, TIMESTAMP
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.database import Base
from datetime import datetime
@@ -11,21 +11,35 @@ class OperationType(str, Enum):
IMP = "imp" # Importación
EXP = "exp" # Exportación
class TransportType(str, Enum):
NONE = "none" # Ninguno
TRANSPORT = "transport" # Transporte
BOX = "box" # Caja
PLATES = "licence plates" # Placas
TRUCK = "truck" # Camión
VESSEL = "vessel" # Buque
BARGE = "rail barge" # Ferrobarcaza
CONTAINER = "container" # Contenedor
AIRPLANE = "airplane" # Avión
GONDOLA = "gondola" # Góndola
FLATBED = "flatbed" # Plataforma
# --- 1. Invoice Header (invoice_header) ---
class InvoiceHeader(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "invoice_header"
__tableargs__ = {'schema': 'a76'}
__table_args__ = (
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(
BigInteger, primary_key=True, autoincrement=True)
# Identifiers
operation_type: Mapped[OperationType] = mapped_column(
String(3)) # TIPOMOVIMIENTO / Clasifica IMP/EXP
invoice_type: Mapped[Optional[str]] = mapped_column(
ForeignKey("public.invoice_types.key")) # TIPOFACTURA
String(3)) # TIPOMOVIMIENTO / Clasifica imp/exp
invoice_type: Mapped[Optional[str]] = mapped_column(ForeignKey(
"public.invoice_types.key")) # TIPOFACTURA (invoice_types)
invoice_number: Mapped[Optional[str]] = mapped_column(
String(20)) # FACTURAIMPO/FACTURAEXPO/FACTURAREMISION
project_number: Mapped[Optional[str]] = mapped_column(
@@ -42,20 +56,22 @@ class InvoiceHeader(Base, TenantScopedMixin, TimestampMixin):
TIMESTAMP(timezone=False), default=datetime.now) # FECHACAPTURA + HORAACTUAL
# Status & Control
status: Mapped[Optional[str]] = mapped_column(String(5)) # ESTATUS
traffic_light_status: Mapped[Optional[str]] = mapped_column(
String(50)) # SEMAFORO / SEMAFOROEXPO/IMPO
process_log: Mapped[Optional[str]] = mapped_column(
String(300)) # COMOFUEPROCESADA **<-- AÑADIDO**
# OBSERVACIONE + OBSERVACIONI + COMENTARIOSESTATUS
comments: Mapped[Optional[str]] = mapped_column(Text)
is_updated: Mapped[Optional[str]] = mapped_column(Boolean) # ESTATUS
updated_date: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP(timezone=False)) # FECHAACTUALIZACION
who_updated: Mapped[Optional[str]] = mapped_column(String(20)) # QUIENA ACTUALIZO
traffic_light_status: Mapped[Optional[str]] = mapped_column(String(50)) # SEMAFORO / SEMAFOROEXPO/IMPO
process_log: Mapped[Optional[str]] = mapped_column(String(300)) # COMOFUEPROCESADA
# Comments
observation_es: Mapped[Optional[str]] = mapped_column(Text) #OBSERVACIONE
observation_en: Mapped[Optional[str]] = mapped_column(Text) #OBSERVACIONI
comments_status: Mapped[Optional[str]] = mapped_column(Text) #COMENTARIOSESTATUS
# Digital Archive Links
cfdi_uuid: Mapped[Optional[str]] = mapped_column(String(100)) # CFDIUUID
path_pdf: Mapped[Optional[str]] = mapped_column(
String(500)) # CFDIPATHPDF **<-- AÑADIDO**
path_xml: Mapped[Optional[str]] = mapped_column(
String(500)) # CFDIPATHXML **<-- AÑADIDO**
path_pdf: Mapped[Optional[str]] = mapped_column(String(500)) # CFDIPATHPDF
path_xml: Mapped[Optional[str]] = mapped_column(String(500)) # CFDIPATHXML
# Relationships (Para navegacion ORM)
compliance_mx: Mapped["InvoiceComplianceMx"] = relationship(
@@ -73,10 +89,12 @@ class InvoiceHeader(Base, TenantScopedMixin, TimestampMixin):
# --- 2. Compliance MX (invoice_compliance_mx) ---
class InvoiceComplianceMx(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "invoice_compliance_mx"
__tableargs__ = {'schema': 'a76'}
__table_args__ = (
{"schema": "a76"},
)
invoice_id: Mapped[int] = mapped_column(
ForeignKey("invoice_header.id"), primary_key=True)
ForeignKey("a76.invoice_header.id"), primary_key=True)
# Core Customs Data
pedimento: Mapped[Optional[str]] = mapped_column(
@@ -84,23 +102,29 @@ class InvoiceComplianceMx(Base, TenantScopedMixin, TimestampMixin):
pedimento_code: Mapped[Optional[str]] = mapped_column(
String(5)) # PEDIMENTOR1, K1
remesa: Mapped[Optional[int]] = mapped_column(Integer)
aduana: Mapped[Optional[str]] = mapped_column(String(5)) # ADUANA_CRUCE
customs_agent: Mapped[Optional[str]] = mapped_column(
String(10)) # AADUANAL
aduana: Mapped[Optional[str]] = mapped_column(ForeignKey("public.customs_sections.customs_code")) # ADUANA_CRUCE
# Clients & Providers
provider_header: Mapped[Optional[str]] = mapped_column(String(20)) # PROVEEDOREXPORTADOR
provider_id: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # PROVEEDOR
sold_to_header: Mapped[Optional[str]] = mapped_column(String(20)) # VENDIDOCONSIGNADO
sold_to_id: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # VENDIDOA
shipped_to_header: Mapped[Optional[str]] = mapped_column(String(20)) # ENVIADOTRANSFERIDO
shipped_to_id: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # ENVIADOA
shipped_by_header: Mapped[Optional[str]] = mapped_column(String(20)) # ENVIADOPORVENDIDOPOR
shipped_by_id: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # VENDIDOPOR
customs_broker_id: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.customs_brokers.id")) # AADUANAL
# Flags & Specific Regimes
is_mixed: Mapped[Optional[str]] = mapped_column(
String(2)) # ESMIXTO **<-- AÑADIDO**
waste_type: Mapped[Optional[str]] = mapped_column(
String(1)) # TIPODESPERDICIO **<-- AÑADIDO**
is_mixed: Mapped[Optional[bool]] = mapped_column(Boolean) # ESMIXTO
waste_type: Mapped[Optional[str]] = mapped_column(String(1)) # TIPODESPERDICIO
appendix_17: Mapped[Optional[int]] = mapped_column(Integer) # APENDICE17
# VUCEM / Digital
edocument: Mapped[Optional[str]] = mapped_column(String(50)) # EDOCUMENT
electronic_signature: Mapped[Optional[str]] = mapped_column(
String(999)) # FIRMAELECTRONICA
sem_id: Mapped[Optional[int]] = mapped_column(
Integer) # SEM (de SFacEntradaSM/SFacSalidaSM)
electronic_signature: Mapped[Optional[str]] = mapped_column(String(999)) # FIRMAELECTRONICA
sem_id: Mapped[Optional[int]] = mapped_column(Integer) # SEM (de SFacEntradaSM/SFacSalidaSM)
# Relationship
header: Mapped["InvoiceHeader"] = relationship(
@@ -110,40 +134,33 @@ class InvoiceComplianceMx(Base, TenantScopedMixin, TimestampMixin):
# --- 3. Financials (invoice_financials) ---
class InvoiceFinancials(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "invoice_financials"
__tableargs__ = {'schema': 'a76'}
__table_args__ = (
{"schema": "a76"},
)
invoice_id: Mapped[int] = mapped_column(
ForeignKey("invoice_header.id"), primary_key=True)
id: Mapped[int] = mapped_column(
BigInteger, primary_key=True, autoincrement=True)
invoice_id: Mapped[int] = mapped_column(ForeignKey("a76.invoice_header.id"))
currency: Mapped[Optional[str]] = mapped_column(String(3)) # CLAVEMONEDA
exchange_rate: Mapped[Optional[float]] = mapped_column(
Numeric(13, 6)) # TIPOCAMBIO
currency_type: Mapped[Optional[str]] = mapped_column(ForeignKey("public.currency_types.code")) # TIPOCLAVEMONEDA
exchange_rate: Mapped[Optional[float]] = mapped_column(Numeric(13, 6)) # TIPOCAMBIO
# Merchandise Values
value_mn: Mapped[Optional[float]] = mapped_column(
Numeric(23, 8), default=0) # VALORIMPOMN/EXPOMN
value_me: Mapped[Optional[float]] = mapped_column(
Numeric(23, 8), default=0)
customs_value_mn: Mapped[Optional[float]] = mapped_column(
Numeric(23, 8), default=0) # VALORADUANASMN **<-- AÑADIDO**
value_mn: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # VALORIMPOMN/EXPOMN
value_me: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0)
customs_value_mn: Mapped[Optional[float]] = mapped_column( Numeric(23, 8), default=0) # VALORADUANASMN
# Costs & Taxes
freight: Mapped[Optional[float]] = mapped_column(
Numeric(19, 8), default=0) # FLETE
insurance: Mapped[Optional[float]] = mapped_column(
Numeric(19, 8), default=0) # VALSEGUROS/SEGUROS
iva_mn: Mapped[Optional[float]] = mapped_column(
Numeric(23, 8), default=0) # IVAEXPOMN / VALORIVAMN
iva_factor: Mapped[Optional[float]] = mapped_column(
Numeric(17, 4)) # FACTORIVA
freight: Mapped[Optional[float]] = mapped_column(Numeric(19, 8), default=0) # FLETE
insurance: Mapped[Optional[float]] = mapped_column(Numeric(19, 8), default=0) # VALSEGUROS/SEGUROS
iva_mn: Mapped[Optional[float]] = mapped_column(Numeric(23, 8), default=0) # IVAEXPOMN / VALORIVAMN
iva_factor: Mapped[Optional[float]] = mapped_column(Numeric(17, 4)) # FACTORIVA
# Weights & Quantities
total_quantity: Mapped[Optional[float]] = mapped_column(
Numeric(19, 8)) # CANTEXPO / CANTIMPO
gross_weight: Mapped[Optional[float]] = mapped_column(
Numeric(19, 8)) # PESOBRUTO **<-- AÑADIDO**
net_weight: Mapped[Optional[float]] = mapped_column(
Numeric(19, 8)) # PESONETO **<-- AÑADIDO**
total_quantity: Mapped[Optional[float]] = mapped_column(Numeric(19, 8)) # CANTEXPO / CANTIMPO
gross_weight: Mapped[Optional[float]] = mapped_column(Numeric(19, 8)) # PESOBRUTO
net_weight: Mapped[Optional[float]] = mapped_column(Numeric(19, 8)) # PESONETO
bundle_count: Mapped[Optional[int]] = mapped_column(Integer) # CANTBULTOS
# Relationship
@@ -153,17 +170,21 @@ class InvoiceFinancials(Base, TenantScopedMixin, TimestampMixin):
# --- 4. Logistics (invoice_logistics) ---
class InvoiceLogistics(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "invoice_logistics"
__tableargs__ = {'schema': 'a76'}
__table_args__ = (
{"schema": "a76"},
)
logistics_id: Mapped[int] = mapped_column(
id: Mapped[int] = mapped_column(
BigInteger, primary_key=True, autoincrement=True)
invoice_id: Mapped[int] = mapped_column(ForeignKey("invoice_header.id"))
invoice_id: Mapped[int] = mapped_column(ForeignKey("a76.invoice_header.id"))
# Carrier Info
carrier_id: Mapped[Optional[str]] = mapped_column(
String(10)) # TRANSPORTISTA
transport_type: Mapped[TransportType] = mapped_column(
String(15), default="none") # TRANSPORTE
transport_mode: Mapped[Optional[str]] = mapped_column(
String(15)) # TRANSPORTE
String(15)) # MODTRANS
driver_name: Mapped[Optional[str]] = mapped_column(String(80)) # CONDUCTOR
is_rail: Mapped[Optional[str]] = mapped_column(String(2)) # ESFERROCARRIL
rail_id: Mapped[Optional[str]] = mapped_column(
@@ -189,11 +210,13 @@ class InvoiceLogistics(Base, TenantScopedMixin, TimestampMixin):
# --- 5. Sales Order Details (invoice_sales_details) ---
class InvoiceSalesDetails(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "invoice_sales_details"
__tableargs__ = {'schema': 'a76'}
__table_args__ = (
{"schema": "a76"},
)
detail_id: Mapped[int] = mapped_column(
id: Mapped[int] = mapped_column(
BigInteger, primary_key=True, autoincrement=True)
invoice_id: Mapped[int] = mapped_column(ForeignKey("invoice_header.id"))
invoice_id: Mapped[int] = mapped_column(ForeignKey("a76.invoice_header.id"))
line_number: Mapped[int] = mapped_column(Integer) # LINEA
sales_order: Mapped[Optional[str]] = mapped_column(
@@ -214,11 +237,13 @@ class InvoiceSalesDetails(Base, TenantScopedMixin, TimestampMixin):
# --- 6. Collections (invoice_collections) ---
class InvoiceCollections(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "invoice_collections"
__tableargs__ = {'schema': 'a76'}
__table_args__ = (
{"schema": "a76"},
)
collection_id: Mapped[int] = mapped_column(
id: Mapped[int] = mapped_column(
BigInteger, primary_key=True, autoincrement=True)
invoice_id: Mapped[int] = mapped_column(ForeignKey("invoice_header.id"))
invoice_id: Mapped[int] = mapped_column(ForeignKey("a76.invoice_header.id"))
concept: Mapped[Optional[str]] = mapped_column(String(100)) # CONCEPTO
is_collected: Mapped[Optional[int]] = mapped_column(Integer) # COBRADO

View File

@@ -5,24 +5,35 @@ from pydantic import BaseModel, Field
from .models import OperationType
# --- Base Schemas ---
class InvoiceHeaderBase(BaseModel):
"""Base fields for Invoice Header"""
operation_type: Optional[OperationType] = Field(None, max_length=20, description="Operation type: imp/exp")
invoice_type: Optional[str] = Field(None, max_length=5, description="Invoice type key")
invoice_number: Optional[str] = Field(None, max_length=20, description="Invoice number")
project_number: Optional[str] = Field(None, max_length=14, description="Project number")
purchase_order: Optional[str] = Field(None, max_length=50, description="Purchase order")
related_doc_id: Optional[int] = Field(None, description="Related document ID for rectifications")
operation_type: Optional[OperationType] = Field(
None, max_length=20, description="Operation type: imp/exp")
invoice_type: Optional[str] = Field(
None, max_length=5, description="Invoice type key")
invoice_number: Optional[str] = Field(
None, max_length=20, description="Invoice number")
project_number: Optional[str] = Field(
None, max_length=14, description="Project number")
purchase_order: Optional[str] = Field(
None, max_length=50, description="Purchase order")
related_doc_id: Optional[int] = Field(
None, description="Related document ID for rectifications")
invoice_date: Optional[date] = Field(None, description="Invoice date")
status: Optional[str] = Field(None, max_length=5, description="Status")
traffic_light_status: Optional[str] = Field(None, max_length=50, description="Traffic light status (SEMAFORO)")
process_log: Optional[str] = Field(None, max_length=300, description="Processing log")
comments: Optional[str] = Field(None, description="Comments and observations")
cfdi_uuid: Optional[str] = Field(None, max_length=100, description="CFDI UUID")
path_pdf: Optional[str] = Field(None, max_length=500, description="Path to PDF file")
path_xml: Optional[str] = Field(None, max_length=500, description="Path to XML file")
is_updated: Optional[bool] = Field(None, description="Status")
traffic_light_status: Optional[str] = Field(
None, max_length=50, description="Traffic light status (SEMAFORO)")
process_log: Optional[str] = Field(
None, max_length=300, description="Processing log")
comments_status: Optional[str] = Field(
None, description="Comments and observations")
cfdi_uuid: Optional[str] = Field(
None, max_length=100, description="CFDI UUID")
path_pdf: Optional[str] = Field(
None, max_length=500, description="Path to PDF file")
path_xml: Optional[str] = Field(
None, max_length=500, description="Path to XML file")
class InvoiceComplianceMxBase(BaseModel):
@@ -36,8 +47,8 @@ class InvoiceComplianceMxBase(BaseModel):
None, max_length=5, description="Customs office")
customs_agent: Optional[str] = Field(
None, max_length=10, description="Customs agent")
is_mixed: Optional[str] = Field(
None, max_length=2, description="Is mixed operation")
is_mixed: Optional[bool] = Field(
None, description="Is mixed operation")
waste_type: Optional[str] = Field(
None, max_length=1, description="Waste type")
appendix_17: Optional[int] = Field(None, description="Appendix 17")

View File

@@ -72,6 +72,18 @@
"customs_sections": "Customs Sections",
"anexo_22_app_31": "Anexo 22 App 3"
},
"import_invoices":{
"title": "Import Invoices",
"temporary": "Temporary",
"definitive": "Definitive",
"mexican_purchases": "Mexican Purchases",
"regime_change": "Regime Change"
},
"export_invoices": {
"title": "Export Invoices",
"exportation": "Exportation",
"repair": "Repair"
},
"clients_and_providers": "Clients and Providers",
"customs_brokers": "Customs Brokers",
"nav_user": {

View File

@@ -72,6 +72,18 @@
"customs_sections": "Secciones Aduaneras",
"anexo_22_app_31": "Anexo 22 App 3"
},
"import_invoices":{
"title": "Facturas de importación",
"temporary": "Temporal",
"definitive": "Definitiva",
"mexican_purchases": "Compras mexicanas",
"regime_change": "Cambio de régimen"
},
"export_invoices": {
"title": "Facturas de exportación",
"exportation": "Exportación",
"repair": "Reparación"
},
"clients_and_providers": "Clientes y Proveedores",
"customs_brokers": "Agentes Aduanales",
"nav_user": {

View File

@@ -0,0 +1,318 @@
/**
* API Client para Facturas (Invoices)
* Gestiona las operaciones CRUD para facturas y sus relaciones
*/
import { api } from '$lib/api';
export type OperationType = 'imp' | 'exp';
export type TransportType = 'none' | 'transport' | 'box' | 'licence plates' | 'truck' | 'vessel' | 'rail barge' | 'container' | 'airplane' | 'gondola' | 'flatbed';
// --- Interfaces ---
export interface InvoiceComplianceMx {
invoice_id?: number;
pedimento?: string | null;
pedimento_code?: string | null;
remesa?: number | null;
aduana?: string | null;
provider_header?: string | null;
provider_id?: string | null;
sold_to_header?: string | null;
sold_to_id?: string | null;
shipped_to_header?: string | null;
shipped_to_id?: string | null;
shipped_by_header?: string | null;
shipped_by_id?: string | null;
customs_broker_id?: string | null;
is_mixed?: boolean | null;
waste_type?: string | null;
appendix_17?: number | null;
edocument?: string | null;
electronic_signature?: string | null;
sem_id?: number | null;
}
export interface InvoiceFinancials {
id?: number;
invoice_id?: number;
currency?: string | null;
currency_type?: string | null;
exchange_rate?: number | null;
value_mn?: number | null;
value_me?: number | null;
customs_value_mn?: number | null;
freight?: number | null;
insurance?: number | null;
iva_mn?: number | null;
iva_factor?: number | null;
total_quantity?: number | null;
gross_weight?: number | null;
net_weight?: number | null;
bundle_count?: number | null;
}
export interface InvoiceLogistics {
id?: number;
invoice_id?: number;
carrier_id?: string | null;
transport_type?: TransportType | null;
transport_mode?: string | null;
driver_name?: string | null;
is_rail?: string | null;
rail_id?: string | null;
vehicle_num?: string | null;
license_plate?: string | null;
seal_number?: string | null;
guide_number?: string | null;
entry_exit_date?: string | null;
}
export interface InvoiceSalesDetails {
id?: number;
invoice_id?: number;
line_number: number;
sales_order?: string | null;
colors_description?: string | null;
square_color_code?: string | null;
line_bundles?: number | null;
}
export interface InvoiceCollections {
id?: number;
invoice_id?: number;
concept?: string | null;
is_collected?: number | null;
collection_date?: string | null;
amount?: number | null;
collector_user?: string | null;
}
export interface Invoice {
id: number;
tenant_id: number;
company_id: number;
operation_type?: OperationType | null;
invoice_type?: string | null;
invoice_number?: string | null;
project_number?: string | null;
purchase_order?: string | null;
related_doc_id?: number | null;
invoice_date?: string | null;
capture_date: string;
is_updated?: boolean | null;
updated_date?: string | null;
who_updated?: string | null;
traffic_light_status?: string | null;
process_log?: string | null;
observation_es?: string | null;
observation_en?: string | null;
comments_status?: string | null;
cfdi_uuid?: string | null;
path_pdf?: string | null;
path_xml?: string | null;
compliance_mx?: InvoiceComplianceMx | null;
financials?: InvoiceFinancials | null;
logistics?: InvoiceLogistics[];
details?: InvoiceSalesDetails[];
collections?: InvoiceCollections[];
}
export interface InvoiceListResponse {
items: Invoice[];
total: number;
page: number;
page_size: number;
}
export interface CreateInvoiceData {
operation_type?: OperationType | null;
invoice_type?: string | null;
invoice_number?: string | null;
project_number?: string | null;
purchase_order?: string | null;
related_doc_id?: number | null;
invoice_date?: string | null;
traffic_light_status?: string | null;
process_log?: string | null;
observation_es?: string | null;
observation_en?: string | null;
comments_status?: string | null;
cfdi_uuid?: string | null;
path_pdf?: string | null;
path_xml?: string | null;
compliance_mx?: Omit<InvoiceComplianceMx, 'invoice_id'> | null;
financials?: Omit<InvoiceFinancials, 'id' | 'invoice_id'> | null;
logistics?: Omit<InvoiceLogistics, 'id' | 'invoice_id'>[] | null;
details?: Omit<InvoiceSalesDetails, 'id' | 'invoice_id'>[] | null;
collections?: Omit<InvoiceCollections, 'id' | 'invoice_id'>[] | null;
}
export interface UpdateInvoiceData {
operation_type?: OperationType | null;
invoice_type?: string | null;
invoice_number?: string | null;
project_number?: string | null;
purchase_order?: string | null;
related_doc_id?: number | null;
invoice_date?: string | null;
traffic_light_status?: string | null;
process_log?: string | null;
observation_es?: string | null;
observation_en?: string | null;
comments_status?: string | null;
cfdi_uuid?: string | null;
path_pdf?: string | null;
path_xml?: string | null;
compliance_mx?: Partial<InvoiceComplianceMx> | null;
financials?: Partial<InvoiceFinancials> | null;
logistics?: Partial<InvoiceLogistics>[] | null;
details?: Partial<InvoiceSalesDetails>[] | null;
collections?: Partial<InvoiceCollections>[] | null;
}
/**
* API para Facturas
*/
export const invoicesApi = {
/**
* Lista todas las facturas con paginación
*/
list: (companyId: number, page = 1, pageSize = 50, filters?: Record<string, any>) => {
const params = new URLSearchParams({
company_id: companyId.toString(),
page: page.toString(),
page_size: pageSize.toString()
});
// Agregar filtros si existen
if (filters) {
Object.entries(filters).forEach(([key, value]) => {
if (value !== null && value !== undefined && value !== '') {
params.append(key, String(value));
}
});
}
return api.get<InvoiceListResponse>(`/v1/a76/invoices?${params.toString()}`);
},
/**
* Obtiene una factura por ID
*/
get: (invoiceId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.get<Invoice>(`/v1/a76/invoices/${invoiceId}?${params.toString()}`);
},
/**
* Crea una nueva factura
*/
create: (companyId: number, data: CreateInvoiceData) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.post<Invoice>(`/v1/a76/invoices?${params.toString()}`, data);
},
/**
* Actualiza una factura existente
*/
update: (invoiceId: number, companyId: number, data: UpdateInvoiceData) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.put<Invoice>(`/v1/a76/invoices/${invoiceId}?${params.toString()}`, data);
},
/**
* Elimina una factura
*/
delete: (invoiceId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.delete(`/v1/a76/invoices/${invoiceId}?${params.toString()}`);
},
// --- Nested Resources ---
/**
* Logística de factura
*/
logistics: {
list: (invoiceId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.get<InvoiceLogistics[]>(`/v1/a76/invoices/${invoiceId}/logistics?${params.toString()}`);
},
create: (invoiceId: number, companyId: number, data: Omit<InvoiceLogistics, 'id' | 'invoice_id'>) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.post<InvoiceLogistics>(`/v1/a76/invoices/${invoiceId}/logistics?${params.toString()}`, data);
},
delete: (invoiceId: number, logisticsId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.delete(`/v1/a76/invoices/${invoiceId}/logistics/${logisticsId}?${params.toString()}`);
}
},
/**
* Detalles de venta de factura
*/
details: {
list: (invoiceId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.get<InvoiceSalesDetails[]>(`/v1/a76/invoices/${invoiceId}/details?${params.toString()}`);
},
create: (invoiceId: number, companyId: number, data: Omit<InvoiceSalesDetails, 'id' | 'invoice_id'>) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.post<InvoiceSalesDetails>(`/v1/a76/invoices/${invoiceId}/details?${params.toString()}`, data);
},
delete: (invoiceId: number, detailId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.delete(`/v1/a76/invoices/${invoiceId}/details/${detailId}?${params.toString()}`);
}
},
/**
* Cobranzas de factura
*/
collections: {
list: (invoiceId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.get<InvoiceCollections[]>(`/v1/a76/invoices/${invoiceId}/collections?${params.toString()}`);
},
create: (invoiceId: number, companyId: number, data: Omit<InvoiceCollections, 'id' | 'invoice_id'>) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.post<InvoiceCollections>(`/v1/a76/invoices/${invoiceId}/collections?${params.toString()}`, data);
},
delete: (invoiceId: number, collectionId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.delete(`/v1/a76/invoices/${invoiceId}/collections/${collectionId}?${params.toString()}`);
}
}
};

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",

View File

@@ -0,0 +1,104 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import {
getAuthTokens,
authenticatedFetch
} from '$lib/server/api';
export const load: PageServerLoad = async ({ fetch, cookies, url, parent }) => {
// Esperar a que el layout padre valide/refresque el token
const parentData = await parent();
// Verificar autenticación
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) {
throw redirect(302, '/login');
}
try {
// Obtener company_id de múltiples fuentes (en orden de prioridad):
// 1. URL query param (permite cambiar vía navegación)
// 2. Cookie active_company_id (setted por el team-switcher)
// 3. Primera compañía del usuario (fallback)
const companyIdParam = url.searchParams.get('company_id');
const cookieCompanyId = cookies.get('active_company_id');
const companyId = companyIdParam
? parseInt(companyIdParam)
: cookieCompanyId
? parseInt(cookieCompanyId)
: parentData.companies?.[0]?.id;
// Si aún no hay companyId, mostrar error
if (!companyId) {
return {
items: [],
total: 0,
page: 1,
page_size: 50,
error: 'No se encontró una compañía seleccionada',
companies: parentData.companies || []
};
}
// Obtener filtro de tipo de operación
const operationType = url.searchParams.get('operation_type');
// Construir parámetros de consulta
const params = new URLSearchParams({
company_id: companyId.toString(),
page: '1',
page_size: '50'
});
// Agregar filtro de tipo si existe y no es 'all'
if (operationType && operationType !== 'all') {
params.append('operation_type', operationType);
}
// Usar authenticatedFetch para manejar automáticamente el refresh de tokens
const response = await authenticatedFetch(
`v1/a76/invoices?${params.toString()}`,
{},
cookies,
fetch,
'/login'
);
if (!response.ok) {
return {
items: [],
total: 0,
page: 1,
page_size: 50,
error: 'Error al cargar facturas',
companies: parentData.companies || [],
currentCompanyId: companyId,
operationType: operationType || 'all'
};
}
const data = await response.json();
return {
items: data.items || [],
total: data.total || 0,
page: data.page || 1,
page_size: data.page_size || 50,
companies: parentData.companies || [],
currentCompanyId: companyId,
operationType: operationType || 'all'
};
} catch (error) {
console.error('Error loading invoices:', error);
return {
items: [],
total: 0,
page: 1,
page_size: 50,
error: 'Error al cargar facturas',
companies: parentData.companies || []
};
}
};

View File

@@ -0,0 +1,283 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invoicesApi, type Invoice } from '$lib/api/dashboard/a76/invoices';
import DataTable from '$lib/components/dashboard/invoices/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/invoices/columns.js';
import CreateEditDialog from '$lib/components/dashboard/invoices/create-edit-dialog.svelte';
import DetailsDialog from '$lib/components/dashboard/invoices/details-dialog.svelte';
import DeleteDialog from '$lib/components/dashboard/invoices/delete-dialog.svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import * as Select from '$lib/components/ui/select';
import { Plus, RefreshCw } from 'lucide-svelte';
import { goto, invalidate } from '$app/navigation';
import { browser } from '$app/environment';
import { companyStore } from '$lib/stores/company.svelte';
// Recibir data del servidor
interface PageData {
items: Invoice[];
total: number;
page: number;
page_size: number;
error?: string;
companies: any[];
currentCompanyId?: number;
operationType?: string;
}
let { data }: { data: PageData } = $props();
// Estado para los diálogos
let showCreateDialog = $state(false);
let showDetailsDialog = $state(false);
let showDeleteDialog = $state(false);
let selectedInvoice = $state<Invoice | null>(null);
// Estado para el filtro de tipo (inicializado desde data del servidor)
let selectedType = $state<string>(data.operationType || 'all');
// Actualizar URL cuando cambia el filtro
function handleTypeChange(value: string) {
selectedType = value;
const url = new URL(window.location.href);
if (value === 'all') {
url.searchParams.delete('operation_type');
} else {
url.searchParams.set('operation_type', value);
}
goto(url.toString(), { keepFocus: true, noScroll: true });
}
// Sincronizar token de cookies a localStorage al montar el componente
onMount(() => {
if (browser) {
const getCookie = (name: string): string | null => {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
return null;
};
const cookieToken = getCookie('access_token');
const localToken = localStorage.getItem('access_token');
if (cookieToken && cookieToken !== localToken) {
localStorage.setItem('access_token', cookieToken);
}
const cookieRefreshToken = getCookie('refresh_token');
const localRefreshToken = localStorage.getItem('refresh_token');
if (cookieRefreshToken && cookieRefreshToken !== localRefreshToken) {
localStorage.setItem('refresh_token', cookieRefreshToken);
}
// Escuchar cambios de compañía
const handleCompanyChange = (event: CustomEvent) => {
invalidate('app:invoices');
};
// Escuchar eventos de facturas
const handleInvoiceView = (event: CustomEvent<Invoice>) => {
handleView(event.detail);
};
const handleInvoiceEdit = (event: CustomEvent<Invoice>) => {
handleEdit(event.detail);
};
const handleInvoiceDelete = (event: CustomEvent<Invoice>) => {
handleDelete(event.detail);
};
window.addEventListener('companyChanged', handleCompanyChange as EventListener);
window.addEventListener('invoiceView', handleInvoiceView as EventListener);
window.addEventListener('invoiceEdit', handleInvoiceEdit as EventListener);
window.addEventListener('invoiceDelete', handleInvoiceDelete as EventListener);
return () => {
window.removeEventListener('companyChanged', handleCompanyChange as EventListener);
window.removeEventListener('invoiceView', handleInvoiceView as EventListener);
window.removeEventListener('invoiceEdit', handleInvoiceEdit as EventListener);
window.removeEventListener('invoiceDelete', handleInvoiceDelete as EventListener);
};
}
});
// Estado para infinite scroll - inicializar con data del servidor
let allItems = $state<Invoice[]>(data.items || []);
let currentPage = $state(data.page || 1);
let pageSize = $state(data.page_size || 50);
let totalItems = $state(data.total || 0);
let loading = $state(false);
let hasMore = $derived(allItems.length < totalItems);
let error = $state<string | null>(data.error || null);
// Actualizar datos cuando cambia data del servidor
$effect(() => {
allItems = data.items || [];
currentPage = data.page || 1;
totalItems = data.total || 0;
error = data.error || null;
});
async function loadMore() {
if (loading || !hasMore || !companyStore.activeCompany) return;
loading = true;
error = null;
try {
const response = await invoicesApi.list(
companyStore.activeCompany.id,
currentPage + 1,
pageSize,
selectedType !== 'all' ? { operation_type: selectedType } : undefined
);
if (response.error) {
console.error('Error en loadMore:', response.error, 'Status:', response.status);
if (response.status === 401 || response.status === 403) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
error = response.error;
}
return;
}
if (response.data?.items) {
allItems = [...allItems, ...response.data.items];
currentPage++;
totalItems = response.data.total;
}
} catch (e) {
error = 'Error cargando más datos';
console.error('Error loading more:', e);
} finally {
loading = false;
}
}
async function reloadData() {
// Invalidar datos para que el servidor recargue
await invalidate('app:invoices');
}
function handleCreateClick() {
selectedInvoice = null;
showCreateDialog = true;
}
function handleView(invoice: Invoice) {
selectedInvoice = invoice;
showDetailsDialog = true;
}
function handleEdit(invoice: Invoice) {
selectedInvoice = invoice;
showCreateDialog = true;
}
function handleDelete(invoice: Invoice) {
selectedInvoice = invoice;
showDeleteDialog = true;
}
function handleSuccess() {
reloadData();
}
// Crear columnas
const columns = createColumns();
</script>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Facturas</h1>
<p class="text-muted-foreground">
Gestiona las facturas de importación y exportación
</p>
</div>
<Button onclick={handleCreateClick}>
<Plus class="mr-2" size={16} />
Nueva Factura
</Button>
</div>
<!-- Error Message -->
{#if error}
<Card.Root class="border-destructive">
<Card.Header>
<Card.Title class="text-destructive">Error</Card.Title>
<Card.Description>{error}</Card.Description>
</Card.Header>
</Card.Root>
{/if}
<!-- Data Table -->
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Listado de Facturas</Card.Title>
<Card.Description>
Mostrando {allItems.length} de {totalItems} registros
{#if companyStore.activeCompany}
- Compañía: {companyStore.activeCompany.name}
{/if}
</Card.Description>
</div>
<div class="flex items-center gap-2">
<Select.Root type="single" value={selectedType} onValueChange={handleTypeChange}>
<Select.Trigger class="w-[180px]">
{selectedType === 'all' ? 'Todas' : selectedType === 'imp' ? 'Importación' : 'Exportación'}
</Select.Trigger>
<Select.Content>
<Select.Item value="all">Todas</Select.Item>
<Select.Item value="imp">Importación</Select.Item>
<Select.Item value="exp">Exportación</Select.Item>
</Select.Content>
</Select.Root>
<Button variant="outline" onclick={reloadData}>
<RefreshCw class="mr-2" size={16} />
Actualizar
</Button>
</div>
</div>
</Card.Header>
<Card.Content>
<DataTable
data={allItems}
{columns}
{loading}
{hasMore}
{loadMore}
/>
</Card.Content>
</Card.Root>
</div>
<!-- Diálogos -->
<CreateEditDialog
bind:open={showCreateDialog}
bind:item={selectedInvoice}
onSuccess={handleSuccess}
/>
<DetailsDialog
bind:open={showDetailsDialog}
invoice={selectedInvoice}
/>
<DeleteDialog
bind:open={showDeleteDialog}
item={selectedInvoice}
onSuccess={handleSuccess}
/>