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:
2026-01-15 13:20:34 -06:00
parent ae1d7f0c58
commit 0a02a3e912
44 changed files with 1536 additions and 515 deletions

View File

@@ -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} />

View File

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

View File

@@ -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>