Guardado de seguridad
This commit is contained in:
@@ -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>
|
||||
@@ -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: " *";
|
||||
|
||||
Reference in New Issue
Block a user