Se esta trabajando en el formulario de partes y sus relaciones
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
)
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
23
frontend/src/lib/api/dashboard/a76/material-types.ts
Normal file
23
frontend/src/lib/api/dashboard/a76/material-types.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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: " *";
|
||||
|
||||
Reference in New Issue
Block a user