Se mejoro el disenio de partes, se genero la informacion mas precisa en los reportes y se carga el logo en las instanacias de las empresas
This commit is contained in:
@@ -45,7 +45,7 @@ async function refreshToken(): Promise<string | null> {
|
||||
if (!browser) return null;
|
||||
|
||||
let refreshTokenValue = localStorage.getItem('refresh_token');
|
||||
|
||||
|
||||
// Si no está en localStorage, intentar obtenerlo de las cookies
|
||||
if (!refreshTokenValue) {
|
||||
const getCookie = (name: string): string | null => {
|
||||
@@ -54,18 +54,18 @@ async function refreshToken(): Promise<string | null> {
|
||||
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
refreshTokenValue = getCookie('refresh_token');
|
||||
if (refreshTokenValue) {
|
||||
if (refreshTokenValue) {
|
||||
localStorage.setItem('refresh_token', refreshTokenValue);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!refreshTokenValue) {
|
||||
console.error('❌ [API] No hay refresh token disponible');
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
@@ -93,25 +93,25 @@ async function refreshToken(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = await response.json();
|
||||
|
||||
// Guardar los nuevos tokens
|
||||
if (data.access_token) {
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
|
||||
|
||||
if (data.refresh_token) {
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
}
|
||||
|
||||
|
||||
// Actualizar también las cookies
|
||||
const isSecure = window.location.protocol === 'https:';
|
||||
const secureFlag = isSecure ? '; Secure' : '';
|
||||
|
||||
|
||||
document.cookie = `access_token=${data.access_token}; path=/; max-age=${60 * 60 * 24 * 7}; SameSite=Lax${secureFlag}`;
|
||||
if (data.refresh_token) {
|
||||
document.cookie = `refresh_token=${data.refresh_token}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax${secureFlag}`;
|
||||
}
|
||||
|
||||
|
||||
// Actualizar el authStore si está disponible
|
||||
try {
|
||||
const { authStore } = await import('./auth');
|
||||
@@ -120,7 +120,7 @@ async function refreshToken(): Promise<string | null> {
|
||||
// Si no se puede importar authStore, no es crítico
|
||||
console.warn('⚠️ [API] No se pudo actualizar authStore:', e);
|
||||
}
|
||||
|
||||
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ async function fetchApi<T = any>(
|
||||
retryCount = 0
|
||||
): Promise<ApiResponse<T>> {
|
||||
// Si ya estamos refrescando el token, esperar
|
||||
if (isRefreshing && retryCount === 0) {
|
||||
if (isRefreshing && retryCount === 0) {
|
||||
return new Promise((resolve) => {
|
||||
subscribeTokenRefresh((newToken) => {
|
||||
resolve(fetchApi<T>(endpoint, options, 1));
|
||||
@@ -149,16 +149,20 @@ async function fetchApi<T = any>(
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
|
||||
|
||||
if (!token && !endpoint.includes('/auth/login')) {
|
||||
console.warn('⚠️ [API] No hay token disponible para', endpoint);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...((options.headers as Record<string, string>) || {})
|
||||
};
|
||||
|
||||
// Only set Content-Type to application/json if not already set and body is not FormData
|
||||
if (!headers['Content-Type'] && !(options.body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
@@ -171,7 +175,7 @@ async function fetchApi<T = any>(
|
||||
});
|
||||
|
||||
// Si recibimos 401 o 403 y no es el endpoint de refresh, intentar refrescar el token
|
||||
if ((response.status === 401 || response.status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) {
|
||||
if ((response.status === 401 || response.status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) {
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
@@ -226,7 +230,7 @@ async function fetchApi<T = any>(
|
||||
// Errores de validación de FastAPI (con detail)
|
||||
else if (data.detail) {
|
||||
let errorMessage = 'Error de validación: ';
|
||||
|
||||
|
||||
// FastAPI devuelve errores de validación en data.detail como array
|
||||
if (Array.isArray(data.detail)) {
|
||||
const errors = data.detail.map((err: any) => {
|
||||
@@ -239,14 +243,14 @@ async function fetchApi<T = any>(
|
||||
} else {
|
||||
errorMessage += JSON.stringify(data.detail);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
error: errorMessage,
|
||||
status: response.status
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
error: data.message || data.detail || 'Error en la petición',
|
||||
status: response.status
|
||||
@@ -281,7 +285,7 @@ export const api = {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body)
|
||||
}),
|
||||
|
||||
|
||||
patch: <T = any>(endpoint: string, body: any) =>
|
||||
fetchApi<T>(endpoint, {
|
||||
method: 'PATCH',
|
||||
@@ -314,5 +318,8 @@ export const api = {
|
||||
myLicense: () => api.get('/v1/licenses/my-license'),
|
||||
usage: (tenantId: number) => api.get(`/v1/licenses/usage/${tenantId}`),
|
||||
validate: (tenantId: number) => api.get(`/v1/licenses/validate/${tenantId}`)
|
||||
}
|
||||
},
|
||||
|
||||
// Generic request for custom needs (like file uploads)
|
||||
request: <T = any>(endpoint: string, options: RequestInit = {}) => fetchApi<T>(endpoint, options)
|
||||
};
|
||||
|
||||
@@ -87,7 +87,7 @@ export interface CreateCustomsBrokerData {
|
||||
*/
|
||||
export const customsBrokersApi = {
|
||||
list: (companyId: string) => {
|
||||
return api.get<CustomsBroker[]>(`/v1/a76/customs-brokers/?company_id=${companyId}`);
|
||||
return api.get<CustomsBroker[]>(`/v1/a76/customs-brokers?company_id=${companyId}`);
|
||||
},
|
||||
|
||||
get: (brokerKey: string, companyId: string) => {
|
||||
@@ -95,7 +95,7 @@ export const customsBrokersApi = {
|
||||
},
|
||||
|
||||
create: (data: CreateCustomsBrokerData, companyId: string) => {
|
||||
return api.post<CustomsBroker>(`/v1/a76/customs-brokers/?company_id=${companyId}`, data);
|
||||
return api.post<CustomsBroker>(`/v1/a76/customs-brokers?company_id=${companyId}`, data);
|
||||
},
|
||||
|
||||
update: (brokerKey: string, data: CreateCustomsBrokerData, companyId: string) => {
|
||||
|
||||
@@ -25,6 +25,13 @@ export interface Company {
|
||||
order_format_type?: string | null;
|
||||
ctpat_svi?: string | null;
|
||||
trusted_exporter_number?: string | null;
|
||||
logo?: string | null;
|
||||
previous_code?: number | null;
|
||||
client_name?: string | null;
|
||||
subassembly_mode?: string | null;
|
||||
inter_db_name?: string | null;
|
||||
prevalidator_key?: string | null;
|
||||
seventh_amendment?: boolean;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
@@ -113,3 +120,13 @@ export async function updateCompany(id: number, data: CompanyUpdate): Promise<Ap
|
||||
export async function deleteCompany(id: number): Promise<ApiResponse<void>> {
|
||||
return await api.delete(`/v1/a76/company/${id}`);
|
||||
}
|
||||
|
||||
export async function uploadCompanyLogo(id: number, file: File): Promise<ApiResponse<{ path: string }>> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
// Use api.request to pass FormData directly without JSON.stringify
|
||||
return await api.request(`/v1/a76/company/${id}/upload-logo`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { api } from '$lib/api';
|
||||
import type { PaginatedResponse } from '$lib/types';
|
||||
|
||||
export interface PaginatedResponse {
|
||||
page: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface MultiCurrencyType {
|
||||
id: number;
|
||||
@@ -29,11 +35,13 @@ export interface MultiCurrencyTypeListResponse extends PaginatedResponse {
|
||||
items: MultiCurrencyType[];
|
||||
}
|
||||
|
||||
import type { ApiResponse } from '$lib/api';
|
||||
|
||||
export async function getMultiCurrencyTypes(
|
||||
companyId: number,
|
||||
page?: number,
|
||||
pageSize?: number
|
||||
): Promise<MultiCurrencyTypeListResponse> {
|
||||
): Promise<ApiResponse<MultiCurrencyTypeListResponse>> {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
if (page) params.append('page', page.toString());
|
||||
if (pageSize) params.append('page_size', pageSize.toString());
|
||||
@@ -44,7 +52,7 @@ export async function getMultiCurrencyTypes(
|
||||
export async function getMultiCurrencyType(
|
||||
multiCurrencyTypeId: number,
|
||||
companyId: number
|
||||
): Promise<MultiCurrencyType> {
|
||||
): Promise<ApiResponse<MultiCurrencyType>> {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
return api.get<MultiCurrencyType>(`/v1/a76/multi-currency-types/${multiCurrencyTypeId}?${params.toString()}`);
|
||||
}
|
||||
@@ -52,7 +60,7 @@ export async function getMultiCurrencyType(
|
||||
export async function createMultiCurrencyType(
|
||||
data: MultiCurrencyTypeCreate,
|
||||
companyId: number
|
||||
): Promise<MultiCurrencyType> {
|
||||
): Promise<ApiResponse<MultiCurrencyType>> {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
return api.post<MultiCurrencyType>(`/v1/a76/multi-currency-types/?${params.toString()}`, data);
|
||||
}
|
||||
@@ -61,7 +69,7 @@ export async function updateMultiCurrencyType(
|
||||
multiCurrencyTypeId: number,
|
||||
data: MultiCurrencyTypeUpdate,
|
||||
companyId: number
|
||||
): Promise<MultiCurrencyType> {
|
||||
): Promise<ApiResponse<MultiCurrencyType>> {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
return api.put<MultiCurrencyType>(
|
||||
`/v1/a76/multi-currency-types/${multiCurrencyTypeId}?${params.toString()}`,
|
||||
@@ -72,7 +80,7 @@ export async function updateMultiCurrencyType(
|
||||
export async function deleteMultiCurrencyType(
|
||||
multiCurrencyTypeId: number,
|
||||
companyId: number
|
||||
): Promise<void> {
|
||||
): Promise<ApiResponse<any>> {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
return api.delete(`/v1/a76/multi-currency-types/${multiCurrencyTypeId}?${params.toString()}`);
|
||||
}
|
||||
@@ -55,24 +55,24 @@ export interface Part {
|
||||
tenant_id: number;
|
||||
company_id: number;
|
||||
client_id: number;
|
||||
|
||||
|
||||
// Identificación
|
||||
part_number: string;
|
||||
commercial_part_number: string | null;
|
||||
|
||||
|
||||
// Descripciones y Clase
|
||||
description_spanish: string | null;
|
||||
description_english: string | null;
|
||||
part_class: string | null;
|
||||
unit_of_measure: string | null;
|
||||
|
||||
unit_of_measure: string | null;
|
||||
|
||||
// Costos y Pesos
|
||||
unit_cost: number | null;
|
||||
currency_key: string | null;
|
||||
currency_type: string | null;
|
||||
unit_weight: number | null;
|
||||
weight_type: string | null;
|
||||
|
||||
|
||||
// Regulatorio
|
||||
fraction: string | null;
|
||||
us_fraction: string | null;
|
||||
@@ -82,14 +82,14 @@ export interface Part {
|
||||
eccn: string | null;
|
||||
export_code: string | null;
|
||||
exclusion_symbol: string | null;
|
||||
|
||||
|
||||
// Estado y Media
|
||||
is_active: boolean;
|
||||
part_photo: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
|
||||
|
||||
fa_data?: FaData | null;
|
||||
inv_data?: InvData | null;
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export interface PartCreate extends Omit<Part, 'id' | 'tenant_id' | 'created_at'
|
||||
}
|
||||
|
||||
|
||||
export interface PartUpdate extends Partial<PartCreate> {}
|
||||
export interface PartUpdate extends Partial<PartCreate> { }
|
||||
|
||||
|
||||
export interface PartListResponse {
|
||||
@@ -112,20 +112,20 @@ export interface PartListResponse {
|
||||
|
||||
export const partsApi = {
|
||||
|
||||
list: (params: {
|
||||
company_id: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
q?: string
|
||||
list: (params: {
|
||||
company_id: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
q?: string
|
||||
}) => {
|
||||
const { company_id, page = 1, page_size = 50, q = '' } = params;
|
||||
const skip = (page - 1) * page_size;
|
||||
|
||||
|
||||
const query = new URLSearchParams({
|
||||
company_id: company_id.toString(),
|
||||
skip: skip.toString(),
|
||||
limit: page_size.toString(),
|
||||
description: q
|
||||
description: q
|
||||
});
|
||||
|
||||
return api.get<PartListResponse>(`/v1/a76/parts/?${query.toString()}`);
|
||||
@@ -140,11 +140,11 @@ export const partsApi = {
|
||||
},
|
||||
|
||||
update: (id: number, data: PartUpdate, company_id: number) => {
|
||||
return api.put<Part>(`/v1/a76/parts/${id}?company_id=${company_id}`, data);
|
||||
return api.put<Part>(`/v1/a76/parts/${id}/?company_id=${company_id}`, data);
|
||||
},
|
||||
|
||||
|
||||
delete: (id: number, company_id: number) => {
|
||||
return api.delete<void>(`/v1/a76/parts/${id}?company_id=${company_id}`);
|
||||
return api.delete<void>(`/v1/a76/parts/${id}/?company_id=${company_id}`);
|
||||
}
|
||||
};
|
||||
@@ -24,9 +24,10 @@
|
||||
// Filtro reactivo local
|
||||
let filteredClients = $derived(
|
||||
clients.filter(c =>
|
||||
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(c.client_or_provider === 'client' || c.client_or_provider === 'both') &&
|
||||
(c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
c.rfc.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
c.id.toString().includes(searchTerm)
|
||||
c.id.toString().includes(searchTerm))
|
||||
)
|
||||
);
|
||||
|
||||
@@ -42,10 +43,8 @@
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
// Petición a la API
|
||||
const res = await clientsProvidersApi.list(companyStore.activeCompany.id, 1, 100, {
|
||||
type: 'client'
|
||||
});
|
||||
// Petición a la API - Traer todos para filtrar localmente
|
||||
const res = await clientsProvidersApi.list(companyStore.activeCompany.id, 1, 1000);
|
||||
|
||||
// Normalización de respuesta
|
||||
const responseData = (res as any).data || res;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import * as Table from "$lib/components/ui/table";
|
||||
import { Search, Loader2, Globe } from "lucide-svelte";
|
||||
import { countriesApi, type Country } from "$lib/api/dashboard/refrence_data/countries";
|
||||
import { toast } from "svelte-sonner";
|
||||
|
||||
// --- PROPS ---
|
||||
let {
|
||||
@@ -33,6 +34,7 @@
|
||||
|
||||
// Cargar datos al abrir
|
||||
$effect(() => {
|
||||
console.log("CountrySelectorDialog: open changed", open);
|
||||
if (open) {
|
||||
loadCountries();
|
||||
}
|
||||
@@ -40,10 +42,17 @@
|
||||
|
||||
async function loadCountries() {
|
||||
loading = true;
|
||||
console.log("Cargando países...");
|
||||
console.log("CountrySelectorDialog: loading countries...");
|
||||
try {
|
||||
const response = await countriesApi.list(1, 300);
|
||||
// FIX: Reducir tamaño de página para evitar timeouts y manejo de errores
|
||||
const response = await countriesApi.list(1, 100);
|
||||
console.log("Respuesta países FULL:", response);
|
||||
|
||||
if (response.error) {
|
||||
console.error("Error API:", response.error);
|
||||
toast.error(`Error al cargar países: ${response.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Caso 1: Estructura esperada { data: { items: [...] } }
|
||||
if (response.data?.items && Array.isArray(response.data.items)) {
|
||||
@@ -69,16 +78,19 @@
|
||||
loaded = true;
|
||||
} else {
|
||||
console.warn("Estructura de datos no reconocida en countriesApi.list:", response.data);
|
||||
toast.error("Formato de datos de países no reconocido");
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.warn("No se encontraron países o formato incorrecto:", response);
|
||||
toast.error("No se encontraron países");
|
||||
}
|
||||
|
||||
console.log(`Países cargados: ${items.length}`);
|
||||
|
||||
} catch (e) {
|
||||
console.error("Error cargando países:", e);
|
||||
} catch (e: any) {
|
||||
console.error("Error cargando países (excepción):", e);
|
||||
toast.error(`Excepción al cargar países: ${e.message || e}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import * as Table from "$lib/components/ui/table";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { Search, Loader2, DollarSign } from "lucide-svelte";
|
||||
import { getMultiCurrencyTypes, type MultiCurrencyType } from "$lib/api/dashboard/a76/general_catalogs/multi-currency-types";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import { currencyTypesApi, type CurrencyType } from "$lib/api/dashboard/refrence_data/currency_types";
|
||||
|
||||
// --- PROPS ---
|
||||
let {
|
||||
@@ -13,11 +13,11 @@
|
||||
onSelect
|
||||
}: {
|
||||
open: boolean,
|
||||
onSelect: (item: MultiCurrencyType) => void
|
||||
onSelect: (item: CurrencyType) => void
|
||||
} = $props();
|
||||
|
||||
// --- ESTADO ---
|
||||
let items = $state<MultiCurrencyType[]>([]);
|
||||
let items = $state<CurrencyType[]>([]);
|
||||
let loading = $state(false);
|
||||
let searchTerm = $state("");
|
||||
let loaded = $state(false);
|
||||
@@ -25,39 +25,50 @@
|
||||
// Filtro local
|
||||
let filteredItems = $derived(
|
||||
items.filter(i =>
|
||||
(i.currency_type_code || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(i.country_key || "").toLowerCase().includes(searchTerm.toLowerCase())
|
||||
(i.code || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(i.currency_name || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(i.country_description || "").toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
// Cargar datos al abrir
|
||||
$effect(() => {
|
||||
if (open && !loaded && companyStore.activeCompany?.id) {
|
||||
if (open && !loaded) {
|
||||
loadCurrencies();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadCurrencies() {
|
||||
if (!companyStore.activeCompany?.id) return;
|
||||
|
||||
loading = true;
|
||||
console.log("CurrencySelectorDialog: loading currencies (public)...");
|
||||
try {
|
||||
const response = await getMultiCurrencyTypes(companyStore.activeCompany.id, 1, 100);
|
||||
// FIX: Usar API pública, sin company_id
|
||||
const response = await currencyTypesApi.list(1, 100);
|
||||
console.log("Respuesta Monedas Public FULL:", response);
|
||||
|
||||
if (response?.items) {
|
||||
items = response.items;
|
||||
loaded = true;
|
||||
} else {
|
||||
console.warn("No se encontraron monedas:", response);
|
||||
if (response.error) {
|
||||
console.error("CurrencySelectorDialog Error:", response.error);
|
||||
toast.error(`Error al cargar monedas: ${response.error}`);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
if (response.data?.items) {
|
||||
items = response.data.items;
|
||||
loaded = true;
|
||||
console.log("CurrencySelectorDialog: loaded items", items.length);
|
||||
} else {
|
||||
console.warn("No se encontraron monedas (public):", response);
|
||||
toast.error("No se encontraron monedas");
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error("Error cargando monedas:", e);
|
||||
toast.error(`Excepción al cargar monedas: ${e.message || e}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(item: MultiCurrencyType) {
|
||||
function handleSelect(item: CurrencyType) {
|
||||
if (onSelect) onSelect(item);
|
||||
open = false;
|
||||
}
|
||||
@@ -68,7 +79,7 @@
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Seleccionar Moneda</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Seleccione el tipo de moneda del catálogo.
|
||||
Seleccione el tipo de moneda del catálogo público.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
@@ -76,7 +87,7 @@
|
||||
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Buscar por código o país..."
|
||||
placeholder="Buscar por código, nombre o país..."
|
||||
class="pl-9"
|
||||
bind:value={searchTerm}
|
||||
/>
|
||||
@@ -96,9 +107,9 @@
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[120px]">Código</Table.Head>
|
||||
<Table.Head class="w-[100px]">País</Table.Head>
|
||||
<Table.Head class="text-right">Factor Conversión</Table.Head>
|
||||
<Table.Head class="w-[100px]">Código</Table.Head>
|
||||
<Table.Head>Moneda</Table.Head>
|
||||
<Table.Head>País</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
@@ -111,15 +122,15 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<DollarSign class="h-3 w-3 text-green-500" />
|
||||
<span class="font-mono font-bold text-primary">
|
||||
{item.currency_type_code}
|
||||
{item.code}
|
||||
</span>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">
|
||||
{item.country_key || '-'}
|
||||
{item.currency_name}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right font-mono text-sm">
|
||||
{item.conversion_factor?.toFixed(4) || '-'}
|
||||
<Table.Cell class="text-sm text-muted-foreground">
|
||||
{item.country_description || '-'}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import * as Table from "$lib/components/ui/table";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { Search, Loader2, Hash } from "lucide-svelte";
|
||||
import { classesApi, type A76Class } from "$lib/api/dashboard/a76/classes";
|
||||
import { getTariffFractions, type TariffFraction } from "$lib/api/dashboard/a76/general_catalogs/tariff-fractions";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
|
||||
// --- PROPS ---
|
||||
@@ -13,32 +14,23 @@
|
||||
onSelect
|
||||
}: {
|
||||
open: boolean,
|
||||
onSelect: (item: { fraction: string; description: string; class_code: string }) => void
|
||||
onSelect: (item: TariffFraction) => void
|
||||
} = $props();
|
||||
|
||||
// --- ESTADO ---
|
||||
let classes = $state<A76Class[]>([]);
|
||||
let items = $state<TariffFraction[]>([]);
|
||||
let loading = $state(false);
|
||||
let searchTerm = $state("");
|
||||
let loaded = $state(false);
|
||||
|
||||
// Extraer fracciones únicas
|
||||
let uniqueFractions = $derived(
|
||||
Array.from(new Set(classes.map(c => c.fraction)))
|
||||
.filter(f => f && f.trim())
|
||||
.map(fraction => {
|
||||
const cls = classes.find(c => c.fraction === fraction);
|
||||
return {
|
||||
fraction,
|
||||
description: cls?.description_es || '',
|
||||
class_code: cls?.class_code || ''
|
||||
};
|
||||
})
|
||||
.filter(item =>
|
||||
item.fraction.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.class_code.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
// Filtro local
|
||||
let filteredItems = $derived(
|
||||
items.filter(i =>
|
||||
(i.fraction || "").includes(searchTerm) ||
|
||||
(i.description || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(i.nico || "").includes(searchTerm) ||
|
||||
(i.code || "").toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
// Cargar datos al abrir
|
||||
@@ -49,41 +41,48 @@
|
||||
});
|
||||
|
||||
async function loadFractions() {
|
||||
if (!companyStore.activeCompany?.id) return;
|
||||
if (!companyStore.activeCompany?.id) {
|
||||
toast.error("No hay empresa seleccionada");
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const response = await classesApi.list({
|
||||
company_id: companyStore.activeCompany.id,
|
||||
page: 1,
|
||||
page_size: 1000
|
||||
});
|
||||
const response = await getTariffFractions(1, 1000, companyStore.activeCompany.id);
|
||||
|
||||
if (response.error) {
|
||||
console.error("Error al cargar fracciones:", response.error);
|
||||
toast.error(`Error: ${response.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data?.items) {
|
||||
classes = response.data.items;
|
||||
items = response.data.items;
|
||||
loaded = true;
|
||||
} else {
|
||||
console.warn("No se encontraron clases:", response);
|
||||
console.warn("No se encontraron fracciones:", response);
|
||||
toast.info("No se encontraron fracciones registradas");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error cargando fracciones:", e);
|
||||
} catch (e: any) {
|
||||
console.error("Excepción cargando fracciones:", e);
|
||||
toast.error(`Error de conexión: ${e.message || e}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(item: { fraction: string; description: string; class_code: string }) {
|
||||
function handleSelect(item: TariffFraction) {
|
||||
if (onSelect) onSelect(item);
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={open}>
|
||||
<Dialog.Content class="sm:max-w-[800px] max-h-[80vh] flex flex-col">
|
||||
<Dialog.Content class="sm:max-w-[900px] max-h-[80vh] flex flex-col">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Seleccionar Fracción Arancelaria</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Seleccione la fracción arancelaria del catálogo de clases.
|
||||
Seleccione la fracción arancelaria del catálogo.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
@@ -91,7 +90,7 @@
|
||||
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Buscar por fracción, clase o descripción..."
|
||||
placeholder="Buscar por fracción, NICO o descripción..."
|
||||
class="pl-9"
|
||||
bind:value={searchTerm}
|
||||
/>
|
||||
@@ -103,7 +102,7 @@
|
||||
<Loader2 class="h-8 w-8 animate-spin text-primary" />
|
||||
<p>Cargando catálogo...</p>
|
||||
</div>
|
||||
{:else if uniqueFractions.length === 0}
|
||||
{:else if filteredItems.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
||||
<p>No se encontraron fracciones.</p>
|
||||
</div>
|
||||
@@ -111,17 +110,21 @@
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[150px]">Fracción</Table.Head>
|
||||
<Table.Head class="w-[100px]">Código</Table.Head>
|
||||
<Table.Head class="w-[120px]">Fracción</Table.Head>
|
||||
<Table.Head class="w-[80px]">NICO</Table.Head>
|
||||
<Table.Head>Descripción</Table.Head>
|
||||
<Table.Head class="w-[120px]">Clase</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each uniqueFractions as item}
|
||||
{#each filteredItems as item}
|
||||
<Table.Row
|
||||
class="cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onclick={() => handleSelect(item)}
|
||||
>
|
||||
<Table.Cell class="font-mono text-xs text-muted-foreground">
|
||||
{item.code}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-2">
|
||||
<Hash class="h-3 w-3 text-orange-500" />
|
||||
@@ -130,14 +133,12 @@
|
||||
</span>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-mono text-sm">
|
||||
{item.nico || '-'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium text-sm">
|
||||
{item.description || '-'}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span class="inline-flex items-center rounded-md bg-blue-50 dark:bg-blue-900/30 px-2 py-1 text-xs font-mono font-bold text-blue-700 dark:text-blue-400 border border-blue-200 dark:border-blue-800">
|
||||
{item.class_code}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
@@ -147,7 +148,7 @@
|
||||
|
||||
<Dialog.Footer>
|
||||
<div class="text-xs text-muted-foreground self-center mr-auto">
|
||||
{uniqueFractions.length} fracciones únicas encontradas
|
||||
{filteredItems.length} registros encontrados
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => open = false}>Cancelar</Button>
|
||||
</Dialog.Footer>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
FileText, Settings, Image as ImageIcon, FolderSearch,
|
||||
UserCheck, CheckCircle2, XCircle, Tag, Layers, Scale, Info, Briefcase, ShieldCheck, Globe
|
||||
} from 'lucide-svelte';
|
||||
import { toast } from "svelte-sonner";
|
||||
|
||||
// Stores & APIs
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
@@ -78,7 +79,7 @@
|
||||
weight_type: 'KG',
|
||||
unit_cost: 0,
|
||||
currency_type: '',
|
||||
currency_key: null,
|
||||
currency_key: null as string | null,
|
||||
added_value: 0,
|
||||
value_added_type: 'USD',
|
||||
us_fraction: '',
|
||||
@@ -144,6 +145,10 @@
|
||||
sector: d.fa_data?.sector || '',
|
||||
fraction_type: d.fa_data?.fraction_type || ''
|
||||
};
|
||||
// Ensure currency_type is mapped correctly if coming from DB (optional, depending on DB values)
|
||||
if (d.currency_key === 'MXN') formData.currency_type = 'NA';
|
||||
else if (d.currency_key === 'USD') formData.currency_type = 'EX';
|
||||
|
||||
if (d.client_id) await fetchClientName(d.client_id, companyId);
|
||||
if (d.part_class) await fetchClassDesc(d.part_class, companyId);
|
||||
if (d.inv_data?.material_type) await fetchMaterialName(d.inv_data.material_type);
|
||||
@@ -151,6 +156,18 @@
|
||||
} catch (e) { console.error(e); } finally { loading = false; }
|
||||
}
|
||||
|
||||
// --- EFECTOS REACTIVOS ---
|
||||
$effect(() => {
|
||||
// Auto-set currency based on type selection
|
||||
if (formData.currency_type === 'NA') {
|
||||
formData.currency_key = 'MXN';
|
||||
selectedCurrencyName = 'MXN';
|
||||
} else if (formData.currency_type === 'EX') {
|
||||
formData.currency_key = 'USD';
|
||||
selectedCurrencyName = 'USD';
|
||||
}
|
||||
});
|
||||
|
||||
// --- HELPERS VISUALES ---
|
||||
async function fetchClientName(clientId: number, companyId: number) {
|
||||
try {
|
||||
@@ -192,11 +209,17 @@
|
||||
function handleUOMSelect(item: any) { formData.unit_of_measure = item.code; }
|
||||
function handleAltUOMSelect(item: any) { formData.alternate_unit_measure = item.code; }
|
||||
function handleCurrencySelect(currency: any) {
|
||||
formData.currency_type = '';
|
||||
formData.currency_key = currency.currency_type_code;
|
||||
selectedCurrencyName = currency.currency_type_code;
|
||||
formData.currency_type = ''; // Reset type legacy field
|
||||
// FIX: Usar 'code' de la API pública currency_types
|
||||
const code = currency.code || currency.currency_type_code;
|
||||
formData.currency_key = code;
|
||||
selectedCurrencyName = code;
|
||||
}
|
||||
function handleCountrySelect(country: any) {
|
||||
// FIX: Asegurar que se asigna la clave correcta
|
||||
formData.origin_country = country.m3_key || country.country_key;
|
||||
selectedCountryName = country.description_es;
|
||||
}
|
||||
function handleCountrySelect(country: any) { formData.origin_country = country.m3_key; selectedCountryName = country.description_es; }
|
||||
function handleFractionSelect(item: any) { formData.fraction = item.fraction; }
|
||||
|
||||
// --- SUBMIT ---
|
||||
@@ -224,15 +247,28 @@
|
||||
delete commonData.origin_country;
|
||||
}
|
||||
|
||||
console.log("Submitting Part Data:", {
|
||||
isEdit,
|
||||
partId,
|
||||
commonData
|
||||
});
|
||||
|
||||
if (isEdit && partId) {
|
||||
await partsApi.update(partId, commonData, activeCompanyId);
|
||||
// TODO: Verify partId is number/string as expected
|
||||
const res = await partsApi.update(Number(partId), commonData, activeCompanyId);
|
||||
console.log("Update Response:", res);
|
||||
if (res.error) throw new Error(res.error);
|
||||
} else {
|
||||
const result = await partsApi.create({ ...commonData, company_id: activeCompanyId }, activeCompanyId);
|
||||
console.log("Create Response:", result);
|
||||
if (result.error) { error = result.error; return; }
|
||||
}
|
||||
toast.success(isEdit ? "Parte actualizada" : "Parte creada");
|
||||
goto('/dashboard/goods/parts');
|
||||
} catch (e: any) {
|
||||
console.error("Submit Error:", e);
|
||||
error = e.message || 'Error al guardar';
|
||||
toast.error(error);
|
||||
} finally { loading = false; }
|
||||
}
|
||||
</script>
|
||||
@@ -527,7 +563,7 @@
|
||||
<div class="flex gap-2">
|
||||
<div class="relative w-full">
|
||||
<Tag class="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input id="part_class_client" bind:value={formData.part_class} maxlength={8} placeholder="Seleccione Clase..." class="pl-9 font-mono cursor-pointer" readonly onclick={() => showClassModal = true}/>
|
||||
<Input id="part_class_client" bind:value={formData.part_class} maxlength={15} placeholder="Clase..." class="pl-9 font-mono"/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showClassModal = true} class="shrink-0"><FolderSearch class="h-4 w-4" /></Button>
|
||||
</div>
|
||||
|
||||
@@ -14,10 +14,36 @@ function formatDate(date?: string | null): string {
|
||||
}
|
||||
|
||||
export function createColumns(
|
||||
onSuccess?: () => void,
|
||||
onDownload?: (invoice: Invoice) => void
|
||||
onSuccess?: () => void
|
||||
): ColumnDef<Invoice>[] {
|
||||
return [
|
||||
// 0. NUEVA COLUMNA: Checkbox visual (el estado real lo maneja la opacidad)
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
return renderSnippet(
|
||||
createRawSnippet(() => ({
|
||||
render: () => `<div class="w-4"></div>`
|
||||
}))
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const isSelected = row.getIsSelected();
|
||||
|
||||
const checkboxSnippet = createRawSnippet<[{ selected: boolean }]>((getProps) => {
|
||||
const { selected } = getProps();
|
||||
return {
|
||||
render: () => `<div class="flex items-center justify-center">
|
||||
<input type="checkbox" class="h-4 w-4" ${selected ? 'checked' : ''} />
|
||||
</div>`
|
||||
};
|
||||
});
|
||||
|
||||
return renderSnippet(checkboxSnippet, { selected: isSelected });
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "operation_type",
|
||||
header: "Operación",
|
||||
@@ -274,8 +300,7 @@ export function createColumns(
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
invoice: row.original,
|
||||
onSuccess,
|
||||
onDownload
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,17 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
// 1. Agregamos FileDown a los imports
|
||||
import { Ellipsis, Eye, Pencil, Trash2, FileDown } from 'lucide-svelte';
|
||||
import { Ellipsis, Eye, Pencil, Trash2 } from 'lucide-svelte';
|
||||
import DetailsDialog from './details-dialog.svelte';
|
||||
import DeleteDialog from './delete-dialog.svelte';
|
||||
|
||||
interface Props {
|
||||
invoice: Invoice;
|
||||
onSuccess?: () => void;
|
||||
// 2. Definimos la nueva prop (opcional para que no rompa si no se pasa)
|
||||
onDownload?: (invoice: Invoice) => void;
|
||||
}
|
||||
|
||||
// 3. Desestructuramos onDownload de los props
|
||||
let { invoice, onSuccess, onDownload }: Props = $props();
|
||||
let { invoice, onSuccess }: Props = $props();
|
||||
|
||||
let showDetails = $state(false);
|
||||
let showDelete = $state(false);
|
||||
@@ -44,12 +42,6 @@
|
||||
Ver Detalles
|
||||
</DropdownMenu.Item>
|
||||
|
||||
{#if onDownload}
|
||||
<DropdownMenu.Item onclick={() => onDownload(invoice)}>
|
||||
<FileDown class="mr-2 h-4 w-4" />
|
||||
Descargar PDF
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Item onclick={handleEdit}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
loading: boolean;
|
||||
hasMore: boolean;
|
||||
loadMore: () => void;
|
||||
// Props para selección
|
||||
selectedId?: number | null;
|
||||
onRowClick?: (row: TData) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -20,7 +23,9 @@
|
||||
columns,
|
||||
loading,
|
||||
hasMore,
|
||||
loadMore
|
||||
loadMore,
|
||||
selectedId = null,
|
||||
onRowClick
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
@@ -28,7 +33,17 @@
|
||||
return data;
|
||||
},
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel()
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row: any) => row.id?.toString(), // Usar ID para identificar filas
|
||||
state: {
|
||||
get rowSelection() {
|
||||
// Mapear el ID seleccionado al formato que espera TanStack Table
|
||||
return selectedId ? { [selectedId]: true } : {};
|
||||
}
|
||||
},
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: false, // Solo permitir una selección a la vez
|
||||
// No necesitamos onRowSelectionChange porque controlamos el estado desde fuera
|
||||
});
|
||||
|
||||
let scrollContainer = $state<HTMLDivElement>();
|
||||
@@ -80,7 +95,11 @@
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
<Table.Row
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
class="cursor-pointer transition-colors {row.getIsSelected() ? 'bg-gray-300 dark:bg-gray-600' : 'hover:bg-gray-100 dark:hover:bg-gray-700'}"
|
||||
onclick={() => onRowClick && onRowClick(row.original)}
|
||||
>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { FileDown, LoaderCircle } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { invoicesReportsApi } from '$lib/api/dashboard/a76/reports/reports-invoices';
|
||||
|
||||
export let invoiceId: number;
|
||||
export let companyId: number;
|
||||
|
||||
let processing = false;
|
||||
|
||||
async function startDownload() {
|
||||
if (processing) return;
|
||||
|
||||
processing = true;
|
||||
const toastId = toast.loading('Iniciando generación de PDF...');
|
||||
|
||||
try {
|
||||
const { task_id } = await invoicesReportsApi.triggerPdfGeneration(invoiceId, companyId);
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const statusData = await invoicesReportsApi.getTaskStatus(task_id);
|
||||
|
||||
if (statusData.state === 'SUCCESS') {
|
||||
clearInterval(pollInterval);
|
||||
toast.success('Factura generada correctamente', { id: toastId });
|
||||
|
||||
const { content, file_name, media_type } = statusData.result;
|
||||
downloadBase64File(content, media_type, file_name);
|
||||
|
||||
processing = false;
|
||||
|
||||
} else if (statusData.state === 'FAILURE') {
|
||||
clearInterval(pollInterval);
|
||||
throw new Error(statusData.result || 'Error desconocido');
|
||||
|
||||
} else if (statusData.state === 'PROCESSING') {
|
||||
const meta = statusData.result;
|
||||
if (meta && typeof meta === 'object') {
|
||||
const current = meta.current || 0;
|
||||
const total = meta.total || 100;
|
||||
const progress = Math.round((current / total) * 100);
|
||||
// Update toast with progress
|
||||
toast.loading(`Generando PDF: ${progress}%`, {
|
||||
id: toastId,
|
||||
description: meta.status || 'Procesando...'
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
clearInterval(pollInterval);
|
||||
handleError(err, toastId);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
} catch (err: any) {
|
||||
handleError(err, toastId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleError(err: any, toastId: string | number) {
|
||||
processing = false;
|
||||
console.error(err);
|
||||
toast.error('Error al generar PDF: ' + (err.message || 'Error desconocido'), { id: toastId });
|
||||
}
|
||||
|
||||
function downloadBase64File(base64Data: string, contentType: string, fileName: string) {
|
||||
const linkSource = `data:${contentType};base64,${base64Data}`;
|
||||
const downloadLink = document.createElement("a");
|
||||
downloadLink.href = linkSource;
|
||||
downloadLink.download = fileName;
|
||||
downloadLink.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={startDownload}
|
||||
disabled={processing}
|
||||
class="w-[100px]"
|
||||
>
|
||||
{#if processing}
|
||||
<LoaderCircle size={16} class="mr-2 animate-spin" />
|
||||
PDF
|
||||
{:else}
|
||||
<FileDown size={16} class="mr-2" />
|
||||
PDF
|
||||
{/if}
|
||||
</Button>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Progress } from "$lib/components/ui/progress";
|
||||
import { invoicesReportsApi } from "$lib/api/dashboard/a76/reports/reports-invoices";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { Loader2, CheckCircle2, XCircle, FileDown } from "lucide-svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
|
||||
export let open = false;
|
||||
export let taskId: string | null = null;
|
||||
export let onClose: () => void;
|
||||
export let onComplete: (result: any) => void;
|
||||
|
||||
let progress = 0;
|
||||
let statusMessage = "Iniciando...";
|
||||
let pollingInterval: any = null;
|
||||
let isComplete = false;
|
||||
let hasError = false;
|
||||
|
||||
// Reiniciar estado cuando se abre el diálogo con un nuevo taskId
|
||||
$: if (open && taskId) {
|
||||
progress = 0;
|
||||
statusMessage = "Iniciando...";
|
||||
isComplete = false;
|
||||
hasError = false;
|
||||
startPolling();
|
||||
} else if (!open) {
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollingInterval) {
|
||||
clearInterval(pollingInterval);
|
||||
pollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function startPolling() {
|
||||
stopPolling(); // Asegurar limpieza previa
|
||||
|
||||
pollingInterval = setInterval(async () => {
|
||||
if (!taskId) return;
|
||||
|
||||
try {
|
||||
const response = await invoicesReportsApi.getTaskStatus(taskId);
|
||||
|
||||
if (response.state === 'PROCESSING' && response.info) {
|
||||
progress = response.info.current || 0;
|
||||
statusMessage = response.info.status || "Procesando...";
|
||||
}
|
||||
else if (response.state === 'SUCCESS') {
|
||||
progress = 100;
|
||||
statusMessage = "¡Completado!";
|
||||
isComplete = true;
|
||||
stopPolling();
|
||||
// Pequeña pausa para ver el 100%
|
||||
setTimeout(() => {
|
||||
onComplete(response.result);
|
||||
}, 500);
|
||||
}
|
||||
else if (response.state === 'FAILURE') {
|
||||
hasError = true;
|
||||
statusMessage = "Error al generar el PDF";
|
||||
stopPolling();
|
||||
toast.error("Falló la generación del PDF");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error polling task status:", error);
|
||||
// No detenemos el polling inmediatamente por un error de red transitorio,
|
||||
// pero podríamos contar intentos fallidos si fuera necesario.
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
if (!newOpen) {
|
||||
stopPolling();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={open} onOpenChange={handleOpenChange}>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Generando PDF</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Por favor espere mientras se genera su documento.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="py-6 flex flex-col gap-6">
|
||||
<div class="flex items-center justify-between text-sm mb-1">
|
||||
<span class="text-muted-foreground">{statusMessage}</span>
|
||||
<span class="font-medium">{progress}%</span>
|
||||
</div>
|
||||
|
||||
<Progress value={progress} class="w-full h-2" />
|
||||
|
||||
<div class="flex justify-center items-center h-16">
|
||||
{#if isComplete}
|
||||
<div class="flex flex-col items-center text-green-600 animate-in fade-in zoom-in duration-300">
|
||||
<CheckCircle2 size={48} />
|
||||
<span class="text-sm font-medium mt-2">Listo para descargar</span>
|
||||
</div>
|
||||
{:else if hasError}
|
||||
<div class="flex flex-col items-center text-destructive animate-in fade-in zoom-in duration-300">
|
||||
<XCircle size={48} />
|
||||
<span class="text-sm font-medium mt-2">Ocurrió un error</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center text-primary animate-pulse">
|
||||
<FileDown size={48} class="opacity-50" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
{#if hasError}
|
||||
<Button variant="secondary" on:click={onClose}>Cerrar</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -26,7 +26,7 @@
|
||||
>
|
||||
{#if companyStore.activeCompany?.logo}
|
||||
<img
|
||||
src={companyStore.activeCompany.logo}
|
||||
src={`${(import.meta.env.VITE_API_URL || '').replace(/\/+$/, '')}/v1/a76/company/${companyStore.activeCompany.id}/logo/image?t=${new Date().getTime()}`}
|
||||
alt={companyStore.activeCompany.name}
|
||||
class="size-full rounded-lg object-cover"
|
||||
/>
|
||||
@@ -75,7 +75,7 @@
|
||||
<div class="flex size-6 items-center justify-center rounded-md border">
|
||||
{#if company.logo}
|
||||
<img
|
||||
src={company.logo}
|
||||
src={`${(import.meta.env.VITE_API_URL || '').replace(/\/+$/, '')}/v1/a76/company/${company.id}/logo/image?t=${new Date().getTime()}`}
|
||||
alt={company.name}
|
||||
class="size-full rounded object-cover"
|
||||
/>
|
||||
|
||||
2
frontend/src/lib/components/ui/progress/index.ts
Normal file
2
frontend/src/lib/components/ui/progress/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
export { default as Progress } from "./progress.svelte";
|
||||
27
frontend/src/lib/components/ui/progress/progress.svelte
Normal file
27
frontend/src/lib/components/ui/progress/progress.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
export let value = 0;
|
||||
export let max = 100;
|
||||
let className: string | undefined = undefined;
|
||||
export { className as class };
|
||||
|
||||
$: percentage = (value / max) * 100;
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
class={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<div
|
||||
class="h-full w-full flex-1 bg-primary transition-all"
|
||||
style="transform: translateX(-{100 - (percentage || 0)}%)"
|
||||
></div>
|
||||
</div>
|
||||
@@ -12,19 +12,19 @@ import { redirect, type Cookies } from '@sveltejs/kit';
|
||||
export function getServerApiUrl(): string {
|
||||
// Primero intentar con INTERNAL_API_URL (para llamadas server-side en Docker)
|
||||
let apiUrl = process.env.INTERNAL_API_URL;
|
||||
|
||||
|
||||
// Si no está definida, usar VITE_API_URL del entorno runtime (no import.meta.env)
|
||||
if (!apiUrl) {
|
||||
apiUrl = process.env.VITE_API_URL;
|
||||
}
|
||||
|
||||
|
||||
// Como último recurso, usar el valor de build-time
|
||||
if (!apiUrl) {
|
||||
apiUrl = import.meta.env.VITE_API_URL;
|
||||
// Reemplazar 'localhost' con 'backend' para llamadas desde el servidor (SSR)
|
||||
apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend').replace('anexo76-dev.aduanasoft.com', 'backend');
|
||||
}
|
||||
|
||||
|
||||
// Normalizar la URL: asegurar que termine con '/'
|
||||
return apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`;
|
||||
}
|
||||
@@ -43,8 +43,8 @@ export function getAuthTokens(cookies: Cookies) {
|
||||
* Establece los tokens de autenticación en las cookies
|
||||
*/
|
||||
export function setAuthTokens(
|
||||
cookies: Cookies,
|
||||
accessToken: string,
|
||||
cookies: Cookies,
|
||||
accessToken: string,
|
||||
refreshToken?: string
|
||||
) {
|
||||
cookies.set('access_token', accessToken, {
|
||||
@@ -54,7 +54,7 @@ export function setAuthTokens(
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60 * 24 * 7 // 7 días
|
||||
});
|
||||
|
||||
|
||||
if (refreshToken) {
|
||||
cookies.set('refresh_token', refreshToken, {
|
||||
path: '/',
|
||||
@@ -95,7 +95,7 @@ export async function refreshAccessToken(
|
||||
fetch: typeof globalThis.fetch
|
||||
): Promise<string | null> {
|
||||
const { refreshToken } = getAuthTokens(cookies);
|
||||
|
||||
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
}
|
||||
@@ -115,10 +115,10 @@ export async function refreshAccessToken(
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
// Actualizar las cookies con los nuevos tokens
|
||||
setAuthTokens(cookies, data.access_token, data.refresh_token);
|
||||
|
||||
|
||||
return data.access_token;
|
||||
} catch (error) {
|
||||
console.error('🔄 [API] Error al refrescar token:', error);
|
||||
@@ -210,9 +210,9 @@ export async function authenticatedFetch(
|
||||
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
console.error('🔴 [API] Error en authenticatedFetch:', endpoint, error);
|
||||
|
||||
|
||||
// Retornar una respuesta de error simulada en lugar de lanzar
|
||||
return new Response(JSON.stringify({ error: 'Network error', details: String(error) }), {
|
||||
status: 500,
|
||||
@@ -253,14 +253,14 @@ export async function validateAuth(
|
||||
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
console.error('🔐 [API] Error validando autenticación:', error);
|
||||
|
||||
|
||||
if (redirectOnFail) {
|
||||
clearAuthTokens(cookies);
|
||||
throw redirect(303, redirectOnFail);
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@
|
||||
createCompany,
|
||||
updateCompany,
|
||||
getCompany, // Asumiendo que esta función existe en tu API
|
||||
uploadCompanyLogo,
|
||||
type Company
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/company';
|
||||
import { ArrowLeft, LoaderCircle, Save } from 'lucide-svelte';
|
||||
import { ArrowLeft, LoaderCircle, Save, Upload } from 'lucide-svelte';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
// 1. Lógica de Navegación y Modo
|
||||
const id = $derived($page.params.id);
|
||||
@@ -21,9 +23,10 @@
|
||||
const title = $derived(isEdit ? "Editar Empresa" : "Nueva Empresa");
|
||||
|
||||
let loading = $state(false);
|
||||
let uploading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// 2. Estado Inicial (Reset)
|
||||
// ... (Initial Data) ...
|
||||
const initialData = {
|
||||
name: '',
|
||||
rfc: '',
|
||||
@@ -43,12 +46,20 @@
|
||||
is_service_company: false,
|
||||
order_format_type: '',
|
||||
ctpat_svi: '',
|
||||
trusted_exporter_number: ''
|
||||
trusted_exporter_number: '',
|
||||
logo: '',
|
||||
previous_code: 0,
|
||||
client_name: '',
|
||||
subassembly_mode: '',
|
||||
broker_company: '',
|
||||
inter_db_name: '',
|
||||
prevalidator_key: '',
|
||||
seventh_amendment: false
|
||||
};
|
||||
|
||||
let formData = $state({ ...initialData });
|
||||
|
||||
// 3. Efecto para "Heredar" datos o Limpiar
|
||||
// ... (Fetch Data) ...
|
||||
$effect(() => {
|
||||
if (isEdit) {
|
||||
fetchData(id);
|
||||
@@ -61,7 +72,6 @@
|
||||
async function fetchData(companyId: string) {
|
||||
loading = true;
|
||||
try {
|
||||
// Nota: Aquí usamos tu API para traer la info de una sola empresa
|
||||
const response = await getCompany(Number(companyId));
|
||||
if (response.data) {
|
||||
const item = response.data;
|
||||
@@ -84,7 +94,15 @@
|
||||
is_service_company: item.is_service_company || false,
|
||||
order_format_type: item.order_format_type || '',
|
||||
ctpat_svi: item.ctpat_svi || '',
|
||||
trusted_exporter_number: item.trusted_exporter_number || ''
|
||||
trusted_exporter_number: item.trusted_exporter_number || '',
|
||||
logo: item.logo || '',
|
||||
previous_code: item.previous_code || 0,
|
||||
client_name: item.client_name || '',
|
||||
subassembly_mode: item.subassembly_mode || '',
|
||||
broker_company: item.broker_company || '',
|
||||
inter_db_name: item.inter_db_name || '',
|
||||
prevalidator_key: item.prevalidator_key || '',
|
||||
seventh_amendment: item.seventh_amendment || false
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -96,6 +114,31 @@
|
||||
|
||||
const clean = (value: any) => (typeof value === 'string' ? value.trim() || null : value);
|
||||
|
||||
async function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (!input.files || input.files.length === 0) return;
|
||||
|
||||
const file = input.files[0];
|
||||
if (!isEdit) {
|
||||
alert("Primero debes guardar la empresa antes de subir un logo.");
|
||||
return;
|
||||
}
|
||||
|
||||
uploading = true;
|
||||
try {
|
||||
const res = await uploadCompanyLogo(Number(id), file);
|
||||
if (res.data) {
|
||||
formData.logo = res.data.path;
|
||||
} else if (res.error) {
|
||||
alert("Error al subir imagen: " + res.error);
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Error al intentar subir la imagen");
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
error = null;
|
||||
loading = true;
|
||||
@@ -122,7 +165,15 @@
|
||||
manufacturer_id: clean(formData.manufacturer_id),
|
||||
order_format_type: clean(formData.order_format_type),
|
||||
ctpat_svi: clean(formData.ctpat_svi),
|
||||
trusted_exporter_number: clean(formData.trusted_exporter_number)
|
||||
trusted_exporter_number: clean(formData.trusted_exporter_number),
|
||||
logo: clean(formData.logo),
|
||||
previous_code: Number(formData.previous_code) || 0,
|
||||
client_name: clean(formData.client_name),
|
||||
subassembly_mode: clean(formData.subassembly_mode),
|
||||
broker_company: clean(formData.broker_company),
|
||||
inter_db_name: clean(formData.inter_db_name),
|
||||
prevalidator_key: clean(formData.prevalidator_key),
|
||||
seventh_amendment: formData.seventh_amendment
|
||||
};
|
||||
|
||||
const response = isEdit
|
||||
@@ -131,6 +182,27 @@
|
||||
|
||||
if (response.error) throw new Error(response.error);
|
||||
|
||||
// Update global store if we are editing the active company
|
||||
if (response.data) {
|
||||
const updatedComp = response.data;
|
||||
// We verify if we are editing the currently active company
|
||||
if (companyStore.activeCompany?.id === updatedComp.id) {
|
||||
// We update the store.
|
||||
// IMPORTANT: To force image refresh, we might need a cache buster in the sidebar,
|
||||
// but updating the store object is Step 1.
|
||||
companyStore.setActiveCompany({
|
||||
id: updatedComp.id,
|
||||
name: updatedComp.name || '',
|
||||
rfc: updatedComp.rfc || '',
|
||||
logo: updatedComp.logo || '',
|
||||
tenant_id: updatedComp.tenant_id
|
||||
});
|
||||
|
||||
// Force reload of company list to ensure integrity
|
||||
companyStore.loadCompanies();
|
||||
}
|
||||
}
|
||||
|
||||
goto('/dashboard/general_catalogs/company_information');
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Error al guardar';
|
||||
@@ -181,6 +253,37 @@
|
||||
<Label for="main_activity">Actividad Principal</Label>
|
||||
<Input id="main_activity" bind:value={formData.main_activity} />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="logo">Ruta del Logo</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input id="logo" bind:value={formData.logo} placeholder="/path/to/logo.png" />
|
||||
{#if isEdit}
|
||||
<div class="relative">
|
||||
<Button variant="outline" size="icon" disabled={uploading}>
|
||||
{#if uploading}
|
||||
<LoaderCircle class="h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Upload class="h-4 w-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="absolute inset-0 opacity-0 cursor-pointer"
|
||||
onchange={handleFileSelect}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-[0.8rem] text-muted-foreground">Sube una imagen para obtener su ruta local.</p>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="client_name">Nombre Cliente (Maquila)</Label>
|
||||
<Input id="client_name" bind:value={formData.client_name} />
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="programas" class="space-y-4 pt-4">
|
||||
@@ -204,6 +307,16 @@
|
||||
<Input id="prosec_auth" bind:value={formData.prosec_authorization} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="broker_company">Empresa Broker</Label>
|
||||
<Input id="broker_company" bind:value={formData.broker_company} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="subassembly">Modo Sub-ensamble</Label>
|
||||
<Input id="subassembly" bind:value={formData.subassembly_mode} />
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="responsable" class="space-y-4 pt-4">
|
||||
@@ -243,8 +356,24 @@
|
||||
<Switch id="service" bind:checked={formData.is_service_company} />
|
||||
<Label for="service">Es Empresa de Servicios</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 p-4 border rounded-lg">
|
||||
<Switch id="seventh" bind:checked={formData.seventh_amendment} />
|
||||
<Label for="seventh">Séptima Enmienda</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 pt-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="previous_code">Código Anterior</Label>
|
||||
<Input id="previous_code" type="number" bind:value={formData.previous_code} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="inter_db">Base de Datos Intermedia</Label>
|
||||
<Input id="inter_db" bind:value={formData.inter_db_name} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="prevalidator">Clave Prevalidador</Label>
|
||||
<Input id="prevalidator" bind:value={formData.prevalidator_key} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="man_id">Manufacturer ID (MID)</Label>
|
||||
<Input id="man_id" bind:value={formData.manufacturer_id} />
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
import { Plus, RefreshCw, Package } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { partsApi, type Part } from '$lib/api/dashboard/a76/parts';
|
||||
import { clientsProvidersApi } from '$lib/api/dashboard/a76/clients-providers';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
// Estado de la lista de partes
|
||||
let parts = $state<Part[]>([]);
|
||||
let clientsMap = $state<Record<number, string>>({}); // Mapa ID -> Nombre
|
||||
let selectedPart = $state<Part | null>(null);
|
||||
let isLoading = $state(false);
|
||||
let searchPartNumber = $state('');
|
||||
@@ -28,9 +30,11 @@
|
||||
(p.description_spanish?.toLowerCase().includes(searchDescription.toLowerCase()) ?? false) ||
|
||||
(p.description_english?.toLowerCase().includes(searchDescription.toLowerCase()) ?? false);
|
||||
|
||||
// Filtro por cliente
|
||||
// Filtro por cliente (Busca en nombre o ID)
|
||||
const clientName = clientsMap[p.client_id] || '';
|
||||
const matchesClient = !searchClient ||
|
||||
(p.client_id?.toString().includes(searchClient) ?? false);
|
||||
(p.client_id?.toString().includes(searchClient) ?? false) ||
|
||||
clientName.toLowerCase().includes(searchClient.toLowerCase());
|
||||
|
||||
// Filtro por clase
|
||||
const matchesClass = !searchClass ||
|
||||
@@ -44,10 +48,40 @@
|
||||
$effect(() => {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (companyId) {
|
||||
loadParts();
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
await Promise.all([loadParts(), loadClients()]);
|
||||
}
|
||||
|
||||
async function loadClients() {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) return;
|
||||
|
||||
try {
|
||||
// Fetch all clients/providers to ensure we map "both" types as well
|
||||
const response = await clientsProvidersApi.list(
|
||||
companyId,
|
||||
1,
|
||||
1000
|
||||
);
|
||||
|
||||
const data = (response as any).data || response;
|
||||
const items = data.items || [];
|
||||
|
||||
const map: Record<number, string> = {};
|
||||
items.forEach((c: any) => {
|
||||
map[c.id] = c.name;
|
||||
});
|
||||
clientsMap = map;
|
||||
|
||||
} catch (e) {
|
||||
console.error("Error cargando clientes:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadParts() {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
@@ -78,17 +112,35 @@
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
await loadParts();
|
||||
await loadData();
|
||||
toast.success('Partes actualizadas');
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
|
||||
async function handleDelete() {
|
||||
if (!selectedPart) {
|
||||
toast.error('Selecciona una parte para borrar');
|
||||
return;
|
||||
}
|
||||
// TODO: Implementar eliminación
|
||||
toast.info('Función de eliminación pendiente');
|
||||
|
||||
const confirmed = window.confirm(`¿Estás seguro de que deseas eliminar la parte ${selectedPart.part_number}? Esta acción no se puede deshacer.`);
|
||||
if (!confirmed) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) return;
|
||||
|
||||
await partsApi.delete(selectedPart.id, companyId);
|
||||
toast.success('Parte eliminada exitosamente');
|
||||
selectedPart = null;
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
console.error("Error al eliminar:", e);
|
||||
toast.error('Error al eliminar la parte');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -135,7 +187,7 @@
|
||||
<Label class="text-xs">Cliente</Label>
|
||||
<Input
|
||||
bind:value={searchClient}
|
||||
placeholder="ID de cliente..."
|
||||
placeholder="Nombre o ID..."
|
||||
class="h-9"
|
||||
/>
|
||||
</div>
|
||||
@@ -215,7 +267,12 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-2 py-1 font-medium text-sm">{part.description_spanish || ''}</td>
|
||||
<td class="px-2 py-1 text-muted-foreground">{part.client_id || '-'}</td>
|
||||
<td class="px-2 py-1 text-muted-foreground">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-xs">{clientsMap[part.client_id] || 'Cargando...'}</span>
|
||||
<span class="text-[10px] opacity-70">ID: {part.client_id}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-2 py-1">
|
||||
{#if part.part_class}
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400">
|
||||
@@ -263,7 +320,7 @@
|
||||
<Label class="text-[10px] uppercase text-muted-foreground font-bold">Cliente</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Package class="h-3 w-3 text-blue-500" />
|
||||
<span class="text-sm font-bold">{selectedPart.client_id || '-'}</span>
|
||||
<span class="text-sm font-bold">{clientsMap[selectedPart.client_id] || selectedPart.client_id}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
import type { PageData } from './$types';
|
||||
import { browser } from '$app/environment';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { Plus, RefreshCw } from 'lucide-svelte';
|
||||
import { Plus, RefreshCw, FileDown, RotateCcw } from 'lucide-svelte';
|
||||
|
||||
// IMPORTANTE: Asegúrate de tener instalada svelte-sonner para las notificaciones
|
||||
import { toast } from "svelte-sonner";
|
||||
import PdfProgressDialog from '$lib/components/dashboard/invoices/pdf-progress-dialog.svelte';
|
||||
|
||||
// Los datos iniciales vienen del servidor
|
||||
let { data }: { data: PageData } = $props();
|
||||
@@ -151,6 +152,24 @@
|
||||
let loading = $state(false);
|
||||
let hasMore = $derived(allItems.length < totalItems);
|
||||
let error = $state<string | null>(data.error || null);
|
||||
|
||||
// Estado para selección de fila
|
||||
let selectedInvoiceId = $state<number | null>(null);
|
||||
|
||||
function handleRowClick(invoice: Invoice) {
|
||||
// Si ya está seleccionado, lo deseleccionamos (opcional, si queremos permitir toggle)
|
||||
// O simplemente lo seleccionamos. Aquí implemento toggle.
|
||||
if (selectedInvoiceId === invoice.id) {
|
||||
selectedInvoiceId = null;
|
||||
} else {
|
||||
selectedInvoiceId = invoice.id;
|
||||
}
|
||||
console.log('Selected Invoice ID:', selectedInvoiceId);
|
||||
}
|
||||
|
||||
const selectedInvoice = $derived(
|
||||
selectedInvoiceId ? allItems.find(i => i.id === selectedInvoiceId) : null
|
||||
);
|
||||
|
||||
async function loadMore() {
|
||||
if (loading || !hasMore) return;
|
||||
@@ -312,83 +331,72 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Estado para el diálogo de progreso
|
||||
let showProgressDialog = $state(false);
|
||||
let currentTaskId = $state<string | null>(null);
|
||||
|
||||
// Utilidad para convertir Base64 a Blob
|
||||
function base64ToBlob(base64: string, type: string) {
|
||||
const binStr = atob(base64);
|
||||
const len = binStr.length;
|
||||
const arr = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
arr[i] = binStr.charCodeAt(i);
|
||||
function base64ToBlob(base64: string, type: string) {
|
||||
const binStr = atob(base64);
|
||||
const len = binStr.length;
|
||||
const arr = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
arr[i] = binStr.charCodeAt(i);
|
||||
}
|
||||
return new Blob([arr], { type: type });
|
||||
}
|
||||
return new Blob([arr], { type: type });
|
||||
}
|
||||
|
||||
async function handleDownloadPdf(invoice: any) {
|
||||
const toastId = toast.loading("Iniciando generación de PDF...");
|
||||
|
||||
try {
|
||||
// 1. Trigger: Iniciar la tarea en Celery
|
||||
const { task_id } = await invoicesReportsApi.triggerPdfGeneration(
|
||||
invoice.id,
|
||||
companyStore.activeCompany.id
|
||||
);
|
||||
async function handleDownloadPdf(invoice: any) {
|
||||
if (!companyStore.activeCompany) {
|
||||
toast.error("No hay empresa seleccionada");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.loading("Procesando PDF en segundo plano...", { id: toastId });
|
||||
try {
|
||||
// 1. Trigger: Iniciar la tarea en Celery
|
||||
const { task_id } = await invoicesReportsApi.triggerPdfGeneration(
|
||||
invoice.id,
|
||||
companyStore.activeCompany.id
|
||||
);
|
||||
|
||||
// 2. Polling: Loop para verificar estado
|
||||
let intentos = 0;
|
||||
const maxIntentos = 30; // Timeout de seguridad (aprox 60 segs)
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
intentos++;
|
||||
try {
|
||||
const statusData = await invoicesReportsApi.getTaskStatus(task_id);
|
||||
// 2. Abrir diálogo de progreso
|
||||
currentTaskId = task_id;
|
||||
showProgressDialog = true;
|
||||
|
||||
if (statusData.state === 'SUCCESS') {
|
||||
clearInterval(interval);
|
||||
|
||||
const result = statusData.result; // Tu dict del backend
|
||||
|
||||
if (result.status === 'success') {
|
||||
// 3. Convertir Base64 a Blob y Descargar
|
||||
const blob = base64ToBlob(result.content, result.media_type);
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = result.file_name; // Nombre que viene del worker
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("No se pudo iniciar la descarga");
|
||||
}
|
||||
}
|
||||
|
||||
toast.success("PDF Descargado", { id: toastId });
|
||||
} else {
|
||||
toast.error("Error al generar el archivo", { id: toastId });
|
||||
}
|
||||
}
|
||||
else if (statusData.state === 'FAILURE') {
|
||||
clearInterval(interval);
|
||||
toast.error("Falló la generación del PDF", { id: toastId });
|
||||
}
|
||||
else if (intentos >= maxIntentos) {
|
||||
clearInterval(interval);
|
||||
toast.error("Tiempo de espera agotado", { id: toastId });
|
||||
}
|
||||
// Si es PENDING o STARTED, el intervalo continúa...
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
clearInterval(interval); // Detener en caso de error de red
|
||||
toast.error("Error de conexión", { id: toastId });
|
||||
function onPdfComplete(result: any) {
|
||||
// Esta función se llama cuando el diálogo reporta SUCCESS
|
||||
try {
|
||||
if (result.status === 'success') {
|
||||
const blob = base64ToBlob(result.content, result.media_type);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = result.file_name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
toast.success("PDF Descargado exitosamente");
|
||||
} else {
|
||||
toast.error("El worker reportó un error: " + (result.message || "Desconocido"));
|
||||
}
|
||||
}, 2000); // Consultar cada 2 segundos
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("No se pudo iniciar la descarga", { id: toastId });
|
||||
} catch (e) {
|
||||
console.error("Error al procesar descarga:", e);
|
||||
toast.error("Error al procesar el archivo descargado");
|
||||
} finally {
|
||||
// Cerrar diálogo después de un breve momento
|
||||
setTimeout(() => {
|
||||
showProgressDialog = false;
|
||||
currentTaskId = null;
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateClick() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -443,8 +451,13 @@ async function handleDownloadPdf(invoice: any) {
|
||||
);
|
||||
});
|
||||
|
||||
function closeProgressDialog() {
|
||||
showProgressDialog = false;
|
||||
currentTaskId = null;
|
||||
}
|
||||
|
||||
// --- AQUÍ PASAMOS LA FUNCIÓN DE DESCARGA A LAS COLUMNAS ---
|
||||
const columns = createColumns(handleSuccess, handleDownloadPdf);
|
||||
const columns = createColumns(handleSuccess);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
@@ -556,7 +569,38 @@ async function handleDownloadPdf(invoice: any) {
|
||||
{loading}
|
||||
{hasMore}
|
||||
{loadMore}
|
||||
selectedId={selectedInvoiceId}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
|
||||
<PdfProgressDialog
|
||||
bind:open={showProgressDialog}
|
||||
taskId={currentTaskId}
|
||||
onComplete={onPdfComplete}
|
||||
onClose={closeProgressDialog}
|
||||
/>
|
||||
|
||||
<!-- Footer fijo con botones de acción -->
|
||||
<div class="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t shadow-lg z-[5] group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] ml-[calc(var(--sidebar-width))]">
|
||||
<div class="px-4 py-4 max-w-[1400px] mx-auto">
|
||||
<!-- Botones de acción -->
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" disabled={!selectedInvoice}>
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Actualizar
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={!selectedInvoice}>
|
||||
<RotateCcw class="h-4 w-4 mr-2" />
|
||||
Desactualizar
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={() => selectedInvoice && handleDownloadPdf(selectedInvoice)} disabled={!selectedInvoice}>
|
||||
<FileDown class="h-4 w-4 mr-2" />
|
||||
Descargar PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user