Merge pull request 'feature/PROSEC-sector' (#164) from feature/PROSEC-sector into development
Reviewed-on: ADUANASOFT/anexo76#164
This commit is contained in:
@@ -46,7 +46,7 @@ class ClientProviderProgramsDTO(BaseModel):
|
||||
program_number: Optional[str] = Field(
|
||||
None, max_length=40, description="Program number"
|
||||
)
|
||||
prosec: Optional[int] = Field(None, description="PROSEC")
|
||||
prosec: Optional[str] = Field(None, max_length=8, description="PROSEC")
|
||||
prosec_authorization: Optional[str] = Field(
|
||||
None, max_length=20, description="PROSEC authorization"
|
||||
)
|
||||
|
||||
@@ -136,7 +136,7 @@ class ClientProviderPrograms(Base, TenantScopedMixin, TimestampMixin):
|
||||
# Program information
|
||||
program: Mapped[Optional[str]] = mapped_column(String(7))
|
||||
program_number: Mapped[Optional[str]] = mapped_column(String(40))
|
||||
prosec: Mapped[Optional[int]] = mapped_column(SmallInteger)
|
||||
prosec: Mapped[Optional[str]] = mapped_column(String(8))
|
||||
prosec_authorization: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
secon_auth_date: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
manufacturer_id: Mapped[Optional[str]] = mapped_column(String(25))
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
<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 * as Table from '$lib/components/ui/table';
|
||||
import { Search, Loader2, Factory } from 'lucide-svelte';
|
||||
import { sectorsApi, type Sector } from '$lib/api/dashboard/reference_data/sectors';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// --- PROPS ---
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onSelect
|
||||
}: {
|
||||
open: boolean;
|
||||
onSelect: (item: Sector) => void;
|
||||
} = $props();
|
||||
|
||||
// --- ESTADO ---
|
||||
let items = $state<Sector[]>([]);
|
||||
let loading = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let previousSearchTerm = '';
|
||||
let page = $state(1);
|
||||
let pageSize = 50;
|
||||
let hasMore = $state(true);
|
||||
let totalItems = $state(0);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
let bottomSentinel: HTMLElement | null = $state(null);
|
||||
let searchTimeout: any;
|
||||
let isInitialized = false;
|
||||
|
||||
// Cargar datos iniciales al abrir
|
||||
$effect(() => {
|
||||
if (open && !isInitialized) {
|
||||
isInitialized = true;
|
||||
previousSearchTerm = searchTerm;
|
||||
resetAndLoad();
|
||||
} else if (!open) {
|
||||
isInitialized = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Manejar búsqueda con debouncing
|
||||
$effect(() => {
|
||||
const term = searchTerm;
|
||||
if (isInitialized && term !== previousSearchTerm) {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
previousSearchTerm = term;
|
||||
resetAndLoad();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Configurar IntersectionObserver para infinite scroll
|
||||
$effect(() => {
|
||||
if (bottomSentinel && hasMore && !loading && !loadingMore && open) {
|
||||
if (observer) observer.disconnect();
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(bottomSentinel);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observer) observer.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
async function resetAndLoad() {
|
||||
page = 1;
|
||||
items = [];
|
||||
hasMore = true;
|
||||
await loadSectors(true);
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!hasMore || loading || loadingMore) return;
|
||||
page += 1;
|
||||
await loadSectors(false);
|
||||
}
|
||||
|
||||
async function loadSectors(isInitial: boolean) {
|
||||
if (isInitial) {
|
||||
loading = true;
|
||||
} else {
|
||||
loadingMore = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sectorsApi.list(page, pageSize);
|
||||
|
||||
if (response.error) {
|
||||
toast.error(`Error: ${response.error}`);
|
||||
hasMore = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let newItems = response.data?.items || [];
|
||||
totalItems = response.data?.total || 0;
|
||||
|
||||
if (searchTerm) {
|
||||
newItems = newItems.filter(
|
||||
(item) =>
|
||||
item.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.key.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (isInitial) {
|
||||
items = newItems;
|
||||
} else {
|
||||
items = [...items, ...newItems];
|
||||
}
|
||||
|
||||
hasMore = items.length < totalItems && newItems.length > 0;
|
||||
} catch (e: any) {
|
||||
console.error('Error loading sectors:', e);
|
||||
toast.error('Error al conectar con el servidor');
|
||||
hasMore = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(item: Sector) {
|
||||
if (onSelect) onSelect(item);
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="flex max-h-[90vh] flex-col sm:max-w-[800px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Seleccionar Sector PROSEC</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Seleccione el sector del catálogo. Escrolea para ver más.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="relative my-2 w-full">
|
||||
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Filtrar por clave o descripción..."
|
||||
class="pl-9"
|
||||
bind:value={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-h-[300px] flex-1 overflow-y-auto rounded-md border">
|
||||
{#if loading && items.length === 0}
|
||||
<div class="flex h-48 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-primary" />
|
||||
<p>Cargando catálogo...</p>
|
||||
</div>
|
||||
{:else if items.length === 0}
|
||||
<div class="flex h-48 flex-col items-center justify-center text-muted-foreground">
|
||||
<p>No se encontraron sectores.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[100px]">Clave</Table.Head>
|
||||
<Table.Head>Descripción</Table.Head>
|
||||
<Table.Head class="w-[100px]">Autorizado</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each items as item}
|
||||
<Table.Row
|
||||
class="cursor-pointer transition-colors hover:bg-accent/50"
|
||||
onclick={() => handleSelect(item)}
|
||||
>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-1">
|
||||
<Factory class="h-3 w-3 text-orange-500" />
|
||||
<span class="font-mono text-xs font-bold">
|
||||
{item.key}
|
||||
</span>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-sm font-medium">
|
||||
{item.description}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs {item.authorized
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'}"
|
||||
>
|
||||
{item.authorized ? 'Sí' : 'No'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
<div bind:this={bottomSentinel} class="flex h-10 items-center justify-center">
|
||||
{#if loadingMore}
|
||||
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<div class="mr-auto self-center text-xs text-muted-foreground">
|
||||
{items.length} de {totalItems} registros
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,224 @@
|
||||
<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 * as Table from '$lib/components/ui/table';
|
||||
import { Search, Loader2, MapPin } from 'lucide-svelte';
|
||||
import { statesApi, type State } from '$lib/api/dashboard/reference_data/states';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// --- PROPS ---
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onSelect
|
||||
}: {
|
||||
open: boolean;
|
||||
onSelect: (item: State) => void;
|
||||
} = $props();
|
||||
|
||||
// --- ESTADO ---
|
||||
let items = $state<State[]>([]);
|
||||
let loading = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let previousSearchTerm = '';
|
||||
let page = $state(1);
|
||||
let pageSize = 50;
|
||||
let hasMore = $state(true);
|
||||
let totalItems = $state(0);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
let bottomSentinel: HTMLElement | null = $state(null);
|
||||
let searchTimeout: any;
|
||||
let isInitialized = false;
|
||||
|
||||
// Cargar datos iniciales al abrir
|
||||
$effect(() => {
|
||||
if (open && !isInitialized) {
|
||||
isInitialized = true;
|
||||
previousSearchTerm = searchTerm;
|
||||
resetAndLoad();
|
||||
} else if (!open) {
|
||||
isInitialized = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Manejar búsqueda con debouncing
|
||||
$effect(() => {
|
||||
const term = searchTerm;
|
||||
if (isInitialized && term !== previousSearchTerm) {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
previousSearchTerm = term;
|
||||
resetAndLoad();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Configurar IntersectionObserver para infinite scroll
|
||||
$effect(() => {
|
||||
if (bottomSentinel && hasMore && !loading && !loadingMore && open) {
|
||||
if (observer) observer.disconnect();
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(bottomSentinel);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observer) observer.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
async function resetAndLoad() {
|
||||
page = 1;
|
||||
items = [];
|
||||
hasMore = true;
|
||||
await loadStates(true);
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!hasMore || loading || loadingMore) return;
|
||||
page += 1;
|
||||
await loadStates(false);
|
||||
}
|
||||
|
||||
async function loadStates(isInitial: boolean) {
|
||||
if (isInitial) {
|
||||
loading = true;
|
||||
} else {
|
||||
loadingMore = true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Note: statesApi.list takes page, pageSize, and searchTerm?
|
||||
// Wait, let me check statesApi.list signature again.
|
||||
// It only takes page and pageSize! I need to check if it supports search.
|
||||
const response = await statesApi.list(page, pageSize);
|
||||
|
||||
if (response.error) {
|
||||
toast.error(`Error: ${response.error}`);
|
||||
hasMore = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Local filtering if search term exists (temporary workaround if API doesn't support it)
|
||||
let newItems = response.data?.items || [];
|
||||
totalItems = response.data?.total || 0;
|
||||
|
||||
if (searchTerm) {
|
||||
newItems = newItems.filter(
|
||||
(item) =>
|
||||
item.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.m3_key.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (isInitial) {
|
||||
items = newItems;
|
||||
} else {
|
||||
items = [...items, ...newItems];
|
||||
}
|
||||
|
||||
hasMore = items.length < totalItems && newItems.length > 0;
|
||||
} catch (e: any) {
|
||||
console.error('Error loading states:', e);
|
||||
toast.error('Error al conectar con el servidor');
|
||||
hasMore = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(item: State) {
|
||||
if (onSelect) onSelect(item);
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="flex max-h-[90vh] flex-col sm:max-w-[800px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Seleccionar Estado</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Seleccione el estado del catálogo. Escrolea para ver más.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="relative my-2 w-full">
|
||||
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Filtrar por clave o descripción..."
|
||||
class="pl-9"
|
||||
bind:value={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-h-[300px] flex-1 overflow-y-auto rounded-md border">
|
||||
{#if loading && items.length === 0}
|
||||
<div class="flex h-48 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-primary" />
|
||||
<p>Cargando catálogo...</p>
|
||||
</div>
|
||||
{:else if items.length === 0}
|
||||
<div class="flex h-48 flex-col items-center justify-center text-muted-foreground">
|
||||
<p>No se encontraron estados.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[100px]">Clave M3</Table.Head>
|
||||
<Table.Head>Descripción</Table.Head>
|
||||
<Table.Head class="w-[80px]">MEX</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each items as item}
|
||||
<Table.Row
|
||||
class="cursor-pointer transition-colors hover:bg-accent/50"
|
||||
onclick={() => handleSelect(item)}
|
||||
>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-1">
|
||||
<MapPin class="h-3 w-3 text-red-500" />
|
||||
<span class="font-mono text-xs font-bold">
|
||||
{item.m3_key}
|
||||
</span>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-sm font-medium">
|
||||
{item.description}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs">
|
||||
{item.mex_key || '-'}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
<div bind:this={bottomSentinel} class="flex h-10 items-center justify-center">
|
||||
{#if loadingMore}
|
||||
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<div class="mr-auto self-center text-xs text-muted-foreground">
|
||||
{items.length} de {totalItems} registros
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -22,10 +22,29 @@
|
||||
FileText,
|
||||
Settings,
|
||||
User,
|
||||
Trash2
|
||||
Trash2,
|
||||
Search,
|
||||
Globe,
|
||||
MapPin as MapPinIcon,
|
||||
Factory,
|
||||
Calendar,
|
||||
Hash,
|
||||
ShieldCheck,
|
||||
Award,
|
||||
Fingerprint,
|
||||
Briefcase
|
||||
} from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// Componentes Compartidos (Modales)
|
||||
import CountrySelectorDialog from '$lib/components/dashboard/goods/modales/country-selector-dialog.svelte';
|
||||
import StateSelectorDialog from '$lib/components/dashboard/shared/modals/state-selector-dialog.svelte';
|
||||
import SectorSelectorDialog from '$lib/components/dashboard/shared/modals/sector-selector-dialog.svelte';
|
||||
|
||||
import { type Country } from '$lib/api/dashboard/reference_data/countries';
|
||||
import { type State } from '$lib/api/dashboard/reference_data/states';
|
||||
import { type Sector } from '$lib/api/dashboard/reference_data/sectors';
|
||||
|
||||
// API & Stores
|
||||
import { clientsProvidersApi } from '$lib/api/dashboard/a76/clients-providers';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
@@ -73,17 +92,30 @@
|
||||
program: '',
|
||||
program_number: '',
|
||||
authorization_date_str: '', // String para input date
|
||||
prosec: 0,
|
||||
prosec: '',
|
||||
manufacturer_id: '',
|
||||
tax_id: '',
|
||||
ctpat_svi: '',
|
||||
is_certified_company: '0'
|
||||
is_certified_company: false
|
||||
});
|
||||
|
||||
let formData = $state(getEmptyForm());
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// --- ESTADO PARA MODALES ---
|
||||
let countryModalOpen = $state(false);
|
||||
let stateModalOpen = $state(false);
|
||||
let sectorModalOpen = $state(false);
|
||||
|
||||
const scaiiPrograms = [
|
||||
{ value: 'IMMEX', label: 'IMMEX' },
|
||||
{ value: 'PROSEC', label: 'PROSEC' },
|
||||
{ value: 'ALTEX', label: 'ALTEX' },
|
||||
{ value: 'ECEX', label: 'ECEX' },
|
||||
{ value: 'DRAWBACK', label: 'DRAWBACK' }
|
||||
];
|
||||
|
||||
// --- UTILIDADES ---
|
||||
const intDateToString = (d?: number | null) =>
|
||||
d
|
||||
@@ -138,11 +170,11 @@
|
||||
program: prog.program || '',
|
||||
program_number: prog.program_number || '',
|
||||
authorization_date_str: intDateToString(prog.secon_auth_date),
|
||||
prosec: prog.prosec || 0,
|
||||
prosec: prog.prosec ? String(prog.prosec) : '',
|
||||
manufacturer_id: prog.manufacturer_id || '',
|
||||
tax_id: prog.tax_id || '',
|
||||
ctpat_svi: prog.ctpat_svi || '',
|
||||
is_certified_company: prog.is_certified_company || '0'
|
||||
is_certified_company: prog.is_certified_company === '1'
|
||||
};
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -202,11 +234,11 @@
|
||||
program: clean(formData.program),
|
||||
program_number: clean(formData.program_number),
|
||||
secon_auth_date: stringDateToInt(formData.authorization_date_str),
|
||||
prosec: Number(formData.prosec) || null,
|
||||
prosec: clean(formData.prosec),
|
||||
manufacturer_id: clean(formData.manufacturer_id),
|
||||
tax_id: clean(formData.tax_id),
|
||||
ctpat_svi: clean(formData.ctpat_svi),
|
||||
is_certified_company: clean(formData.is_certified_company)
|
||||
is_certified_company: formData.is_certified_company ? '1' : '0'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -470,17 +502,44 @@
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="state">Estado</Label>
|
||||
<Input id="state" bind:value={formData.state} maxlength={30} disabled={loading} />
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
id="state"
|
||||
bind:value={formData.state}
|
||||
maxlength={30}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onclick={() => (stateModalOpen = true)}
|
||||
disabled={loading}
|
||||
title="Buscar Estado"
|
||||
>
|
||||
<Search size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="country">País (ISO)</Label>
|
||||
<Input
|
||||
id="country"
|
||||
bind:value={formData.country}
|
||||
placeholder="MEX"
|
||||
maxlength={3}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
id="country"
|
||||
bind:value={formData.country}
|
||||
placeholder="MEX"
|
||||
maxlength={3}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onclick={() => (countryModalOpen = true)}
|
||||
disabled={loading}
|
||||
title="Buscar País"
|
||||
>
|
||||
<Globe size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -509,77 +568,160 @@
|
||||
>Información sobre IMMEX, PROSEC y otras certificaciones.</Card.Description
|
||||
>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="program">Programa</Label>
|
||||
<Input
|
||||
id="program"
|
||||
bind:value={formData.program}
|
||||
placeholder="IMMEX"
|
||||
maxlength={7}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Card.Content class="space-y-8">
|
||||
<!-- Section: Promotion Programs -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Briefcase size={18} />
|
||||
<span>Programas de Fomento</span>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="program_num">Número de Programa</Label>
|
||||
<Input
|
||||
id="program_num"
|
||||
bind:value={formData.program_number}
|
||||
maxlength={40}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Separator />
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="grid gap-2 lg:col-span-1">
|
||||
<Label for="program" class="flex items-center gap-2">
|
||||
<Building2 size={14} class="text-muted-foreground" />
|
||||
Programa
|
||||
</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={formData.program}
|
||||
onValueChange={(v) => (formData.program = v)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Select.Trigger id="program" class="w-full">
|
||||
{formData.program || 'Selecciona un programa'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each scaiiPrograms as prog}
|
||||
<Select.Item value={prog.value} label={prog.label}>
|
||||
{prog.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="program_num" class="flex items-center gap-2">
|
||||
<Hash size={14} class="text-muted-foreground" />
|
||||
Número de Programa
|
||||
</Label>
|
||||
<Input
|
||||
id="program_num"
|
||||
bind:value={formData.program_number}
|
||||
maxlength={40}
|
||||
disabled={loading}
|
||||
placeholder="P. ej. 1234-2024"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="auth_date" class="flex items-center gap-2">
|
||||
<Calendar size={14} class="text-muted-foreground" />
|
||||
Fecha Autorización
|
||||
</Label>
|
||||
<Input
|
||||
id="auth_date"
|
||||
type="date"
|
||||
bind:value={formData.authorization_date_str}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="prosec" class="flex items-center gap-2">
|
||||
<Factory size={14} class="text-muted-foreground" />
|
||||
PROSEC (Sector)
|
||||
</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
id="prosec"
|
||||
bind:value={formData.prosec}
|
||||
disabled={loading}
|
||||
placeholder="Ej: XII, IV"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onclick={() => (sectorModalOpen = true)}
|
||||
disabled={loading}
|
||||
title="Buscar Sector"
|
||||
>
|
||||
<Search size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="grid gap-2">
|
||||
<Label for="auth_date">Fecha Autorización</Label>
|
||||
<Input
|
||||
id="auth_date"
|
||||
type="date"
|
||||
bind:value={formData.authorization_date_str}
|
||||
disabled={loading}
|
||||
/>
|
||||
<!-- Section: Industrial Identification -->
|
||||
<div class="space-y-4 pt-4">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Fingerprint size={18} />
|
||||
<span>Identificación Industrial</span>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="tax_id">Tax ID (Extranjero)</Label>
|
||||
<Input
|
||||
id="tax_id"
|
||||
bind:value={formData.tax_id}
|
||||
maxlength={30}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="man_id">Manufacturer ID</Label>
|
||||
<Input
|
||||
id="man_id"
|
||||
bind:value={formData.manufacturer_id}
|
||||
maxlength={25}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Separator />
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="tax_id" class="flex items-center gap-2">
|
||||
<Globe size={14} class="text-muted-foreground" />
|
||||
Tax ID (Extranjero)
|
||||
</Label>
|
||||
<Input
|
||||
id="tax_id"
|
||||
bind:value={formData.tax_id}
|
||||
maxlength={30}
|
||||
disabled={loading}
|
||||
placeholder="Identificador fiscal extranjero"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="man_id" class="flex items-center gap-2">
|
||||
<FileText size={14} class="text-muted-foreground" />
|
||||
Manufacturer ID (MID)
|
||||
</Label>
|
||||
<Input
|
||||
id="man_id"
|
||||
bind:value={formData.manufacturer_id}
|
||||
maxlength={25}
|
||||
disabled={loading}
|
||||
placeholder="P. ej. MXABCD12345"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="ctpat">C-TPAT / SVI</Label>
|
||||
<Input
|
||||
id="ctpat"
|
||||
bind:value={formData.ctpat_svi}
|
||||
maxlength={100}
|
||||
disabled={loading}
|
||||
/>
|
||||
<!-- Section: Certifications & Security -->
|
||||
<div class="space-y-4 pt-4">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<ShieldCheck size={18} />
|
||||
<span>Certificaciones y Seguridad</span>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="prosec">PROSEC (Sector)</Label>
|
||||
<Input
|
||||
id="prosec"
|
||||
type="number"
|
||||
bind:value={formData.prosec}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Separator />
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="ctpat" class="flex items-center gap-2">
|
||||
<Award size={14} class="text-muted-foreground" />
|
||||
C-TPAT / SVI
|
||||
</Label>
|
||||
<Input
|
||||
id="ctpat"
|
||||
bind:value={formData.ctpat_svi}
|
||||
maxlength={100}
|
||||
disabled={loading}
|
||||
placeholder="P. ej. SVI-12345"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 rounded-lg border bg-card/50 p-4">
|
||||
<Switch
|
||||
id="is_certified"
|
||||
bind:checked={formData.is_certified_company}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div class="grid gap-0.5">
|
||||
<Label for="is_certified">Empresa Certificada</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Indica si cuenta con certificación de empresa
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
@@ -670,3 +812,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODALES DE SELECCIÓN -->
|
||||
<CountrySelectorDialog
|
||||
bind:open={countryModalOpen}
|
||||
onSelect={(country) => (formData.country = country.m3_key)}
|
||||
/>
|
||||
|
||||
<StateSelectorDialog
|
||||
bind:open={stateModalOpen}
|
||||
onSelect={(state) => {
|
||||
formData.state = state.description;
|
||||
if (state.m3_key && !formData.country) {
|
||||
formData.country = state.m3_key;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<SectorSelectorDialog
|
||||
bind:open={sectorModalOpen}
|
||||
onSelect={(sector) => (formData.prosec = sector.key)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user