Merge pull request 'Modulo/Creacion de endpoint para las tablas de facturas, asi como sus creaciones' (#25) from modulo/modulo_URL_FACTURAS_IMPORTACION_EXPORTACION into feature/facturas
Reviewed-on: ADUANASOFT/anexo76#25
This commit is contained in:
@@ -150,6 +150,7 @@ def get_invoice_details(
|
||||
status_code=201,
|
||||
summary="Add sales detail to an invoice",
|
||||
)
|
||||
|
||||
def create_invoice_detail(
|
||||
invoice_id: int = Path(..., description="Invoice ID"),
|
||||
detail_data: schemas.InvoiceSalesDetailsCreate = ...,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Ellipsis, Eye, Pencil, Trash2 } from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
interface Props {
|
||||
invoice: Invoice;
|
||||
@@ -14,13 +15,14 @@
|
||||
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 }));
|
||||
}
|
||||
|
||||
function dispatchEdit() {
|
||||
window.dispatchEvent(new CustomEvent('invoiceEdit', { detail: invoice }));
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
@@ -35,18 +37,21 @@
|
||||
<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>
|
||||
</DropdownMenu.Root>
|
||||
@@ -323,19 +323,23 @@ export function getSidebarData(): SidebarData {
|
||||
items: [
|
||||
{
|
||||
title: m["sidebar.import_invoices.temporary"](),
|
||||
url: "/dashboard/invoices?operation_type=imp&invoice_type=TEM",
|
||||
//url: "/dashboard/invoices?operation_type=imp&invoice_type=TEM"
|
||||
url: "/dashboard/invoices/importacion/temporal",
|
||||
},
|
||||
{
|
||||
title: m["sidebar.import_invoices.definitive"](),
|
||||
url: "/dashboard/invoices?operation_type=imp&invoice_type=DEF",
|
||||
//url: "/dashboard/invoices?operation_type=imp&invoice_type=DEF",
|
||||
url: "/dashboard/invoices/importacion/definitiva",
|
||||
},
|
||||
{
|
||||
title: m["sidebar.import_invoices.mexican_purchases"](),
|
||||
url: "/dashboard/invoices?operation_type=imp&invoice_type=MEX",
|
||||
//url: "/dashboard/invoices?operation_type=imp&invoice_type=MEX",
|
||||
url: "/dashboard/invoices/importacion/compras_mexicanas",
|
||||
},
|
||||
{
|
||||
title: m["sidebar.import_invoices.regime_change"](),
|
||||
url: "/dashboard/invoices?operation_type=imp&invoice_type=CR",
|
||||
//url: "/dashboard/invoices?operation_type=imp&invoice_type=CR",
|
||||
url: "/dashboard/invoices/importacion/cambio_regimen",
|
||||
}
|
||||
],
|
||||
},
|
||||
@@ -346,11 +350,13 @@ export function getSidebarData(): SidebarData {
|
||||
items: [
|
||||
{
|
||||
title: m["sidebar.export_invoices.exportation"](),
|
||||
url: "/dashboard/invoices?operation_type=exp",
|
||||
//url: "/dashboard/invoices?operation_type=exp",
|
||||
url: "/dashboard/invoices/exportacion/exportacion",
|
||||
},
|
||||
{
|
||||
title: m["sidebar.export_invoices.repair"](),
|
||||
url: "/dashboard/invoices?operation_type=exp&invoice_type=REPAR",
|
||||
//url: "/dashboard/invoices?operation_type=exp&invoice_type=REPAR",
|
||||
url: "/dashboard/invoices/exportacion/reparacion",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import type { PageServerLoad } from '../$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import {
|
||||
getAuthTokens,
|
||||
@@ -43,8 +43,8 @@ export const load: PageServerLoad = async ({ fetch, cookies, url, parent }) => {
|
||||
}
|
||||
|
||||
// Obtener filtro de tipo de operación
|
||||
const operationType = url.searchParams.get('operation_type');
|
||||
const invoiceType = url.searchParams.get('invoice_type');
|
||||
const operationType = 'exp'
|
||||
const invoiceType = 'exp'
|
||||
|
||||
// Construir parámetros de consulta
|
||||
const params = new URLSearchParams({
|
||||
@@ -0,0 +1,114 @@
|
||||
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 = 'exp'
|
||||
const invoiceType = 'REPAR'
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Agregar filtro de invoice_type si existe
|
||||
if (invoiceType) {
|
||||
params.append('invoice_type', invoiceType);
|
||||
}
|
||||
|
||||
// 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',
|
||||
invoiceType: invoiceType || null
|
||||
};
|
||||
}
|
||||
|
||||
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',
|
||||
invoiceType: invoiceType || null
|
||||
};
|
||||
} 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 || [],
|
||||
operationType: 'all',
|
||||
invoiceType: null
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,382 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { invoicesApi, type Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import { invoiceTypesApi, type InvoiceType } from '$lib/api/dashboard/refrence_data/invoice_types';
|
||||
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;
|
||||
invoiceType?: string | null;
|
||||
}
|
||||
|
||||
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');
|
||||
let selectedInvoiceType = $state<string | null>(data.invoiceType || null);
|
||||
let availableInvoiceTypes = $state<InvoiceType[]>([]);
|
||||
let loadingInvoiceTypes = $state(false);
|
||||
|
||||
// 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');
|
||||
url.searchParams.delete('invoice_type');
|
||||
selectedInvoiceType = null;
|
||||
} else {
|
||||
url.searchParams.set('operation_type', value);
|
||||
// Mantener invoice_type si existe
|
||||
if (selectedInvoiceType) {
|
||||
url.searchParams.set('invoice_type', selectedInvoiceType);
|
||||
}
|
||||
}
|
||||
goto(url.toString(), { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
// Actualizar URL cuando cambia el filtro de invoice_type
|
||||
function handleInvoiceTypeChange(value: string) {
|
||||
selectedInvoiceType = value === 'all' ? null : value;
|
||||
const url = new URL(window.location.href);
|
||||
if (!selectedInvoiceType) {
|
||||
url.searchParams.delete('invoice_type');
|
||||
} else {
|
||||
url.searchParams.set('invoice_type', selectedInvoiceType);
|
||||
}
|
||||
if (selectedType !== 'all') {
|
||||
url.searchParams.set('operation_type', selectedType);
|
||||
}
|
||||
goto(url.toString(), { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
// Cargar tipos de factura disponibles según operation_type
|
||||
async function loadInvoiceTypes(operationType: string) {
|
||||
if (operationType === 'all') {
|
||||
availableInvoiceTypes = [];
|
||||
return;
|
||||
}
|
||||
|
||||
loadingInvoiceTypes = true;
|
||||
try {
|
||||
const response = await invoiceTypesApi.list(1, 100, operationType);
|
||||
if (response.data) {
|
||||
availableInvoiceTypes = response.data.items;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading invoice types:', e);
|
||||
availableInvoiceTypes = [];
|
||||
} finally {
|
||||
loadingInvoiceTypes = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Efecto para cargar tipos de factura cuando cambia selectedType
|
||||
$effect(() => {
|
||||
loadInvoiceTypes(selectedType);
|
||||
});
|
||||
|
||||
// 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;
|
||||
selectedInvoiceType = data.invoiceType || null;
|
||||
selectedType = data.operationType || 'all';
|
||||
});
|
||||
|
||||
async function loadMore() {
|
||||
if (loading || !hasMore || !companyStore.activeCompany) return;
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const filters: any = {};
|
||||
if (selectedType !== 'all') {
|
||||
filters.operation_type = selectedType;
|
||||
}
|
||||
if (selectedInvoiceType) {
|
||||
filters.invoice_type = selectedInvoiceType;
|
||||
}
|
||||
|
||||
const response = await invoicesApi.list(
|
||||
companyStore.activeCompany.id,
|
||||
currentPage + 1,
|
||||
pageSize,
|
||||
Object.keys(filters).length > 0 ? filters : 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>
|
||||
|
||||
{#if selectedType !== 'all' && availableInvoiceTypes.length > 0}
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={selectedInvoiceType || 'all'}
|
||||
onValueChange={handleInvoiceTypeChange}
|
||||
disabled={loadingInvoiceTypes}
|
||||
>
|
||||
<Select.Trigger class="w-[250px]">
|
||||
<span class="truncate">
|
||||
{#if loadingInvoiceTypes}
|
||||
Cargando...
|
||||
{:else if selectedInvoiceType}
|
||||
{(() => {
|
||||
const found = availableInvoiceTypes.find(t => t.key === selectedInvoiceType);
|
||||
return found ? `${found.key} - ${found.description}` : selectedInvoiceType;
|
||||
})()}
|
||||
{:else}
|
||||
Todos los tipos
|
||||
{/if}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="all">Todos los tipos</Select.Item>
|
||||
{#each availableInvoiceTypes as invType}
|
||||
<Select.Item value={invType.key}>
|
||||
<span class="block truncate max-w-[300px]" title={`${invType.key} - ${invType.description}`}>
|
||||
{invType.key} - {invType.description}
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{/if}
|
||||
|
||||
<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}
|
||||
defaultOperationType={selectedType !== 'all' ? selectedType as 'imp' | 'exp' : undefined}
|
||||
defaultInvoiceType={selectedInvoiceType || undefined}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
<DetailsDialog
|
||||
bind:open={showDetailsDialog}
|
||||
invoice={selectedInvoice}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
bind:open={showDeleteDialog}
|
||||
item={selectedInvoice}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
@@ -0,0 +1,114 @@
|
||||
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 = 'imp'
|
||||
const invoiceType = 'CR'
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Agregar filtro de invoice_type si existe
|
||||
if (invoiceType) {
|
||||
params.append('invoice_type', invoiceType);
|
||||
}
|
||||
|
||||
// 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',
|
||||
invoiceType: invoiceType || null
|
||||
};
|
||||
}
|
||||
|
||||
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',
|
||||
invoiceType: invoiceType || null
|
||||
};
|
||||
} 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 || [],
|
||||
operationType: 'all',
|
||||
invoiceType: null
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,382 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { invoicesApi, type Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import { invoiceTypesApi, type InvoiceType } from '$lib/api/dashboard/refrence_data/invoice_types';
|
||||
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;
|
||||
invoiceType?: string | null;
|
||||
}
|
||||
|
||||
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');
|
||||
let selectedInvoiceType = $state<string | null>(data.invoiceType || null);
|
||||
let availableInvoiceTypes = $state<InvoiceType[]>([]);
|
||||
let loadingInvoiceTypes = $state(false);
|
||||
|
||||
// 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');
|
||||
url.searchParams.delete('invoice_type');
|
||||
selectedInvoiceType = null;
|
||||
} else {
|
||||
url.searchParams.set('operation_type', value);
|
||||
// Mantener invoice_type si existe
|
||||
if (selectedInvoiceType) {
|
||||
url.searchParams.set('invoice_type', selectedInvoiceType);
|
||||
}
|
||||
}
|
||||
goto(url.toString(), { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
// Actualizar URL cuando cambia el filtro de invoice_type
|
||||
function handleInvoiceTypeChange(value: string) {
|
||||
selectedInvoiceType = value === 'all' ? null : value;
|
||||
const url = new URL(window.location.href);
|
||||
if (!selectedInvoiceType) {
|
||||
url.searchParams.delete('invoice_type');
|
||||
} else {
|
||||
url.searchParams.set('invoice_type', selectedInvoiceType);
|
||||
}
|
||||
if (selectedType !== 'all') {
|
||||
url.searchParams.set('operation_type', selectedType);
|
||||
}
|
||||
goto(url.toString(), { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
// Cargar tipos de factura disponibles según operation_type
|
||||
async function loadInvoiceTypes(operationType: string) {
|
||||
if (operationType === 'all') {
|
||||
availableInvoiceTypes = [];
|
||||
return;
|
||||
}
|
||||
|
||||
loadingInvoiceTypes = true;
|
||||
try {
|
||||
const response = await invoiceTypesApi.list(1, 100, operationType);
|
||||
if (response.data) {
|
||||
availableInvoiceTypes = response.data.items;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading invoice types:', e);
|
||||
availableInvoiceTypes = [];
|
||||
} finally {
|
||||
loadingInvoiceTypes = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Efecto para cargar tipos de factura cuando cambia selectedType
|
||||
$effect(() => {
|
||||
loadInvoiceTypes(selectedType);
|
||||
});
|
||||
|
||||
// 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;
|
||||
selectedInvoiceType = data.invoiceType || null;
|
||||
selectedType = data.operationType || 'all';
|
||||
});
|
||||
|
||||
async function loadMore() {
|
||||
if (loading || !hasMore || !companyStore.activeCompany) return;
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const filters: any = {};
|
||||
if (selectedType !== 'all') {
|
||||
filters.operation_type = selectedType;
|
||||
}
|
||||
if (selectedInvoiceType) {
|
||||
filters.invoice_type = selectedInvoiceType;
|
||||
}
|
||||
|
||||
const response = await invoicesApi.list(
|
||||
companyStore.activeCompany.id,
|
||||
currentPage + 1,
|
||||
pageSize,
|
||||
Object.keys(filters).length > 0 ? filters : 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 href="/dashboard/invoices/importacion/cambio_regimen/new">
|
||||
<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>
|
||||
|
||||
{#if selectedType !== 'all' && availableInvoiceTypes.length > 0}
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={selectedInvoiceType || 'all'}
|
||||
onValueChange={handleInvoiceTypeChange}
|
||||
disabled={loadingInvoiceTypes}
|
||||
>
|
||||
<Select.Trigger class="w-[250px]">
|
||||
<span class="truncate">
|
||||
{#if loadingInvoiceTypes}
|
||||
Cargando...
|
||||
{:else if selectedInvoiceType}
|
||||
{(() => {
|
||||
const found = availableInvoiceTypes.find(t => t.key === selectedInvoiceType);
|
||||
return found ? `${found.key} - ${found.description}` : selectedInvoiceType;
|
||||
})()}
|
||||
{:else}
|
||||
Todos los tipos
|
||||
{/if}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="all">Todos los tipos</Select.Item>
|
||||
{#each availableInvoiceTypes as invType}
|
||||
<Select.Item value={invType.key}>
|
||||
<span class="block truncate max-w-[300px]" title={`${invType.key} - ${invType.description}`}>
|
||||
{invType.key} - {invType.description}
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{/if}
|
||||
|
||||
<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}
|
||||
defaultOperationType={selectedType !== 'all' ? selectedType as 'imp' | 'exp' : undefined}
|
||||
defaultInvoiceType={selectedInvoiceType || undefined}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
<DetailsDialog
|
||||
bind:open={showDetailsDialog}
|
||||
invoice={selectedInvoice}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
bind:open={showDeleteDialog}
|
||||
item={selectedInvoice}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
@@ -0,0 +1,320 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import * as Tabs from "$lib/components/ui/tabs";
|
||||
import * as Card from "$lib/components/ui/card"; // Usamos Card para enmarcar
|
||||
import { invoicesApi, type CreateInvoiceData } from "$lib/api/dashboard/a76/invoices";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import { LoaderCircle, ArrowLeft, Save } from 'lucide-svelte';
|
||||
|
||||
// DATOS FIJOS PARA ESTA CARPETA (DEFINITIVA)
|
||||
const FIXED_OP_TYPE = "imp";
|
||||
const FIXED_INV_TYPE = "CR";
|
||||
|
||||
// ESTADO DEL FORMULARIO
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let formData = $state({
|
||||
// Header fields
|
||||
operation_type: FIXED_OP_TYPE as "imp" | "exp",
|
||||
invoice_type: FIXED_INV_TYPE,
|
||||
invoice_number: "",
|
||||
project_number: "",
|
||||
purchase_order: "",
|
||||
related_doc_id: null as number | null,
|
||||
invoice_date: new Date().toISOString().split('T')[0], // Fecha de hoy por defecto
|
||||
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
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
error = "No hay compañía seleccionada";
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
// Construimos el Payload
|
||||
const payload: CreateInvoiceData = {
|
||||
...formData,
|
||||
// Aseguramos que se envíen los fijos
|
||||
operation_type: FIXED_OP_TYPE,
|
||||
invoice_type: FIXED_INV_TYPE
|
||||
};
|
||||
|
||||
const response = await invoicesApi.create(companyStore.activeCompany.id, payload);
|
||||
|
||||
if (response.error) {
|
||||
if (response.status === 401) {
|
||||
error = 'Sesión expirada. Recargando...';
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
} else {
|
||||
error = response.error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ÉXITO: Volvemos a la lista
|
||||
goto('/dashboard/invoices/importacion/cambio_regimen');
|
||||
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Error al guardar";
|
||||
console.error("Error saving:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-5xl mx-auto py-6 px-4 space-y-6">
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<Button variant="outline" size="icon" href="/dashboard/invoices/importacion/cambio_regimen">
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">Nueva Factura Cambio Regimen</h1>
|
||||
<p class="text-muted-foreground">Ingresa los datos para registrar la importación.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="p-4 rounded-md bg-destructive/10 text-destructive border border-destructive/20 text-sm font-medium">
|
||||
⚠️ {error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-6">
|
||||
<Card.Root>
|
||||
<Card.Content class="p-6">
|
||||
|
||||
<Tabs.Root value="general" class="w-full">
|
||||
<Tabs.List class="grid w-full grid-cols-3 mb-6">
|
||||
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
||||
<Tabs.Trigger value="compliance">Cumplimiento</Tabs.Trigger>
|
||||
<Tabs.Trigger value="financials">Financieros</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="general" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Tipo de Operación</Label>
|
||||
<div class="px-3 py-2 bg-muted rounded-md text-sm font-medium">Importación</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Tipo de Factura</Label>
|
||||
<div class="px-3 py-2 bg-muted rounded-md text-sm font-medium">CAMBIO REGIMEN (CR)</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="invoice_number">Número de Factura *</Label>
|
||||
<Input id="invoice_number" bind:value={formData.invoice_number} required placeholder="Número 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} required />
|
||||
</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 pt-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>
|
||||
|
||||
<Tabs.Content value="compliance" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md: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>
|
||||
|
||||
<Tabs.Content value="financials" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md: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 MN" />
|
||||
</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 ME" />
|
||||
</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 Aduana 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 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 Seguro" />
|
||||
</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="Num. Bultos" />
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
</Tabs.Root>
|
||||
|
||||
</Card.Content>
|
||||
|
||||
<Card.Footer class="flex justify-end gap-4 border-t bg-muted/20 p-6">
|
||||
<Button variant="outline" href="/dashboard/invoices/importacion/cambio_regimen">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
Guardando...
|
||||
{:else}
|
||||
<Save class="mr-2 h-4 w-4" />
|
||||
Guardar Factura
|
||||
{/if}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,114 @@
|
||||
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 = 'imp'
|
||||
const invoiceType = 'MEX'
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Agregar filtro de invoice_type si existe
|
||||
if (invoiceType) {
|
||||
params.append('invoice_type', invoiceType);
|
||||
}
|
||||
|
||||
// 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',
|
||||
invoiceType: invoiceType || null
|
||||
};
|
||||
}
|
||||
|
||||
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',
|
||||
invoiceType: invoiceType || null
|
||||
};
|
||||
} 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 || [],
|
||||
operationType: 'all',
|
||||
invoiceType: null
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,382 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { invoicesApi, type Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import { invoiceTypesApi, type InvoiceType } from '$lib/api/dashboard/refrence_data/invoice_types';
|
||||
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;
|
||||
invoiceType?: string | null;
|
||||
}
|
||||
|
||||
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');
|
||||
let selectedInvoiceType = $state<string | null>(data.invoiceType || null);
|
||||
let availableInvoiceTypes = $state<InvoiceType[]>([]);
|
||||
let loadingInvoiceTypes = $state(false);
|
||||
|
||||
// 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');
|
||||
url.searchParams.delete('invoice_type');
|
||||
selectedInvoiceType = null;
|
||||
} else {
|
||||
url.searchParams.set('operation_type', value);
|
||||
// Mantener invoice_type si existe
|
||||
if (selectedInvoiceType) {
|
||||
url.searchParams.set('invoice_type', selectedInvoiceType);
|
||||
}
|
||||
}
|
||||
goto(url.toString(), { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
// Actualizar URL cuando cambia el filtro de invoice_type
|
||||
function handleInvoiceTypeChange(value: string) {
|
||||
selectedInvoiceType = value === 'all' ? null : value;
|
||||
const url = new URL(window.location.href);
|
||||
if (!selectedInvoiceType) {
|
||||
url.searchParams.delete('invoice_type');
|
||||
} else {
|
||||
url.searchParams.set('invoice_type', selectedInvoiceType);
|
||||
}
|
||||
if (selectedType !== 'all') {
|
||||
url.searchParams.set('operation_type', selectedType);
|
||||
}
|
||||
goto(url.toString(), { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
// Cargar tipos de factura disponibles según operation_type
|
||||
async function loadInvoiceTypes(operationType: string) {
|
||||
if (operationType === 'all') {
|
||||
availableInvoiceTypes = [];
|
||||
return;
|
||||
}
|
||||
|
||||
loadingInvoiceTypes = true;
|
||||
try {
|
||||
const response = await invoiceTypesApi.list(1, 100, operationType);
|
||||
if (response.data) {
|
||||
availableInvoiceTypes = response.data.items;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading invoice types:', e);
|
||||
availableInvoiceTypes = [];
|
||||
} finally {
|
||||
loadingInvoiceTypes = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Efecto para cargar tipos de factura cuando cambia selectedType
|
||||
$effect(() => {
|
||||
loadInvoiceTypes(selectedType);
|
||||
});
|
||||
|
||||
// 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;
|
||||
selectedInvoiceType = data.invoiceType || null;
|
||||
selectedType = data.operationType || 'all';
|
||||
});
|
||||
|
||||
async function loadMore() {
|
||||
if (loading || !hasMore || !companyStore.activeCompany) return;
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const filters: any = {};
|
||||
if (selectedType !== 'all') {
|
||||
filters.operation_type = selectedType;
|
||||
}
|
||||
if (selectedInvoiceType) {
|
||||
filters.invoice_type = selectedInvoiceType;
|
||||
}
|
||||
|
||||
const response = await invoicesApi.list(
|
||||
companyStore.activeCompany.id,
|
||||
currentPage + 1,
|
||||
pageSize,
|
||||
Object.keys(filters).length > 0 ? filters : 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 href="/dashboard/invoices/importacion/compras_mexicanas/new">
|
||||
<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>
|
||||
|
||||
{#if selectedType !== 'all' && availableInvoiceTypes.length > 0}
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={selectedInvoiceType || 'all'}
|
||||
onValueChange={handleInvoiceTypeChange}
|
||||
disabled={loadingInvoiceTypes}
|
||||
>
|
||||
<Select.Trigger class="w-[250px]">
|
||||
<span class="truncate">
|
||||
{#if loadingInvoiceTypes}
|
||||
Cargando...
|
||||
{:else if selectedInvoiceType}
|
||||
{(() => {
|
||||
const found = availableInvoiceTypes.find(t => t.key === selectedInvoiceType);
|
||||
return found ? `${found.key} - ${found.description}` : selectedInvoiceType;
|
||||
})()}
|
||||
{:else}
|
||||
Todos los tipos
|
||||
{/if}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="all">Todos los tipos</Select.Item>
|
||||
{#each availableInvoiceTypes as invType}
|
||||
<Select.Item value={invType.key}>
|
||||
<span class="block truncate max-w-[300px]" title={`${invType.key} - ${invType.description}`}>
|
||||
{invType.key} - {invType.description}
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{/if}
|
||||
|
||||
<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}
|
||||
defaultOperationType={selectedType !== 'all' ? selectedType as 'imp' | 'exp' : undefined}
|
||||
defaultInvoiceType={selectedInvoiceType || undefined}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
<DetailsDialog
|
||||
bind:open={showDetailsDialog}
|
||||
invoice={selectedInvoice}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
bind:open={showDeleteDialog}
|
||||
item={selectedInvoice}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
@@ -0,0 +1,320 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import * as Tabs from "$lib/components/ui/tabs";
|
||||
import * as Card from "$lib/components/ui/card"; // Usamos Card para enmarcar
|
||||
import { invoicesApi, type CreateInvoiceData } from "$lib/api/dashboard/a76/invoices";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import { LoaderCircle, ArrowLeft, Save } from 'lucide-svelte';
|
||||
|
||||
// DATOS FIJOS PARA ESTA CARPETA (COMPRAS MEXICANAS)
|
||||
const FIXED_OP_TYPE = "imp";
|
||||
const FIXED_INV_TYPE = "MEX";
|
||||
|
||||
// ESTADO DEL FORMULARIO
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let formData = $state({
|
||||
// Header fields
|
||||
operation_type: FIXED_OP_TYPE as "imp" | "exp",
|
||||
invoice_type: FIXED_INV_TYPE,
|
||||
invoice_number: "",
|
||||
project_number: "",
|
||||
purchase_order: "",
|
||||
related_doc_id: null as number | null,
|
||||
invoice_date: new Date().toISOString().split('T')[0], // Fecha de hoy por defecto
|
||||
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
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
error = "No hay compañía seleccionada";
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
// Construimos el Payload
|
||||
const payload: CreateInvoiceData = {
|
||||
...formData,
|
||||
// Aseguramos que se envíen los fijos
|
||||
operation_type: FIXED_OP_TYPE,
|
||||
invoice_type: FIXED_INV_TYPE
|
||||
};
|
||||
|
||||
const response = await invoicesApi.create(companyStore.activeCompany.id, payload);
|
||||
|
||||
if (response.error) {
|
||||
if (response.status === 401) {
|
||||
error = 'Sesión expirada. Recargando...';
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
} else {
|
||||
error = response.error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ÉXITO: Volvemos a la lista
|
||||
goto('/dashboard/invoices/importacion/compras_mexicanas');
|
||||
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Error al guardar";
|
||||
console.error("Error saving:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-5xl mx-auto py-6 px-4 space-y-6">
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<Button variant="outline" size="icon" href="/dashboard/invoices/importacion/compras_mexicanas">
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">Nueva Factura</h1>
|
||||
<p class="text-muted-foreground">Ingresa los datos para registrar la importación.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="p-4 rounded-md bg-destructive/10 text-destructive border border-destructive/20 text-sm font-medium">
|
||||
⚠️ {error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-6">
|
||||
<Card.Root>
|
||||
<Card.Content class="p-6">
|
||||
|
||||
<Tabs.Root value="general" class="w-full">
|
||||
<Tabs.List class="grid w-full grid-cols-3 mb-6">
|
||||
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
||||
<Tabs.Trigger value="compliance">Cumplimiento</Tabs.Trigger>
|
||||
<Tabs.Trigger value="financials">Financieros</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="general" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Tipo de Operación</Label>
|
||||
<div class="px-3 py-2 bg-muted rounded-md text-sm font-medium">Importación</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Tipo de Factura</Label>
|
||||
<div class="px-3 py-2 bg-muted rounded-md text-sm font-medium">COMPRAS MEXICANAS (MEX)</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="invoice_number">Número de Factura *</Label>
|
||||
<Input id="invoice_number" bind:value={formData.invoice_number} required placeholder="Número 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} required />
|
||||
</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 pt-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>
|
||||
|
||||
<Tabs.Content value="compliance" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md: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>
|
||||
|
||||
<Tabs.Content value="financials" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md: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 MN" />
|
||||
</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 ME" />
|
||||
</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 Aduana 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 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 Seguro" />
|
||||
</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="Num. Bultos" />
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
</Tabs.Root>
|
||||
|
||||
</Card.Content>
|
||||
|
||||
<Card.Footer class="flex justify-end gap-4 border-t bg-muted/20 p-6">
|
||||
<Button variant="outline" href="/dashboard/invoices/importacion/campra_mexicanas">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
Guardando...
|
||||
{:else}
|
||||
<Save class="mr-2 h-4 w-4" />
|
||||
Guardar Factura
|
||||
{/if}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,114 @@
|
||||
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 = 'imp'
|
||||
const invoiceType = 'DEF'
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Agregar filtro de invoice_type si existe
|
||||
if (invoiceType) {
|
||||
params.append('invoice_type', invoiceType);
|
||||
}
|
||||
|
||||
// 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',
|
||||
invoiceType: invoiceType || null
|
||||
};
|
||||
}
|
||||
|
||||
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',
|
||||
invoiceType: invoiceType || null
|
||||
};
|
||||
} 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 || [],
|
||||
operationType: 'all',
|
||||
invoiceType: null
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,390 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { invoicesApi, type Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import { invoiceTypesApi, type InvoiceType } from '$lib/api/dashboard/refrence_data/invoice_types';
|
||||
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;
|
||||
invoiceType?: string | null;
|
||||
}
|
||||
|
||||
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');
|
||||
let selectedInvoiceType = $state<string | null>(data.invoiceType || null);
|
||||
let availableInvoiceTypes = $state<InvoiceType[]>([]);
|
||||
let loadingInvoiceTypes = $state(false);
|
||||
|
||||
// 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');
|
||||
url.searchParams.delete('invoice_type');
|
||||
selectedInvoiceType = null;
|
||||
} else {
|
||||
url.searchParams.set('operation_type', value);
|
||||
// Mantener invoice_type si existe
|
||||
if (selectedInvoiceType) {
|
||||
url.searchParams.set('invoice_type', selectedInvoiceType);
|
||||
}
|
||||
}
|
||||
goto(url.toString(), { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
// Actualizar URL cuando cambia el filtro de invoice_type
|
||||
function handleInvoiceTypeChange(value: string) {
|
||||
selectedInvoiceType = value === 'all' ? null : value;
|
||||
const url = new URL(window.location.href);
|
||||
if (!selectedInvoiceType) {
|
||||
url.searchParams.delete('invoice_type');
|
||||
} else {
|
||||
url.searchParams.set('invoice_type', selectedInvoiceType);
|
||||
}
|
||||
if (selectedType !== 'all') {
|
||||
url.searchParams.set('operation_type', selectedType);
|
||||
}
|
||||
goto(url.toString(), { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
// Cargar tipos de factura disponibles según operation_type
|
||||
async function loadInvoiceTypes(operationType: string) {
|
||||
if (operationType === 'all') {
|
||||
availableInvoiceTypes = [];
|
||||
return;
|
||||
}
|
||||
|
||||
loadingInvoiceTypes = true;
|
||||
try {
|
||||
const response = await invoiceTypesApi.list(1, 100, operationType);
|
||||
if (response.data) {
|
||||
availableInvoiceTypes = response.data.items;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading invoice types:', e);
|
||||
availableInvoiceTypes = [];
|
||||
} finally {
|
||||
loadingInvoiceTypes = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Efecto para cargar tipos de factura cuando cambia selectedType
|
||||
$effect(() => {
|
||||
loadInvoiceTypes(selectedType);
|
||||
});
|
||||
|
||||
// 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>) => {
|
||||
// Esta es la función que realmente abre la ventana emergente
|
||||
selectedInvoice = event.detail; // Carga los datos
|
||||
showCreateDialog = true; // Abre el modal
|
||||
};
|
||||
|
||||
window.addEventListener('invoiceEdit', handleInvoiceEdit as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('invoiceEdit', handleInvoiceEdit as EventListener);
|
||||
};
|
||||
|
||||
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;
|
||||
selectedInvoiceType = data.invoiceType || null;
|
||||
selectedType = data.operationType || 'all';
|
||||
});
|
||||
|
||||
async function loadMore() {
|
||||
if (loading || !hasMore || !companyStore.activeCompany) return;
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const filters: any = {};
|
||||
if (selectedType !== 'all') {
|
||||
filters.operation_type = selectedType;
|
||||
}
|
||||
if (selectedInvoiceType) {
|
||||
filters.invoice_type = selectedInvoiceType;
|
||||
}
|
||||
|
||||
const response = await invoicesApi.list(
|
||||
companyStore.activeCompany.id,
|
||||
currentPage + 1,
|
||||
pageSize,
|
||||
Object.keys(filters).length > 0 ? filters : 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 href="/dashboard/invoices/importacion/definitiva/new">
|
||||
<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>
|
||||
|
||||
{#if selectedType !== 'all' && availableInvoiceTypes.length > 0}
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={selectedInvoiceType || 'all'}
|
||||
onValueChange={handleInvoiceTypeChange}
|
||||
disabled={loadingInvoiceTypes}
|
||||
>
|
||||
<Select.Trigger class="w-[250px]">
|
||||
<span class="truncate">
|
||||
{#if loadingInvoiceTypes}
|
||||
Cargando...
|
||||
{:else if selectedInvoiceType}
|
||||
{(() => {
|
||||
const found = availableInvoiceTypes.find(t => t.key === selectedInvoiceType);
|
||||
return found ? `${found.key} - ${found.description}` : selectedInvoiceType;
|
||||
})()}
|
||||
{:else}
|
||||
Todos los tipos
|
||||
{/if}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="all">Todos los tipos</Select.Item>
|
||||
{#each availableInvoiceTypes as invType}
|
||||
<Select.Item value={invType.key}>
|
||||
<span class="block truncate max-w-[300px]" title={`${invType.key} - ${invType.description}`}>
|
||||
{invType.key} - {invType.description}
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{/if}
|
||||
|
||||
<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}
|
||||
defaultOperationType={selectedType !== 'all' ? selectedType as 'imp' | 'exp' : undefined}
|
||||
defaultInvoiceType={selectedInvoiceType || undefined}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
<DetailsDialog
|
||||
bind:open={showDetailsDialog}
|
||||
invoice={selectedInvoice}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
bind:open={showDeleteDialog}
|
||||
item={selectedInvoice}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
@@ -0,0 +1,320 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import * as Tabs from "$lib/components/ui/tabs";
|
||||
import * as Card from "$lib/components/ui/card"; // Usamos Card para enmarcar
|
||||
import { invoicesApi, type CreateInvoiceData } from "$lib/api/dashboard/a76/invoices";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import { LoaderCircle, ArrowLeft, Save } from 'lucide-svelte';
|
||||
|
||||
// DATOS FIJOS PARA ESTA CARPETA (DEFINITIVA)
|
||||
const FIXED_OP_TYPE = "imp";
|
||||
const FIXED_INV_TYPE = "DEF";
|
||||
|
||||
// ESTADO DEL FORMULARIO
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let formData = $state({
|
||||
// Header fields
|
||||
operation_type: FIXED_OP_TYPE as "imp" | "exp",
|
||||
invoice_type: FIXED_INV_TYPE,
|
||||
invoice_number: "",
|
||||
project_number: "",
|
||||
purchase_order: "",
|
||||
related_doc_id: null as number | null,
|
||||
invoice_date: new Date().toISOString().split('T')[0], // Fecha de hoy por defecto
|
||||
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
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
error = "No hay compañía seleccionada";
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
// Construimos el Payload
|
||||
const payload: CreateInvoiceData = {
|
||||
...formData,
|
||||
// Aseguramos que se envíen los fijos
|
||||
operation_type: FIXED_OP_TYPE,
|
||||
invoice_type: FIXED_INV_TYPE
|
||||
};
|
||||
|
||||
const response = await invoicesApi.create(companyStore.activeCompany.id, payload);
|
||||
|
||||
if (response.error) {
|
||||
if (response.status === 401) {
|
||||
error = 'Sesión expirada. Recargando...';
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
} else {
|
||||
error = response.error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ÉXITO: Volvemos a la lista
|
||||
goto('/dashboard/invoices/importacion/definitiva');
|
||||
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Error al guardar";
|
||||
console.error("Error saving:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-5xl mx-auto py-6 px-4 space-y-6">
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<Button variant="outline" size="icon" href="/dashboard/invoices/importacion/definitiva">
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">Nueva Factura Definitiva</h1>
|
||||
<p class="text-muted-foreground">Ingresa los datos para registrar la importación.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="p-4 rounded-md bg-destructive/10 text-destructive border border-destructive/20 text-sm font-medium">
|
||||
⚠️ {error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-6">
|
||||
<Card.Root>
|
||||
<Card.Content class="p-6">
|
||||
|
||||
<Tabs.Root value="general" class="w-full">
|
||||
<Tabs.List class="grid w-full grid-cols-3 mb-6">
|
||||
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
||||
<Tabs.Trigger value="compliance">Cumplimiento</Tabs.Trigger>
|
||||
<Tabs.Trigger value="financials">Financieros</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="general" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Tipo de Operación</Label>
|
||||
<div class="px-3 py-2 bg-muted rounded-md text-sm font-medium">Importación</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Tipo de Factura</Label>
|
||||
<div class="px-3 py-2 bg-muted rounded-md text-sm font-medium">Definitiva (DEF)</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="invoice_number">Número de Factura *</Label>
|
||||
<Input id="invoice_number" bind:value={formData.invoice_number} required placeholder="Número 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} required />
|
||||
</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 pt-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>
|
||||
|
||||
<Tabs.Content value="compliance" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md: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>
|
||||
|
||||
<Tabs.Content value="financials" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md: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 MN" />
|
||||
</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 ME" />
|
||||
</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 Aduana 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 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 Seguro" />
|
||||
</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="Num. Bultos" />
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
</Tabs.Root>
|
||||
|
||||
</Card.Content>
|
||||
|
||||
<Card.Footer class="flex justify-end gap-4 border-t bg-muted/20 p-6">
|
||||
<Button variant="outline" href="/dashboard/invoices/importacion/definitiva">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
Guardando...
|
||||
{:else}
|
||||
<Save class="mr-2 h-4 w-4" />
|
||||
Guardar Factura
|
||||
{/if}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,114 @@
|
||||
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 = 'imp'
|
||||
const invoiceType = 'TEM'
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Agregar filtro de invoice_type si existe
|
||||
if (invoiceType) {
|
||||
params.append('invoice_type', invoiceType);
|
||||
}
|
||||
|
||||
// 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',
|
||||
invoiceType: invoiceType || null
|
||||
};
|
||||
}
|
||||
|
||||
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',
|
||||
invoiceType: invoiceType || null
|
||||
};
|
||||
} 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 || [],
|
||||
operationType: 'all',
|
||||
invoiceType: null
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,382 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { invoicesApi, type Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import { invoiceTypesApi, type InvoiceType } from '$lib/api/dashboard/refrence_data/invoice_types';
|
||||
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;
|
||||
invoiceType?: string | null;
|
||||
}
|
||||
|
||||
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');
|
||||
let selectedInvoiceType = $state<string | null>(data.invoiceType || null);
|
||||
let availableInvoiceTypes = $state<InvoiceType[]>([]);
|
||||
let loadingInvoiceTypes = $state(false);
|
||||
|
||||
// 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');
|
||||
url.searchParams.delete('invoice_type');
|
||||
selectedInvoiceType = null;
|
||||
} else {
|
||||
url.searchParams.set('operation_type', value);
|
||||
// Mantener invoice_type si existe
|
||||
if (selectedInvoiceType) {
|
||||
url.searchParams.set('invoice_type', selectedInvoiceType);
|
||||
}
|
||||
}
|
||||
goto(url.toString(), { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
// Actualizar URL cuando cambia el filtro de invoice_type
|
||||
function handleInvoiceTypeChange(value: string) {
|
||||
selectedInvoiceType = value === 'all' ? null : value;
|
||||
const url = new URL(window.location.href);
|
||||
if (!selectedInvoiceType) {
|
||||
url.searchParams.delete('invoice_type');
|
||||
} else {
|
||||
url.searchParams.set('invoice_type', selectedInvoiceType);
|
||||
}
|
||||
if (selectedType !== 'all') {
|
||||
url.searchParams.set('operation_type', selectedType);
|
||||
}
|
||||
goto(url.toString(), { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
// Cargar tipos de factura disponibles según operation_type
|
||||
async function loadInvoiceTypes(operationType: string) {
|
||||
if (operationType === 'all') {
|
||||
availableInvoiceTypes = [];
|
||||
return;
|
||||
}
|
||||
|
||||
loadingInvoiceTypes = true;
|
||||
try {
|
||||
const response = await invoiceTypesApi.list(1, 100, operationType);
|
||||
if (response.data) {
|
||||
availableInvoiceTypes = response.data.items;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading invoice types:', e);
|
||||
availableInvoiceTypes = [];
|
||||
} finally {
|
||||
loadingInvoiceTypes = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Efecto para cargar tipos de factura cuando cambia selectedType
|
||||
$effect(() => {
|
||||
loadInvoiceTypes(selectedType);
|
||||
});
|
||||
|
||||
// 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;
|
||||
selectedInvoiceType = data.invoiceType || null;
|
||||
selectedType = data.operationType || 'all';
|
||||
});
|
||||
|
||||
async function loadMore() {
|
||||
if (loading || !hasMore || !companyStore.activeCompany) return;
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const filters: any = {};
|
||||
if (selectedType !== 'all') {
|
||||
filters.operation_type = selectedType;
|
||||
}
|
||||
if (selectedInvoiceType) {
|
||||
filters.invoice_type = selectedInvoiceType;
|
||||
}
|
||||
|
||||
const response = await invoicesApi.list(
|
||||
companyStore.activeCompany.id,
|
||||
currentPage + 1,
|
||||
pageSize,
|
||||
Object.keys(filters).length > 0 ? filters : 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 href="/dashboard/invoices/importacion/temporal/new">
|
||||
<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>
|
||||
|
||||
{#if selectedType !== 'all' && availableInvoiceTypes.length > 0}
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={selectedInvoiceType || 'all'}
|
||||
onValueChange={handleInvoiceTypeChange}
|
||||
disabled={loadingInvoiceTypes}
|
||||
>
|
||||
<Select.Trigger class="w-[250px]">
|
||||
<span class="truncate">
|
||||
{#if loadingInvoiceTypes}
|
||||
Cargando...
|
||||
{:else if selectedInvoiceType}
|
||||
{(() => {
|
||||
const found = availableInvoiceTypes.find(t => t.key === selectedInvoiceType);
|
||||
return found ? `${found.key} - ${found.description}` : selectedInvoiceType;
|
||||
})()}
|
||||
{:else}
|
||||
Todos los tipos
|
||||
{/if}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="all">Todos los tipos</Select.Item>
|
||||
{#each availableInvoiceTypes as invType}
|
||||
<Select.Item value={invType.key}>
|
||||
<span class="block truncate max-w-[300px]" title={`${invType.key} - ${invType.description}`}>
|
||||
{invType.key} - {invType.description}
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{/if}
|
||||
|
||||
<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}
|
||||
defaultOperationType={selectedType !== 'all' ? selectedType as 'imp' | 'exp' : undefined}
|
||||
defaultInvoiceType={selectedInvoiceType || undefined}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
<DetailsDialog
|
||||
bind:open={showDetailsDialog}
|
||||
invoice={selectedInvoice}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
bind:open={showDeleteDialog}
|
||||
item={selectedInvoice}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
@@ -0,0 +1,320 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import * as Tabs from "$lib/components/ui/tabs";
|
||||
import * as Card from "$lib/components/ui/card"; // Usamos Card para enmarcar
|
||||
import { invoicesApi, type CreateInvoiceData } from "$lib/api/dashboard/a76/invoices";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import { LoaderCircle, ArrowLeft, Save } from 'lucide-svelte';
|
||||
|
||||
// DATOS FIJOS PARA ESTA CARPETA (Temporal Importación)
|
||||
const FIXED_OP_TYPE = "imp";
|
||||
const FIXED_INV_TYPE = "TEM";
|
||||
|
||||
// ESTADO DEL FORMULARIO
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let formData = $state({
|
||||
// Header fields
|
||||
operation_type: FIXED_OP_TYPE as "imp" | "exp",
|
||||
invoice_type: FIXED_INV_TYPE,
|
||||
invoice_number: "",
|
||||
project_number: "",
|
||||
purchase_order: "",
|
||||
related_doc_id: null as number | null,
|
||||
invoice_date: new Date().toISOString().split('T')[0], // Fecha de hoy por defecto
|
||||
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
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
error = "No hay compañía seleccionada";
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
// Construimos el Payload
|
||||
const payload: CreateInvoiceData = {
|
||||
...formData,
|
||||
// Aseguramos que se envíen los fijos
|
||||
operation_type: FIXED_OP_TYPE,
|
||||
invoice_type: FIXED_INV_TYPE
|
||||
};
|
||||
|
||||
const response = await invoicesApi.create(companyStore.activeCompany.id, payload);
|
||||
|
||||
if (response.error) {
|
||||
if (response.status === 401) {
|
||||
error = 'Sesión expirada. Recargando...';
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
} else {
|
||||
error = response.error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ÉXITO: Volvemos a la lista
|
||||
goto('/dashboard/invoices/importacion/temporal');
|
||||
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Error al guardar";
|
||||
console.error("Error saving:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-5xl mx-auto py-6 px-4 space-y-6">
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<Button variant="outline" size="icon" href="/dashboard/invoices/importacion/temporal">
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">Nueva Factura Temporal</h1>
|
||||
<p class="text-muted-foreground">Ingresa los datos para registrar la importación.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="p-4 rounded-md bg-destructive/10 text-destructive border border-destructive/20 text-sm font-medium">
|
||||
⚠️ {error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-6">
|
||||
<Card.Root>
|
||||
<Card.Content class="p-6">
|
||||
|
||||
<Tabs.Root value="general" class="w-full">
|
||||
<Tabs.List class="grid w-full grid-cols-3 mb-6">
|
||||
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
||||
<Tabs.Trigger value="compliance">Cumplimiento</Tabs.Trigger>
|
||||
<Tabs.Trigger value="financials">Financieros</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="general" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Tipo de Operación</Label>
|
||||
<div class="px-3 py-2 bg-muted rounded-md text-sm font-medium">Importación</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Tipo de Factura</Label>
|
||||
<div class="px-3 py-2 bg-muted rounded-md text-sm font-medium">Temporal (TEM)</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="invoice_number">Número de Factura *</Label>
|
||||
<Input id="invoice_number" bind:value={formData.invoice_number} required placeholder="Número 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} required />
|
||||
</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 pt-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>
|
||||
|
||||
<Tabs.Content value="compliance" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md: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>
|
||||
|
||||
<Tabs.Content value="financials" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md: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 MN" />
|
||||
</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 ME" />
|
||||
</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 Aduana 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 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 Seguro" />
|
||||
</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="Num. Bultos" />
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
</Tabs.Root>
|
||||
|
||||
</Card.Content>
|
||||
|
||||
<Card.Footer class="flex justify-end gap-4 border-t bg-muted/20 p-6">
|
||||
<Button variant="outline" href="/dashboard/invoices/importacion/temporal">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
Guardando...
|
||||
{:else}
|
||||
<Save class="mr-2 h-4 w-4" />
|
||||
Guardar Factura
|
||||
{/if}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</form>
|
||||
</div>
|
||||
Reference in New Issue
Block a user