Guardado de seguridad

This commit is contained in:
2026-01-06 14:32:13 -06:00
parent 7158967b17
commit bef37aa23c
2 changed files with 291 additions and 22 deletions

View File

@@ -0,0 +1,146 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import * as Dialog from "$lib/components/ui/dialog";
import { Search, Loader2, User, Building2, CheckCircle2, XCircle } from "lucide-svelte";
import { clientsProvidersApi, type ClientProvider } from "$lib/api/dashboard/a76/clients-providers";
import { companyStore } from "$lib/stores/company.svelte";
// Props
let { open = $bindable(false), onSelect }: { open: boolean, onSelect: (client: ClientProvider) => void } = $props();
// Estado
let clients = $state<ClientProvider[]>([]);
let loading = $state(false);
let searchTerm = $state("");
let loaded = $state(false);
// Filtramos localmente para que sea instantáneo
let filteredClients = $derived(
clients.filter(c =>
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
c.rfc.toLowerCase().includes(searchTerm.toLowerCase()) ||
c.id.toString().includes(searchTerm)
)
);
// Cargar clientes al abrir el modal
$effect(() => {
if (open && !loaded && companyStore.activeCompany?.id) {
loadClients();
}
});
async function loadClients() {
if (!companyStore.activeCompany?.id) return;
loading = true;
try {
const res = await clientsProvidersApi.list(companyStore.activeCompany.id, 1, 100, {
type: 'client'
});
const responseData = (res as any).data || res;
if (responseData && responseData.items) {
clients = responseData.items;
loaded = true;
} else {
console.warn("La API respondió pero no trajo items:", responseData);
}
} catch (e) {
console.error("Error cargando clientes:", e);
} finally {
loading = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[700px] max-h-[80vh] flex flex-col">
<Dialog.Header>
<Dialog.Title>Seleccionar Cliente</Dialog.Title>
<Dialog.Description>
Busca y selecciona el cliente propietario de la parte.
</Dialog.Description>
</Dialog.Header>
<div class="relative w-full my-2">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Buscar por Nombre, RFC o ID..."
class="pl-9"
bind:value={searchTerm}
/>
</div>
<div class="flex-1 overflow-y-auto border rounded-md min-h-[300px]">
{#if loading}
<div class="flex flex-col items-center justify-center h-48 gap-2 text-muted-foreground">
<Loader2 class="h-8 w-8 animate-spin text-primary" />
<p>Cargando catálogo...</p>
</div>
{:else if filteredClients.length === 0}
<div class="flex flex-col items-center justify-center h-48 text-muted-foreground">
<p>No se encontraron clientes.</p>
</div>
{:else}
<table class="w-full text-sm">
<thead class="bg-muted/50 sticky top-0 backdrop-blur-sm">
<tr class="text-left border-b">
<th class="p-3 font-medium text-muted-foreground w-[60px]">ID</th>
<th class="p-3 font-medium text-muted-foreground w-[130px]">RFC</th>
<th class="p-3 font-medium text-muted-foreground">Razón Social</th>
<th class="p-3 font-medium text-muted-foreground w-[100px] text-center">Estado</th>
<th class="p-3 font-medium text-muted-foreground w-[80px]">Acción</th>
</tr>
</thead>
<tbody>
{#each filteredClients as client}
<tr class="border-b hover:bg-muted/50 transition-colors group">
<td class="p-3 font-mono text-xs">{client.id}</td>
<td class="p-3 font-mono text-xs">{client.rfc}</td>
<td class="p-3 font-medium">
<div class="flex items-center gap-2">
{#if client.client_or_provider === 'client'}
<User class="h-3 w-3 text-blue-500" />
{:else}
<Building2 class="h-3 w-3 text-purple-500" />
{/if}
{client.name}
</div>
</td>
<td class="p-3 text-center">
{#if client.is_active}
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">
Activo
</span>
{:else}
<span class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">
Baja
</span>
{/if}
</td>
<td class="p-2">
<Button size="sm" variant="ghost" class="h-8 w-full" onclick={() => handleSelect(client)}>
Usar
</Button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<Dialog.Footer>
<div class="text-xs text-muted-foreground self-center mr-auto">
Mostrando {filteredClients.length} registro(s)
</div>
<Button variant="outline" onclick={() => open = false}>Cancelar</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -12,10 +12,20 @@
import * as Card from '$lib/components/ui/card';
import * as Select from "$lib/components/ui/select";
import { Switch } from "$lib/components/ui/switch";
import { ArrowLeft, LoaderCircle, Save, Package, DollarSign, FileText, Settings, Image as ImageIcon } from 'lucide-svelte';
// Iconos
import {
ArrowLeft, LoaderCircle, Save, Package, DollarSign,
FileText, Settings, Image as ImageIcon, Search,
UserCheck, CheckCircle2, XCircle
} from 'lucide-svelte';
// Stores & APIs
import { companyStore } from '$lib/stores/company.svelte';
import { partsApi, type PartCreate } from '$lib/api/dashboard/a76/parts';
import { clientsProvidersApi } from '$lib/api/dashboard/a76/clients-providers';
// COMPONENTE DEL MODAL (Ajusta la ruta si es necesario)
import ClientSelectorDialog from '$lib/components/dashboard/parts/client-selector-dialog.svelte';
// --- 1. IDENTIFICACIÓN ---
let id = $derived($page.params.id === 'new' ? null : Number($page.params.id));
@@ -26,22 +36,30 @@
let loading = $state(false);
let error = $state<string | null>(null);
// Estado del Modal de Clientes
let showClientModal = $state(false);
let selectedClientName = $state("");
let selectedClientStatus = $state(true);
// Estado del Formulario
let formData = $state({
client_id: 0,
part_number: '',
// General
description_spanish: '',
description_english: '',
part_class: '',
country_of_origin: 'MEX',
unit_of_measure: 'PZ',
// Costos y Pesos
unit_weight: 0,
weight_type: 'KG',
unit_cost: 0,
currency_key: 'USD',
added_value: 0,
value_added_type: 'USD', // Campo visual (no en BD)
us_fraction: '',
// Opciones
@@ -110,12 +128,18 @@
unit_cost: Number(d.unit_cost) || 0,
currency_key: d.currency_key || 'USD',
added_value: Number(d.added_value) || 0,
value_added_type: 'USD', // Valor por defecto al cargar
commercial_part_number: d.commercial_part_number || '',
alternate_unit_measure: d.alternate_unit_measure || '',
part_photo: d.part_photo || '',
is_active: d.is_active ?? true
};
// Cargar info visual del cliente
if (d.client_id) {
await fetchClientName(d.client_id, companyId);
}
}
} catch (e) {
error = "Error al cargar la parte";
@@ -125,11 +149,33 @@
}
}
// Función auxiliar para obtener nombre del cliente
async function fetchClientName(clientId: number, companyId: number) {
try {
const res = await clientsProvidersApi.get(clientId, companyId);
// Ajusta esto según cómo devuelva tu API el objeto (res o res.data)
const clientData = (res as any).data || res;
if (clientData) {
selectedClientName = clientData.name;
selectedClientStatus = clientData.is_active ?? true;
}
} catch (e) {
console.log("No se pudo cargar info visual del cliente", e);
}
}
// Callback del Modal
function handleClientSelect(client: any) {
formData.client_id = client.id;
selectedClientName = client.name;
selectedClientStatus = client.is_active ?? true;
}
async function handleSubmit() {
error = null;
const activeCompanyId = companyStore.activeCompany?.id;
if (!activeCompanyId) { error = 'No hay una compañía activa seleccionada'; return; }
if (!formData.client_id) { error = 'ID Cliente requerido (Pestaña Otros)'; return; }
if (!formData.client_id) { error = 'Debe seleccionar un Cliente (Pestaña Otros)'; return; }
if (!formData.part_number.trim()) { error = 'Número de Parte requerido'; return; }
loading = true;
@@ -245,7 +291,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="part_class">Clase</Label>
<Input id="part_class" bind:value={formData.part_class} maxlength={8} placeholder="Código de clase"/>
<Input id="part_class" bind:value={formData.part_class} maxlength={8} />
</div>
<div class="grid gap-2">
<Label for="country">País Origen (ISO)</Label>
@@ -255,11 +301,18 @@
<div class="p-4 border rounded-lg bg-slate-50 dark:bg-slate-900/30 space-y-4">
<h3 class="font-medium text-sm text-muted-foreground flex items-center gap-2">
<Package class="h-4 w-4"/> Unidades de Medida
<Package class="h-4 w-4"/> Tipos de material y unidades de medida
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="uom">UM Comercial</Label>
<Label for="mat_type">Tipo Material</Label>
<Input id="mat_type" placeholder="Ej: Materia Prima" />
<p class="text-[10px] text-muted-foreground">Campo informativo (Visual)</p>
</div>
<div class="grid gap-2">
<Label for="uom">Comercial (UM)</Label>
<Select.Root type="single" bind:value={formData.unit_of_measure}>
<Select.Trigger id="uom">
{formData.unit_of_measure || "Seleccione"}
@@ -278,32 +331,34 @@
<div class="p-4 border rounded-lg bg-green-50/50 dark:bg-green-900/10 space-y-4">
<h3 class="font-medium text-sm text-green-800 dark:text-green-300 flex items-center gap-2">
<DollarSign class="h-4 w-4"/> Costos y Pesos
<DollarSign class="h-4 w-4"/> Costos, valores y peso unitario
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="grid gap-2">
<Label for="currency">Moneda</Label>
<Label for="currency">Tipos de Moneda</Label>
<Select.Root type="single" bind:value={formData.currency_key}>
<Select.Trigger id="currency">{formData.currency_key}</Select.Trigger>
<Select.Content>
<Select.Item value="USD">Dólar (USD)</Select.Item>
<Select.Item value="MXP">Peso (MXP)</Select.Item>
<Select.Item value="EUR">Euro (EUR)</Select.Item>
<Select.Item value="USD">Dólares (USD)</Select.Item>
<Select.Item value="MXP">Pesos (MXP)</Select.Item>
<Select.Item value="EUR">Euros (EUR)</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="grid gap-2">
<Label for="unit_cost">Costo Unitario</Label>
<Input type="number" step="0.0001" id="unit_cost" bind:value={formData.unit_cost} />
<div class="relative">
<span class="absolute left-3 top-2.5 text-muted-foreground">$</span>
<Input type="number" step="0.0001" id="unit_cost" bind:value={formData.unit_cost} class="pl-7" />
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2 border-t border-green-200 dark:border-green-800/30">
<div class="grid gap-2">
<Label for="unit_weight">Peso Unitario</Label>
<div class="flex gap-2">
<Input type="number" step="0.0001" id="unit_weight" bind:value={formData.unit_weight} />
<Input type="number" step="0.0001" id="unit_weight" bind:value={formData.unit_weight} placeholder="0.0000" />
<Select.Root type="single" bind:value={formData.weight_type}>
<Select.Trigger class="w-[100px]">{formData.weight_type}</Select.Trigger>
<Select.Content>
@@ -316,10 +371,42 @@
</div>
</div>
<div class="grid gap-2">
<Label for="added_value">Valor Agregado (Monto)</Label>
<Input type="number" step="0.0001" id="added_value" bind:value={formData.added_value} />
<p class="text-xs text-muted-foreground">Ingrese el monto numérico del valor agregado.</p>
<div class="space-y-3 p-4 border rounded-lg">
<Label class="font-semibold">Tipo Valor Agregado</Label>
<div class="flex flex-wrap gap-6">
<div class="flex items-center space-x-2">
<input type="radio" id="va_ext" name="va_type" value="USD" bind:group={formData.value_added_type} class="accent-primary h-4 w-4 cursor-pointer" />
<Label for="va_ext" class="font-normal cursor-pointer">Extranjera (Dls)</Label>
</div>
<div class="flex items-center space-x-2">
<input type="radio" id="va_nac" name="va_type" value="MXP" bind:group={formData.value_added_type} class="accent-primary h-4 w-4 cursor-pointer" />
<Label for="va_nac" class="font-normal cursor-pointer">Nacional (Pesos)</Label>
</div>
<div class="flex items-center space-x-2">
<input type="radio" id="va_pct" name="va_type" value="PERCENT" bind:group={formData.value_added_type} class="accent-primary h-4 w-4 cursor-pointer" />
<Label for="va_pct" class="font-normal cursor-pointer">Porcentaje</Label>
</div>
</div>
<div class="grid gap-2 mt-2">
<div class="relative max-w-xs">
{#if formData.value_added_type === 'PERCENT'}
<span class="absolute right-3 top-2.5 text-muted-foreground">%</span>
{:else}
<span class="absolute left-3 top-2.5 text-muted-foreground">$</span>
{/if}
<Input
type="number"
step="0.0001"
id="added_value"
bind:value={formData.added_value}
class={formData.value_added_type === 'PERCENT' ? 'pr-7' : 'pl-7'}
placeholder="0.00"
/>
</div>
</div>
</div>
<div class="grid gap-2 pt-4 border-t">
@@ -352,7 +439,7 @@
</div>
</div>
</Tabs.Content>
</Tabs.Content>
<Tabs.Content value="opcionales2" class="space-y-6 pt-4 animate-in fade-in duration-300">
@@ -364,13 +451,44 @@
</div>
</div>
</Tabs.Content>
</Tabs.Content>
<Tabs.Content value="otros" class="space-y-6 pt-4 animate-in fade-in duration-300">
<div class="grid gap-2">
<Label for="client_id" class="required">Catálogo de Clientes (ID)</Label>
<Input type="number" id="client_id" bind:value={formData.client_id} placeholder="ID del Cliente" />
<Label for="client_id" class="required">Cliente Asignado</Label>
<div class="flex gap-2">
<div class="relative flex-1">
<UserCheck class="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="number"
id="client_id"
bind:value={formData.client_id}
class="pl-9 font-mono"
placeholder="Seleccione un cliente..."
readonly
onclick={() => showClientModal = true}
/>
</div>
<Button variant="outline" class="shrink-0" onclick={() => showClientModal = true}>
<Search class="h-4 w-4 mr-2" /> Buscar
</Button>
</div>
{#if selectedClientName}
<div class="flex items-center gap-2 mt-1 px-3 py-2 bg-slate-50 dark:bg-slate-900/50 border rounded-md text-sm animate-in slide-in-from-top-1">
<span class="font-semibold text-primary">{selectedClientName}</span>
<span class="text-muted-foreground mx-1"></span>
{#if selectedClientStatus}
<span class="text-green-600 flex items-center gap-1 text-xs font-medium"><CheckCircle2 class="h-3 w-3"/> Activo</span>
{:else}
<span class="text-red-600 flex items-center gap-1 text-xs font-medium"><XCircle class="h-3 w-3"/> Baja / Inactivo</span>
{/if}
</div>
{/if}
<p class="text-[10px] text-muted-foreground">El cliente propietario de este número de parte.</p>
</div>
<div class="grid gap-2">
@@ -461,6 +579,11 @@
</form>
</div>
<ClientSelectorDialog
bind:open={showClientModal}
onSelect={handleClientSelect}
/>
<style>
:global(.required::after) {
content: " *";