Se esta trabajando en el formulario de partes y sus relaciones

This commit is contained in:
2026-01-06 18:02:45 -06:00
parent 2182f0b739
commit 7c5f78f258
9 changed files with 595 additions and 252 deletions

View File

@@ -3,27 +3,45 @@ from decimal import Decimal
from typing import List, Optional
from pydantic import BaseModel, Field
# --- DTO DE CREACIÓN ---
class PartCreateDTO(BaseModel):
client_id: int
part_number: str = Field(..., max_length=50)
# Campos Generales
description_spanish: Optional[str] = None
description_english: Optional[str] = None
part_class: Optional[str] = None
unit_of_measure: Optional[str] = "PZ"
commercial_part_number: Optional[str] = None
country_of_origin: Optional[str] = "MEX"
# Costos y Pesos
unit_cost: Optional[Decimal] = Decimal("0.0")
currency_key: Optional[str] = "USD"
unit_weight: Optional[Decimal] = Decimal("0.0")
weight_type: Optional[str] = "KG"
# --- LOS QUE FALTABAN Y AHORA SE GUARDARÁN ---
added_value: Optional[Decimal] = None
part_photo: Optional[str] = None
alternate_unit_measure: Optional[str] = None
license_code: Optional[str] = None
export_code: Optional[str] = None
exclusion_symbol: Optional[str] = None
# Regulatorios
fraction: Optional[str] = None
us_fraction: Optional[str] = None
supplier: Optional[str] = None
fda_key: Optional[str] = None
fcc_key: Optional[str] = None
eccn: Optional[str] = None
# Estatus
is_active: Optional[bool] = True
# --- DTO DE ACTUALIZACIÓN ---
class PartUpdateDTO(BaseModel):
description_spanish: Optional[str] = None
description_english: Optional[str] = None
@@ -31,10 +49,18 @@ class PartUpdateDTO(BaseModel):
unit_of_measure: Optional[str] = None
commercial_part_number: Optional[str] = None
country_of_origin: Optional[str] = None
unit_cost: Optional[Decimal] = None
currency_key: Optional[str] = None
unit_weight: Optional[Decimal] = None
weight_type: Optional[str] = None
added_value: Optional[Decimal] = None
part_photo: Optional[str] = None
alternate_unit_measure: Optional[str] = None
license_code: Optional[str] = None
export_code: Optional[str] = None
exclusion_symbol: Optional[str] = None
fraction: Optional[str] = None
us_fraction: Optional[str] = None
supplier: Optional[str] = None
@@ -43,6 +69,7 @@ class PartUpdateDTO(BaseModel):
eccn: Optional[str] = None
is_active: Optional[bool] = None
class PartResponseDTO(PartCreateDTO):
id: int
tenant_id: int

View File

@@ -41,6 +41,7 @@ from .general_catalogs.electronic_notices.routes import router as electronic_not
from .transportation.trailers.routes import router as trailers_router
from .transportation.transporters.routes import router as transporters_router
from .transportation.vehicles.routes import router as vehicles_router
from api.v1.modules.public.reference_data.material_types.routes import router as material_types_router
# Router principal
router = APIRouter()
@@ -94,3 +95,10 @@ router.include_router(error_catalogs_router, prefix="/a76")
router.include_router(doda_router, prefix="/a76")
router.include_router(prevalidators_router, prefix="/a76")
router.include_router(electronic_notices_router, prefix="/a76")
# Registrar router de tipos de material públicos
router.include_router(
material_types_router,
prefix="/public/reference-data",
tags=["Reference Data"]
)

View File

@@ -1,107 +1,52 @@
/**
* API para gestión de Classes (Clases A76)
*/
import { api } from '$lib/api';
import type { ApiResponse } from '$lib/api';
export interface A76Class {
id: number;
tenant_id: number;
company_id: number;
client_id: number;
class_code: string;
description_es: string | null;
description_en: string | null;
material_key: string | null;
unit_of_measure: string;
fraction: string;
us_fraction: string;
sub_key: string;
physical_review: number;
iva_exempt_fraction: string;
created_at: string;
updated_at: string;
}
export interface A76ClassCreate {
company_id: number;
client_id: number;
class_code: string;
description_es?: string | null;
description_en?: string | null;
material_key?: string | null;
unit_of_measure: string;
fraction: string;
us_fraction: string;
sub_key: string;
physical_review?: number;
iva_exempt_fraction: string;
}
export interface A76ClassUpdate {
client_id?: number;
class_code?: string;
description_es?: string | null;
description_en?: string | null;
material_key?: string | null;
unit_of_measure?: string;
fraction?: string;
us_fraction?: string;
sub_key?: string;
physical_review?: number;
iva_exempt_fraction?: string;
id: number;
tenant_id: number;
company_id: number;
client_id: number;
class_code: string;
description_es: string | null;
description_en: string | null;
material_key: string | null;
unit_of_measure: string;
fraction: string;
us_fraction: string;
sub_key: string;
physical_review: number;
iva_exempt_fraction: string;
created_at: string;
updated_at: string;
}
export interface A76ClassListResponse {
items: A76Class[];
total: number;
page: number;
page_size: number;
items: A76Class[];
classes?: A76Class[];
total: number;
page: number;
page_size: number;
size?: number;
}
export interface A76ClassListParams {
company_id: number;
page?: number;
page_size?: number;
company_id: number;
page?: number;
page_size?: number;
class_code?: string;
description?: string;
}
/**
* API de Classes
*/
export const classesApi = {
/**
* Obtener lista de classes con paginación
*/
list: (params: A76ClassListParams): Promise<ApiResponse<A76ClassListResponse>> => {
const { company_id, page = 1, page_size = 50 } = params;
return api.get(`/v1/a76/classes/?company_id=${company_id}&page=${page}&page_size=${page_size}`);
},
list: (params: A76ClassListParams): Promise<ApiResponse<A76ClassListResponse>> => {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value) query.append(key, value.toString());
});
return api.get(`/v1/a76/classes/?${query.toString()}`);
},
/**
* Obtener un class por ID
*/
get: (id: number, company_id: number): Promise<ApiResponse<A76Class>> => {
return api.get(`/v1/a76/classes/${id}?company_id=${company_id}`);
},
/**
* Crear un nuevo class
*/
create: (data: A76ClassCreate, company_id: number): Promise<ApiResponse<A76Class>> => {
return api.post(`/v1/a76/classes/?company_id=${company_id}`, data);
},
/**
* Actualizar un class existente
*/
update: (id: number, data: A76ClassUpdate, company_id: number): Promise<ApiResponse<A76Class>> => {
return api.put(`/v1/a76/classes/${id}?company_id=${company_id}`, data);
},
/**
* Eliminar un class
*/
delete: (id: number, company_id: number): Promise<ApiResponse<void>> => {
return api.delete(`/v1/a76/classes/${id}?company_id=${company_id}`);
}
};
get: (id: number, company_id: number): Promise<ApiResponse<A76Class>> => {
return api.get(`/v1/a76/classes/${id}?company_id=${company_id}`);
}
};

View File

@@ -0,0 +1,23 @@
import { api } from '$lib/api';
export interface MaterialType {
key: string;
type: string;
description: string;
}
export interface MaterialTypeListResponse {
items: MaterialType[];
total: number;
page: number;
page_size: number;
}
export const materialTypesApi = {
list: async (page = 1, pageSize = 100) => {
return api.get<MaterialTypeListResponse>(`/v1/public/reference-data/material-types/?page=${page}&page_size=${pageSize}`);
}
};

View File

@@ -0,0 +1,166 @@
<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, Tag, Ruler } from "lucide-svelte";
import { classesApi, type A76Class } from "$lib/api/dashboard/a76/classes"; // Ajusta ruta
import { companyStore } from "$lib/stores/company.svelte";
// --- PROPS ---
let {
open = $bindable(false),
onSelect
}: {
open: boolean,
onSelect: (item: A76Class) => void
} = $props();
// --- ESTADO ---
let items = $state<A76Class[]>([]);
let loading = $state(false);
let searchTerm = $state("");
let loaded = $state(false);
// Filtro local: Busca por Clave (class_code) o Descripción (description_es)
let filteredItems = $derived(
items.filter(i =>
(i.class_code || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
(i.description_es || "").toLowerCase().includes(searchTerm.toLowerCase())
)
);
// Cargar datos al abrir
$effect(() => {
if (open && !loaded && companyStore.activeCompany?.id) {
loadClasses();
}
});
async function loadClasses() {
if (!companyStore.activeCompany?.id) return;
loading = true;
try {
const res = await classesApi.list({
company_id: companyStore.activeCompany.id,
page: 1,
page_size: 100
});
const data = (res as any).data || res;
const list = data.items || data.classes || [];
if (Array.isArray(list)) {
items = list;
loaded = true;
} else {
console.warn("No encontré lista de clases en la respuesta:", data);
}
} catch (e) {
console.error("Error cargando clases:", e);
} finally {
loading = false;
}
}
function handleSelect(item: A76Class) {
if (onSelect) onSelect(item);
open = false;
}
</script>
<Dialog.Root bind:open={open}>
<Dialog.Content class="sm:max-w-[700px] max-h-[80vh] flex flex-col">
<Dialog.Header>
<Dialog.Title>Seleccionar Clase (Anexo 24)</Dialog.Title>
<Dialog.Description>
Seleccione la clasificación del material.
</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 Clave o Descripción..."
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 filteredItems.length === 0}
<div class="flex flex-col items-center justify-center h-48 text-muted-foreground">
<p>No se encontraron clases.</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-[100px]">Clave</th>
<th class="p-3 font-medium text-muted-foreground">Descripción</th>
<th class="p-3 font-medium text-muted-foreground w-[80px]">UM</th>
<th class="p-3 font-medium text-muted-foreground w-[80px]">Acción</th>
</tr>
</thead>
<tbody>
{#each filteredItems as item}
<tr class="border-b hover:bg-muted/50 transition-colors">
<td class="p-3">
<span class="font-mono font-bold text-primary bg-primary/10 px-2 py-1 rounded text-xs">
{item.class_code}
</span>
</td>
<td class="p-3">
<div class="flex items-center gap-2">
<Tag class="h-3 w-3 text-muted-foreground shrink-0" />
<span class="truncate max-w-[300px]" title={item.description_es || ''}>
{item.description_es || 'Sin descripción'}
</span>
</div>
</td>
<td class="p-3">
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<Ruler class="h-3 w-3" />
{item.unit_of_measure || '-'}
</div>
</td>
<td class="p-2">
<Button
type="button"
size="sm"
variant="ghost"
class="h-8 w-full"
onclick={() => handleSelect(item)}
>
Usar
</Button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<Dialog.Footer>
<div class="text-xs text-muted-foreground self-center mr-auto">
{filteredItems.length} registros encontrados
</div>
<Button variant="outline" onclick={() => open = false}>Cancelar</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -2,20 +2,26 @@
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 { Search, Loader2, User, Building2 } 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
// --- PROPS Y BINDING ---
let {
open = $bindable(false),
onSelect
}: {
open: boolean,
onSelect: (client: ClientProvider) => void
} = $props();
// --- ESTADO LOCAL ---
let clients = $state<ClientProvider[]>([]);
let loading = $state(false);
let searchTerm = $state("");
let loaded = $state(false);
let loaded = $state(false);
// Filtramos localmente para que sea instantáneo
// Filtro reactivo local
let filteredClients = $derived(
clients.filter(c =>
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
@@ -24,7 +30,7 @@
)
);
// Cargar clientes al abrir el modal
// Efecto para cargar datos cuando se abre el modal
$effect(() => {
if (open && !loaded && companyStore.activeCompany?.id) {
loadClients();
@@ -36,19 +42,20 @@
loading = true;
try {
// Petición a la API
const res = await clientsProvidersApi.list(companyStore.activeCompany.id, 1, 100, {
type: 'client'
});
// Normalización de respuesta
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);
console.warn("La API no trajo items:", responseData);
}
} catch (e) {
console.error("Error cargando clientes:", e);
} finally {
@@ -56,9 +63,17 @@
}
}
// --- FUNCIÓN DE SELECCIÓN ---
function handleSelect(client: ClientProvider) {
console.log("Seleccionando cliente:", client.name);
if (onSelect) {
onSelect(client);
}
open = false; // Cerrar el modal
}
</script>
<Dialog.Root bind:open>
<Dialog.Root bind:open={open}>
<Dialog.Content class="sm:max-w-[700px] max-h-[80vh] flex flex-col">
<Dialog.Header>
<Dialog.Title>Seleccionar Cliente</Dialog.Title>
@@ -125,7 +140,13 @@
{/if}
</td>
<td class="p-2">
<Button size="sm" variant="ghost" class="h-8 w-full" onclick={() => handleSelect(client)}>
<Button
type="button"
size="sm"
variant="ghost"
class="h-8 w-full"
onclick={() => handleSelect(client)}
>
Usar
</Button>
</td>

View File

@@ -18,7 +18,7 @@ function formatCurrency(amount: number | null, currency: string | null): string
export function createColumns(onSuccess?: () => void): ColumnDef<Part>[] {
return [
// 1. STATUS (Corregido a Texto)
{
accessorKey: "is_active",
header: "Status",
@@ -35,7 +35,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Part>[] {
}
},
// 2. NUMERO PARTE
{
accessorKey: "part_number",
header: "No. Parte",
@@ -51,7 +51,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Part>[] {
}
},
// 3. DESCRIPCION (Español)
{
accessorKey: "description_spanish",
header: "Descripción",
@@ -67,7 +67,6 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Part>[] {
}
},
// 4. DESCRIPCION INGLES
{
accessorKey: "description_english",
header: "Desc. Inglés",
@@ -83,7 +82,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Part>[] {
}
},
// 5. CLASE
{
accessorKey: "part_class",
header: "Clase",
@@ -98,7 +97,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Part>[] {
}
},
// 6. TIPO (Commercial Part Number)
{
accessorKey: "commercial_part_number",
header: "Tipo",

View File

@@ -0,0 +1,133 @@
<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, Layers, Tag, Box } from "lucide-svelte";
// Importamos la interfaz corregida
import { materialTypesApi, type MaterialType } from "$lib/api/dashboard/a76/material-types";
// --- PROPS ---
let {
open = $bindable(false),
onSelect
}: {
open: boolean,
onSelect: (item: MaterialType) => void
} = $props();
let items = $state<MaterialType[]>([]);
let loading = $state(false);
let searchTerm = $state("");
let loaded = $state(false);
let filteredItems = $derived(
items.filter(i =>
i.key.toLowerCase().includes(searchTerm.toLowerCase()) ||
i.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
i.type.toLowerCase().includes(searchTerm.toLowerCase())
)
);
$effect(() => {
if (open && !loaded) loadMaterials();
});
async function loadMaterials() {
loading = true;
try {
const res = await materialTypesApi.list(1, 100);
const responseData = (res as any).data || res;
if (responseData && responseData.items) {
items = responseData.items;
loaded = true;
} else {
console.error("Estructura inesperada:", responseData);
}
} catch (e) {
console.error("Error cargando materiales:", e);
} finally {
loading = false;
}
}
function handleSelect(item: MaterialType) {
if (onSelect) onSelect(item);
open = false;
}
function getCategoryIcon(type: string) {
if (type.includes('PRODUCTOS')) return Box;
if (type.includes('ACTIVO')) return Layers;
return Tag;
}
</script>
<Dialog.Root bind:open={open}>
<Dialog.Content class="sm:max-w-[650px] max-h-[80vh] flex flex-col">
<Dialog.Header>
<Dialog.Title>Seleccionar Tipo de Material</Dialog.Title>
<Dialog.Description>Catálogo general.</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..." 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 filteredItems.length === 0}
<div class="flex flex-col items-center justify-center h-48 text-muted-foreground">
<p>No se encontraron resultados.</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-[80px]">Clave</th>
<th class="p-3 font-medium text-muted-foreground">Descripción</th>
<th class="p-3 font-medium text-muted-foreground w-[120px]">Tipo</th>
<th class="p-3 font-medium text-muted-foreground w-[80px]">Acción</th>
</tr>
</thead>
<tbody>
{#each filteredItems as item}
<tr class="border-b hover:bg-muted/50 transition-colors">
<td class="p-3 font-mono font-bold text-primary">{item.key}</td>
<td class="p-3 font-medium">{item.description}</td>
<td class="p-3">
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<svelte:component this={getCategoryIcon(item.type)} class="h-3 w-3" />
{item.type}
</div>
</td>
<td class="p-2">
<Button type="button" size="sm" variant="ghost" class="h-8 w-full" onclick={() => handleSelect(item)}>
Usar
</Button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => open = false}>Cancelar</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -12,36 +12,46 @@
import * as Card from '$lib/components/ui/card';
import * as Select from "$lib/components/ui/select";
import { Switch } from "$lib/components/ui/switch";
// Iconos
import {
ArrowLeft, LoaderCircle, Save, Package, DollarSign,
FileText, Settings, Image as ImageIcon, Search,
UserCheck, CheckCircle2, XCircle
UserCheck, CheckCircle2, XCircle, Tag, Layers
} 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';
import { classesApi } from '$lib/api/dashboard/a76/classes';
import { materialTypesApi } from '$lib/api/dashboard/a76/material-types';
// COMPONENTE DEL MODAL (Ajusta la ruta si es necesario)
import ClientSelectorDialog from '$lib/components/dashboard/parts/client-selector-dialog.svelte';
import ClassSelectorDialog from '$lib/components/dashboard/parts/class-selector-dialog.svelte';
import MaterialTypeSelectorDialog from '$lib/components/dashboard/parts/material-type-selector-dialog.svelte';
// --- 1. IDENTIFICACIÓN ---
let id = $derived($page.params.id === 'new' ? null : Number($page.params.id));
let isEdit = $derived(!!id);
let title = $derived(isEdit ? "Editar Parte" : "Nueva Parte");
// --- 2. ESTADOS ---
let loading = $state(false);
let error = $state<string | null>(null);
// Estado del Modal de Clientes
// Estado Modales
let showClientModal = $state(false);
let showClassModal = $state(false);
let showMaterialModal = $state(false);
// Descripciones Visuales
let selectedClientName = $state("");
let selectedClientStatus = $state(true);
let selectedClassDesc = $state("");
let selectedMaterialDesc = $state("");
// Estado del Formulario
// Estado Formulario
let formData = $state({
client_id: 0,
part_number: '',
@@ -49,7 +59,8 @@
// General
description_spanish: '',
description_english: '',
part_class: '',
part_class: '',
material_type_key: '',
country_of_origin: 'MEX',
unit_of_measure: 'PZ',
@@ -59,7 +70,7 @@
unit_cost: 0,
currency_key: 'USD',
added_value: 0,
value_added_type: 'USD', // Campo visual (no en BD)
value_added_type: 'USD',
us_fraction: '',
// Opciones
@@ -72,7 +83,7 @@
// Otros
commercial_part_number: '',
fraction: '', // Fraccion MX
fraction: '',
eccn: '',
license_code: '',
export_code: '',
@@ -81,7 +92,7 @@
is_active: true
});
// --- 3. CARGA ---
// --- 3. CARGA INICIAL ---
onMount(async () => {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
@@ -103,20 +114,23 @@
if (response.data) {
const d = response.data;
// Mapeo de datos
formData = {
client_id: d.client_id,
part_number: d.part_number,
description_spanish: d.description_spanish || '',
description_english: d.description_english || '',
part_class: d.part_class || '',
// OJO: Asegúrate que tu backend devuelva este campo si existe en BD
material_type_key: (d as any).material_type_key || '',
country_of_origin: d.country_of_origin || 'MEX',
unit_of_measure: d.unit_of_measure || 'PZ',
fraction: d.fraction || '',
us_fraction: d.us_fraction || '',
unit_weight: Number(d.unit_weight) || 0,
weight_type: d.weight_type || 'KG',
supplier: d.supplier || '',
fda_key: d.fda_key || '',
fcc_key: d.fcc_key || '',
@@ -124,22 +138,20 @@
license_code: d.license_code || '',
export_code: d.export_code || '',
exclusion_symbol: d.exclusion_symbol || '',
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
value_added_type: 'USD',
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);
}
// Cargar datos visuales
if (d.client_id) await fetchClientName(d.client_id, companyId);
if (d.part_class) await fetchClassDesc(d.part_class, companyId);
if ((d as any).material_type_key) await fetchMaterialName((d as any).material_type_key);
}
} catch (e) {
error = "Error al cargar la parte";
@@ -149,28 +161,57 @@
}
}
// 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);
}
} catch (e) { console.log("Error visual cliente", e); }
}
// Callback del Modal
async function fetchClassDesc(code: string, companyId: number) {
try {
const res = await classesApi.list({ company_id: companyId, class_code: code });
const data = (res as any).data || res;
const list = data.items || data.classes || [];
if (list.length > 0) {
const found = list.find((i: any) => i.class_code === code) || list[0];
selectedClassDesc = found.description_es || found.description_en || "";
}
} catch (e) { console.log("Error visual clase", e); }
}
async function fetchMaterialName(key: string) {
try {
const res = await materialTypesApi.list(1, 100);
const data = (res as any).data || res;
const list = data.items || [];
const found = list.find((m: any) => m.key === key);
if (found) selectedMaterialDesc = found.description;
} catch (e) { console.log("Error visual material", e); }
}
function handleClientSelect(client: any) {
formData.client_id = client.id;
selectedClientName = client.name;
selectedClientStatus = client.is_active ?? true;
}
function handleClassSelect(item: any) {
formData.part_class = item.class_code;
selectedClassDesc = item.description_es || item.description_en || "";
}
function handleMaterialSelect(item: any) {
formData.material_type_key = item.key;
selectedMaterialDesc = item.description;
}
async function handleSubmit() {
error = null;
const activeCompanyId = companyStore.activeCompany?.id;
@@ -180,18 +221,21 @@
loading = true;
try {
const commonData = {
description_spanish: formData.description_spanish || null,
description_english: formData.description_english || null,
part_class: formData.part_class || null,
part_class: formData.part_class || null,
material_type_key: formData.material_type_key || null,
country_of_origin: formData.country_of_origin || 'MEX',
unit_of_measure: formData.unit_of_measure,
fraction: formData.fraction || null,
us_fraction: formData.us_fraction || null,
unit_weight: Number(formData.unit_weight) || 0,
weight_type: formData.weight_type || 'KG',
supplier: formData.supplier || null,
fda_key: formData.fda_key || null,
fcc_key: formData.fcc_key || null,
@@ -199,21 +243,21 @@
license_code: formData.license_code || null,
export_code: formData.export_code || null,
exclusion_symbol: formData.exclusion_symbol || null,
unit_cost: Number(formData.unit_cost) || 0,
currency_key: formData.currency_key || 'USD',
added_value: Number(formData.added_value) || 0,
commercial_part_number: formData.commercial_part_number || null,
alternate_unit_measure: formData.alternate_unit_measure || null,
part_photo: formData.part_photo || null,
added_value: Number(formData.added_value) || 0,
unit_cost: Number(formData.unit_cost) || 0,
currency_key: formData.currency_key || 'USD',
commercial_part_number: formData.commercial_part_number || null,
is_active: formData.is_active
};
if (isEdit && id) {
const response = await partsApi.update(id, commonData, activeCompanyId);
if (response.error) throw new Error(response.error);
} else {
const createData: PartCreate = {
...commonData,
company_id: activeCompanyId,
@@ -228,13 +272,7 @@
} catch (e: any) {
console.error("Error en el guardado:", e);
if (e.message?.includes('already exists')) {
error = `El número de parte ${formData.part_number} ya existe para este cliente.`;
} else if (e.message?.includes('foreign key')) {
error = `Error: Uno de los catálogos (País, Moneda, UM) no es válido.`;
} else {
error = e.message || 'Error inesperado al guardar';
}
error = e.message || 'Error inesperado al guardar';
} finally {
loading = false;
}
@@ -289,51 +327,76 @@
</div>
<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} />
<Label for="part_class_client" class="required">Clase (Anexo 24 - Cliente)</Label>
<div class="flex gap-2">
<div class="relative flex-1">
<div class="absolute left-3 top-2.5 text-muted-foreground">
<Package class="h-4 w-4" />
</div>
<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}
/>
</div>
<Button variant="outline" size="icon" type="button" onclick={() => showClassModal = true}>
<Search class="h-4 w-4" />
</Button>
</div>
{#if selectedClassDesc}
<div class="text-xs text-primary font-medium px-1 animate-in fade-in flex items-center gap-1">
<Tag class="h-3 w-3" />
{selectedClassDesc}
</div>
{/if}
</div>
<div class="grid gap-2">
<Label for="part_class_material">Tipo Material (Catálogo General)</Label>
<div class="flex gap-2">
<div class="relative flex-1">
<div class="absolute left-3 top-2.5 text-muted-foreground">
<Layers class="h-4 w-4" />
</div>
<Input
id="part_class_material"
bind:value={formData.material_type_key}
maxlength={8}
placeholder="Seleccione Material..."
class="pl-9 font-mono cursor-pointer"
readonly
onclick={() => showMaterialModal = true}
/>
</div>
<Button variant="outline" size="icon" type="button" onclick={() => showMaterialModal = true}>
<Search class="h-4 w-4" />
</Button>
</div>
{#if selectedMaterialDesc}
<div class="text-xs text-blue-600 font-medium px-1 animate-in fade-in flex items-center gap-1">
<Layers class="h-3 w-3" />
{selectedMaterialDesc}
</div>
{/if}
<p class="text-[10px] text-muted-foreground">Opcional: Clasificación adicional por tipo de material.</p>
</div>
<div class="grid gap-2 md:col-span-2">
<Label for="country">País Origen (ISO)</Label>
<Input id="country" bind:value={formData.country_of_origin} maxlength={3} placeholder="MEX"/>
</div>
</div>
<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"/> 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="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"}
</Select.Trigger>
<Select.Content>
<Select.Item value="PZ">Pieza (PZ)</Select.Item>
<Select.Item value="KG">Kilogramo (KG)</Select.Item>
<Select.Item value="EA">Elemento (EA)</Select.Item>
<Select.Item value="L">Litro (L)</Select.Item>
<Select.Item value="M">Metro (M)</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
</div>
<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, 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">Tipos de Moneda</Label>
@@ -346,7 +409,6 @@
</Select.Content>
</Select.Root>
</div>
<div class="grid gap-2">
<Label for="unit_cost">Costo Unitario</Label>
<div class="relative">
@@ -354,7 +416,6 @@
<Input type="number" step="0.0001" id="unit_cost" bind:value={formData.unit_cost} class="pl-7" />
</div>
</div>
<div class="grid gap-2">
<Label for="unit_weight">Peso Unitario</Label>
<div class="flex gap-2">
@@ -373,7 +434,6 @@
<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" />
@@ -388,7 +448,6 @@
<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'}
@@ -396,15 +455,7 @@
{: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"
/>
<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>
@@ -417,47 +468,38 @@
</Tabs.Content>
<Tabs.Content value="opciones" class="space-y-6 pt-4 animate-in fade-in duration-300">
<div class="space-y-4 p-4 border rounded-lg bg-card">
<h3 class="font-medium text-sm text-muted-foreground">Configuración General</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid gap-2">
<Label for="fcc">Clave FCC</Label>
<Input id="fcc" bind:value={formData.fcc_key} maxlength={30} />
</div>
<div class="grid gap-2">
<Label for="supplier">Proveedor (Textil)</Label>
<Input id="supplier" bind:value={formData.supplier} maxlength={14} />
</div>
</div>
<div class="grid gap-2">
<Label for="photo" class="flex items-center gap-2"><ImageIcon class="h-4 w-4"/> URL Imagen de la parte</Label>
<Input id="photo" bind:value={formData.part_photo} maxlength={255} placeholder="https://..." />
</div>
</div>
</Tabs.Content>
<Tabs.Content value="opcionales2" class="space-y-6 pt-4 animate-in fade-in duration-300">
<div class="p-4 border rounded-lg space-y-4">
<h3 class="font-medium text-sm text-muted-foreground">FDA (Food and Drug Administration)</h3>
<h3 class="font-medium text-sm text-muted-foreground">FDA</h3>
<div class="grid gap-2">
<Label for="fda">Clave FDA</Label>
<Input id="fda" bind:value={formData.fda_key} maxlength={20} />
</div>
</div>
</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">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" />
@@ -471,41 +513,31 @@
onclick={() => showClientModal = true}
/>
</div>
<Button variant="outline" class="shrink-0" onclick={() => showClientModal = true}>
<Button variant="outline" class="shrink-0" type="button" 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">
<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">
<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>
<span class="text-red-600 flex items-center gap-1 text-xs font-medium"><XCircle class="h-3 w-3"/> 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">
<Label for="comm_pn">Número de Parte Comercial</Label>
<Input id="comm_pn" bind:value={formData.commercial_part_number} maxlength={70} />
</div>
<div class="p-4 border rounded-lg space-y-4">
<h3 class="font-medium text-sm text-muted-foreground">Factor Conversión</h3>
<div class="grid gap-2">
<Label for="alt_um">UM Conversión (Alterna)</Label>
<Input id="alt_um" bind:value={formData.alternate_unit_measure} maxlength={14} />
</div>
<Label for="alt_um">UM Conversión (Alterna)</Label>
<Input id="alt_um" bind:value={formData.alternate_unit_measure} maxlength={14} />
</div>
<div class="p-4 border rounded-lg space-y-4 bg-slate-50 dark:bg-slate-900/30">
<h3 class="font-medium text-sm text-muted-foreground">Datos Regulatorios Adicionales</h3>
<h3 class="font-medium text-sm text-muted-foreground">Datos Regulatorios</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="fraction_mx" class="required">Fracción Arancelaria (MX)</Label>
@@ -529,49 +561,28 @@
</div>
</div>
</div>
<div class="flex items-center gap-3 p-4 border rounded-lg bg-card">
<Switch id="is_active" bind:checked={formData.is_active} disabled={loading} />
<div class="grid gap-0.5">
<Label for="is_active">Parte Activa en Sistema</Label>
<p class="text-xs text-muted-foreground">Habilitar o deshabilitar</p>
</div>
<Label for="is_active">Parte Activa en Sistema</Label>
</div>
</Tabs.Content>
</div>
<Tabs.List class="grid grid-cols-4 fixed bottom-24 left-1/2 -translate-x-1/2 w-[95%] max-w-2xl z-40 shadow-2xl bg-background border p-1 rounded-xl">
<Tabs.Trigger value="general" class="flex gap-2 items-center justify-center">
<Package class="h-4 w-4 hidden sm:block" /> General
</Tabs.Trigger>
<Tabs.Trigger value="opciones" class="flex gap-2 items-center justify-center">
<Settings class="h-4 w-4 hidden sm:block" /> Opciones
</Tabs.Trigger>
<Tabs.Trigger value="opcionales2" class="flex gap-2 items-center justify-center">
<FileText class="h-4 w-4 hidden sm:block" /> Opcionales 2
</Tabs.Trigger>
<Tabs.Trigger value="otros" class="flex gap-2 items-center justify-center">
<Settings class="h-4 w-4 hidden sm:block" /> Otros
</Tabs.Trigger>
<Tabs.Trigger value="general" class="flex gap-2 items-center justify-center"><Package class="h-4 w-4 hidden sm:block" /> General</Tabs.Trigger>
<Tabs.Trigger value="opciones" class="flex gap-2 items-center justify-center"><Settings class="h-4 w-4 hidden sm:block" /> Opciones</Tabs.Trigger>
<Tabs.Trigger value="opcionales2" class="flex gap-2 items-center justify-center"><FileText class="h-4 w-4 hidden sm:block" /> Opcionales 2</Tabs.Trigger>
<Tabs.Trigger value="otros" class="flex gap-2 items-center justify-center"><Settings class="h-4 w-4 hidden sm:block" /> Otros</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
</Card.Content>
</Card.Root>
<div class="fixed bottom-0 right-0 left-0 md:left-64 p-4 border-t bg-background/95 backdrop-blur z-50">
<div class="max-w-6xl mx-auto flex justify-end gap-4">
<Button variant="ghost" href="/dashboard/goods/parts" disabled={loading}>
Cancelar
</Button>
<Button variant="ghost" href="/dashboard/goods/parts" disabled={loading}>Cancelar</Button>
<Button type="submit" disabled={loading} class="min-w-[140px]">
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Save class="mr-2 h-4 w-4" />
{/if}
{#if loading}<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />{:else}<Save class="mr-2 h-4 w-4" />{/if}
{isEdit ? 'Actualizar' : 'Guardar'}
</Button>
</div>
@@ -584,6 +595,16 @@
onSelect={handleClientSelect}
/>
<ClassSelectorDialog
bind:open={showClassModal}
onSelect={handleClassSelect}
/>
<MaterialTypeSelectorDialog
bind:open={showMaterialModal}
onSelect={handleMaterialSelect}
/>
<style>
:global(.required::after) {
content: " *";