feature/correccion-file-picker

This commit is contained in:
2026-04-15 07:53:35 -06:00
parent 7d38ac2720
commit f3a79a0a56
6 changed files with 236 additions and 349 deletions

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import { Folder } from 'lucide-svelte';
import { cn } from '$lib/utils.js';
let {
value = '',
placeholder = 'Seleccionar archivo',
accept = '',
disabled = false,
onchange,
class: className = '',
id = ''
}: {
value?: string;
placeholder?: string;
accept?: string;
disabled?: boolean;
onchange?: (file: File) => void;
class?: string;
id?: string;
} = $props();
let fileInputEl: HTMLInputElement | null = $state(null);
let buttonEl: HTMLButtonElement | null = $state(null);
let focused = $state(false);
function handleButtonClick() {
if (disabled) return;
fileInputEl?.click();
}
function handleContainerClick(event: MouseEvent) {
if (disabled) return;
const target = event.target as HTMLElement | null;
if (target?.closest('button')) return;
handleButtonClick();
}
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) onchange?.(file);
// Resetear para que el mismo archivo pueda re-seleccionarse
input.value = '';
}
</script>
<!--
Contenedor unificado: el input de texto es sólo display (tabindex="-1"),
el botón de carpeta recibe el foco y su focus-state dibuja el halo sobre ambos.
-->
<div
class={cn(
'border-input bg-background dark:bg-input/30 shadow-xs flex h-9 w-full items-center rounded-md border transition-[color,box-shadow]',
!disabled && 'cursor-pointer',
focused && 'border-ring ring-ring/50 ring-[3px]',
disabled && 'cursor-not-allowed opacity-50',
className
)}
onclick={handleContainerClick}
>
<!-- read-only display: sin tabstop, sin foco propio.
Cuando value coincide con el placeholder (sin archivo seleccionado),
dejamos value vacío para que el navegador muestre el placeholder muted. -->
<input
type="text"
readonly
tabindex="-1"
aria-hidden="true"
value={value === placeholder ? '' : value}
{placeholder}
{disabled}
class="placeholder:text-muted-foreground min-w-0 flex-1 border-0 bg-transparent px-3 py-1 text-base shadow-none outline-none md:text-sm"
onfocus={() => buttonEl?.focus()}
/>
<!-- input file real, oculto -->
<input
type="file"
class="hidden"
{accept}
{id}
bind:this={fileInputEl}
onchange={handleFileChange}
/>
<!-- separador + botón, único elemento focusable -->
<button
type="button"
{disabled}
bind:this={buttonEl}
class="hover:text-foreground text-muted-foreground flex h-full w-10 shrink-0 items-center justify-center rounded-r-md border-0 bg-transparent outline-none transition-colors disabled:cursor-not-allowed"
onclick={(event) => {
event.stopPropagation();
handleButtonClick();
}}
onfocus={() => (focused = true)}
onblur={() => (focused = false)}
>
<Folder class="h-4 w-4" />
</button>
</div>

View File

@@ -1,7 +1,9 @@
import Root from "./input.svelte";
import FilePickerInput from "./file-picker-input.svelte";
export {
Root,
//
Root as Input,
FilePickerInput,
};

View File

@@ -5,6 +5,8 @@ export const obtenerAtajosEdicionAgente = (acciones: {
irContacto: () => void;
irDireccion: () => void;
irVU: () => void;
irDoda: () => void;
irAnam: () => void;
guardar: () => void;
cancelar: () => void;
}): ShortcutDef[] => [
@@ -28,6 +30,16 @@ export const obtenerAtajosEdicionAgente = (acciones: {
description: 'Tab Ventanilla Única',
action: acciones.irVU
},
{
key: 'Alt+Digit5',
description: 'Tab DODA',
action: acciones.irDoda
},
{
key: 'Alt+Digit6',
description: 'Tab ANAM',
action: acciones.irAnam
},
{
key: 'Ctrl+S',
description: 'Guardar',

View File

@@ -11,6 +11,11 @@ export const obtenerAtajosFormularioEmpresa = (acciones: {
{ key: 'Alt+Digit4', description: 'Pestaña Certificaciones', action: () => acciones.cambiarPestana('certificaciones') },
{ key: 'Alt+Digit5', description: 'Pestaña Direcciones', action: () => acciones.cambiarPestana('direcciones') },
{ key: 'Alt+Digit6', description: 'Pestaña Config', action: () => acciones.cambiarPestana('config') },
{
key: 'Alt+Digit7',
description: 'Pestaña Certificados Digitales',
action: () => acciones.cambiarPestana('certificados')
},
{
key: 'Control+KeyS',
description: 'Guardar Empresa',

View File

@@ -12,7 +12,7 @@
import CountryDialog from '$lib/components/dashboard/invoices/edit/items/fa/country-dialog.svelte';
import StateDialog from '$lib/components/dashboard/invoices/edit/items/fa/state-dialog.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Input, FilePickerInput } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Separator } from '$lib/components/ui/separator';
import { Badge } from '$lib/components/ui/badge';
@@ -32,7 +32,6 @@
FileKey,
Key,
Globe,
Folder,
Mail,
UserRound,
Fingerprint,
@@ -127,21 +126,6 @@
});
// --- 5. FUNCIONES ---
function handleLocalFileSelect(event: Event, targetKey: keyof typeof vuData) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
vuData[targetKey] = file.name;
if (targetKey === 'certificate_path') pendingVuFiles.certificate = file;
if (targetKey === 'key_path') pendingVuFiles.key = file;
if (targetKey === 'xml_files_path') pendingVuFiles.cove = file;
if (targetKey === 'doda_certificate_path') pendingVuFiles.dodaCertificate = file;
if (targetKey === 'doda_key_path') pendingVuFiles.dodaKey = file;
if (targetKey === 'doda_xml_files_path') pendingVuFiles.dodaCove = file;
toast.success(`Archivo ${file.name} seleccionado`);
}
}
async function uploadPendingVuFiles(companyId: string, brokerKey: string) {
if (pendingVuFiles.certificate) {
const res = await uploadCustomsBrokerVuFile(
@@ -408,6 +392,8 @@
irContacto: () => (activeTab = 'contact'),
irDireccion: () => (activeTab = 'address'),
irVU: () => (activeTab = 'vu'),
irDoda: () => (activeTab = 'doda'),
irAnam: () => (activeTab = 'anam'),
guardar: handleSave,
cancelar: handleCancel
})
@@ -791,35 +777,17 @@
class="ml-1 inline text-muted-foreground"
/></Label
>
<div class="flex items-center gap-2">
<Input
value={getFileDisplayName(
vuData.certificate_path,
'CER',
'Seleccionar archivo .cer'
)}
readonly
placeholder="Seleccionar archivo .cer"
disabled={loading}
class="h-10 flex-1"
/>
<input
type="file"
class="hidden"
onchange={(e) => handleLocalFileSelect(e, 'certificate_path')}
id="vu-cert-file"
/>
<Button
type="button"
variant="outline"
size="icon"
class="h-10 w-10 shrink-0"
disabled={loading}
onclick={() => document.getElementById('vu-cert-file')?.click()}
>
<Folder class="h-4 w-4" />
</Button>
</div>
<FilePickerInput
value={getFileDisplayName(vuData.certificate_path, 'CER', 'Seleccionar archivo .cer')}
placeholder="Seleccionar archivo .cer"
accept=".cer"
disabled={loading}
onchange={(file) => {
vuData.certificate_path = file.name;
pendingVuFiles.certificate = file;
toast.success(`Archivo ${file.name} seleccionado`);
}}
/>
</div>
<div class="grid gap-2">
<Label
@@ -828,35 +796,17 @@
class="ml-1 inline text-muted-foreground"
/></Label
>
<div class="flex items-center gap-2">
<Input
value={getFileDisplayName(
vuData.key_path,
'KEY',
'Seleccionar archivo .key'
)}
readonly
placeholder="Seleccionar archivo .key"
disabled={loading}
class="h-10 flex-1"
/>
<input
type="file"
class="hidden"
onchange={(e) => handleLocalFileSelect(e, 'key_path')}
id="vu-key-file"
/>
<Button
type="button"
variant="outline"
size="icon"
class="h-10 w-10 shrink-0"
disabled={loading}
onclick={() => document.getElementById('vu-key-file')?.click()}
>
<Folder class="h-4 w-4" />
</Button>
</div>
<FilePickerInput
value={getFileDisplayName(vuData.key_path, 'KEY', 'Seleccionar archivo .key')}
placeholder="Seleccionar archivo .key"
accept=".key"
disabled={loading}
onchange={(file) => {
vuData.key_path = file.name;
pendingVuFiles.key = file;
toast.success(`Archivo ${file.name} seleccionado`);
}}
/>
</div>
</div>
@@ -884,7 +834,7 @@
/></Label
>
<Select.Root type="single" bind:value={vuData.vu_figure_type} disabled={loading}>
<Select.Trigger class="h-10">
<Select.Trigger class="h-10 w-full">
{vuData.vu_figure_type || 'Seleccionar tipo de figura'}
</Select.Trigger>
<Select.Content>
@@ -936,35 +886,16 @@
class="ml-1 inline text-muted-foreground"
/></Label
>
<div class="flex items-center gap-2">
<Input
value={getFileDisplayName(
vuData.xml_files_path,
'COVE',
'Seleccionar archivo COVE'
)}
readonly
placeholder="Seleccionar archivo COVE"
disabled={loading}
class="h-10 flex-1"
/>
<input
type="file"
class="hidden"
onchange={(e) => handleLocalFileSelect(e, 'xml_files_path')}
id="vu-cove-file"
/>
<Button
type="button"
variant="outline"
size="icon"
class="h-10 w-10 shrink-0"
disabled={loading}
onclick={() => document.getElementById('vu-cove-file')?.click()}
>
<Folder class="h-4 w-4" />
</Button>
</div>
<FilePickerInput
value={getFileDisplayName(vuData.xml_files_path, 'COVE', 'Seleccionar archivo COVE')}
placeholder="Seleccionar archivo COVE"
disabled={loading}
onchange={(file) => {
vuData.xml_files_path = file.name;
pendingVuFiles.cove = file;
toast.success(`Archivo ${file.name} seleccionado`);
}}
/>
</div>
</div>
@@ -1062,35 +993,17 @@
class="ml-1 inline text-muted-foreground"
/></Label
>
<div class="flex items-center gap-2">
<Input
value={getFileDisplayName(
vuData.doda_certificate_path,
'CER',
'Seleccionar archivo .cer'
)}
readonly
placeholder="Seleccionar archivo .cer"
disabled={loading}
class="h-10 flex-1"
/>
<input
type="file"
class="hidden"
onchange={(e) => handleLocalFileSelect(e, 'doda_certificate_path')}
id="doda-cert-file"
/>
<Button
type="button"
variant="outline"
size="icon"
class="h-10 w-10 shrink-0"
disabled={loading}
onclick={() => document.getElementById('doda-cert-file')?.click()}
>
<Folder class="h-4 w-4" />
</Button>
</div>
<FilePickerInput
value={getFileDisplayName(vuData.doda_certificate_path, 'CER', 'Seleccionar archivo .cer')}
placeholder="Seleccionar archivo .cer"
accept=".cer"
disabled={loading}
onchange={(file) => {
vuData.doda_certificate_path = file.name;
pendingVuFiles.dodaCertificate = file;
toast.success(`Archivo ${file.name} seleccionado`);
}}
/>
</div>
<div class="grid gap-2">
<Label
@@ -1099,35 +1012,17 @@
class="ml-1 inline text-muted-foreground"
/></Label
>
<div class="flex items-center gap-2">
<Input
value={getFileDisplayName(
vuData.doda_key_path,
'KEY',
'Seleccionar archivo .key'
)}
readonly
placeholder="Seleccionar archivo .key"
disabled={loading}
class="h-10 flex-1"
/>
<input
type="file"
class="hidden"
onchange={(e) => handleLocalFileSelect(e, 'doda_key_path')}
id="doda-key-file"
/>
<Button
type="button"
variant="outline"
size="icon"
class="h-10 w-10 shrink-0"
disabled={loading}
onclick={() => document.getElementById('doda-key-file')?.click()}
>
<Folder class="h-4 w-4" />
</Button>
</div>
<FilePickerInput
value={getFileDisplayName(vuData.doda_key_path, 'KEY', 'Seleccionar archivo .key')}
placeholder="Seleccionar archivo .key"
accept=".key"
disabled={loading}
onchange={(file) => {
vuData.doda_key_path = file.name;
pendingVuFiles.dodaKey = file;
toast.success(`Archivo ${file.name} seleccionado`);
}}
/>
</div>
</div>
@@ -1154,35 +1049,16 @@
class="ml-1 inline text-muted-foreground"
/></Label
>
<div class="flex items-center gap-2">
<Input
value={getFileDisplayName(
vuData.doda_xml_files_path,
'XML',
'Seleccionar archivo XML'
)}
readonly
placeholder="Seleccionar archivo XML"
disabled={loading}
class="h-10 flex-1"
/>
<input
type="file"
class="hidden"
onchange={(e) => handleLocalFileSelect(e, 'doda_xml_files_path')}
id="doda-xml-file"
/>
<Button
type="button"
variant="outline"
size="icon"
class="h-10 w-10 shrink-0"
disabled={loading}
onclick={() => document.getElementById('doda-xml-file')?.click()}
>
<Folder class="h-4 w-4" />
</Button>
</div>
<FilePickerInput
value={getFileDisplayName(vuData.doda_xml_files_path, 'XML', 'Seleccionar archivo XML')}
placeholder="Seleccionar archivo XML"
disabled={loading}
onchange={(file) => {
vuData.doda_xml_files_path = file.name;
pendingVuFiles.dodaCove = file;
toast.success(`Archivo ${file.name} seleccionado`);
}}
/>
</div>
</div>
</Card.Content>

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Button, buttonVariants } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Input, FilePickerInput } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Tabs from '$lib/components/ui/tabs';
import { Switch } from '$lib/components/ui/switch';
@@ -460,66 +460,36 @@
}
}
async function handleCertificateSelect(event: Event, type: string) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
// Validar extensión de forma estricta según el tipo de campo:
// - *_cer => solo .cer
// - *_key => solo .key
async function handleCertificateFile(file: File, type: string) {
const ext = file.name.split('.').pop()?.toLowerCase();
if (type.toLowerCase().endsWith('_cer') && ext !== 'cer') {
error = 'El archivo debe ser .cer';
target.value = ''; // Reset
return;
}
if (type.toLowerCase().endsWith('_key') && ext !== 'key') {
error = 'El archivo debe ser .key';
target.value = ''; // Reset
return;
}
// Validar tamaño (5MB)
if (file.size > 5 * 1024 * 1024) {
error = 'El archivo es demasiado grande. Máximo 5MB';
target.value = '';
return;
}
if (!isEdit) {
error = 'Debes guardar la empresa primero antes de subir certificados.';
target.value = '';
return;
}
loading = true;
error = null;
successMessage = null;
try {
const response = await uploadCompanyCertificate(Number(id), type, file);
if (response.error) {
throw new Error(response.error);
}
// Actualizar el valor en el formulario para reflejar que hay un archivo
// Nota: La respuesta trae file_path
if (response.error) throw new Error(response.error);
if (response.data) {
const path = response.data.file_path;
// Actualizar el campo correspondiente en formData
// type coincide con las claves del formData (fiel_cer, fiel_key, etc.)
(formData as any)[type] = path;
(formData as any)[type] = response.data.file_path;
successMessage = `Certificado ${type} subido correctamente`;
// Opcional: recargar datos completos para asegurar consistencia
// await fetchData(String(id));
}
} catch (e: any) {
error = `Error al subir certificado: ${e.message}`;
target.value = '';
} finally {
loading = false;
}
@@ -1600,47 +1570,23 @@
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="fiel_cer">Archivo .CER</Label>
<div class="flex items-center gap-2">
<Input
id="fiel_cer"
type="file"
accept=".cer"
class="hidden"
onchange={(e) => handleCertificateSelect(e, 'fiel_cer')}
/>
<Label
for="fiel_cer"
class={buttonVariants({ variant: 'outline', size: 'default' }) +
' w-full cursor-pointer justify-start text-left font-normal'}
>
<Upload class="mr-2 h-4 w-4" />
<span class="truncate">
{getFileDisplayName(formData.fiel_cer, 'CER', 'Seleccionar archivo .cer')}
</span>
</Label>
</div>
<FilePickerInput
value={getFileDisplayName(formData.fiel_cer, 'CER', 'Seleccionar archivo .cer')}
placeholder="Seleccionar archivo .cer"
accept=".cer"
disabled={loading}
onchange={(file) => handleCertificateFile(file, 'fiel_cer')}
/>
</div>
<div class="grid gap-2">
<Label for="fiel_key">Archivo .KEY</Label>
<div class="flex items-center gap-2">
<Input
id="fiel_key"
type="file"
accept=".key"
class="hidden"
onchange={(e) => handleCertificateSelect(e, 'fiel_key')}
/>
<Label
for="fiel_key"
class={buttonVariants({ variant: 'outline', size: 'default' }) +
' w-full cursor-pointer justify-start text-left font-normal'}
>
<Upload class="mr-2 h-4 w-4" />
<span class="truncate">
{getFileDisplayName(formData.fiel_key, 'KEY', 'Seleccionar archivo .key')}
</span>
</Label>
</div>
<FilePickerInput
value={getFileDisplayName(formData.fiel_key, 'KEY', 'Seleccionar archivo .key')}
placeholder="Seleccionar archivo .key"
accept=".key"
disabled={loading}
onchange={(file) => handleCertificateFile(file, 'fiel_key')}
/>
</div>
<div class="grid gap-2">
<Label for="fiel_pass">Contraseña</Label>
@@ -1670,55 +1616,23 @@
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="cfdi_cert_cer">Archivo .CER</Label>
<div class="flex items-center gap-2">
<Input
id="cfdi_cert_cer"
type="file"
accept=".cer"
class="hidden"
onchange={(e) => handleCertificateSelect(e, 'cfdi_cert_cer')}
/>
<Label
for="cfdi_cert_cer"
class={buttonVariants({ variant: 'outline', size: 'default' }) +
' w-full cursor-pointer justify-start text-left font-normal'}
>
<Upload class="mr-2 h-4 w-4" />
<span class="truncate">
{getFileDisplayName(
formData.cfdi_cert_cer,
'CER',
'Seleccionar archivo .cer'
)}
</span>
</Label>
</div>
<FilePickerInput
value={getFileDisplayName(formData.cfdi_cert_cer, 'CER', 'Seleccionar archivo .cer')}
placeholder="Seleccionar archivo .cer"
accept=".cer"
disabled={loading}
onchange={(file) => handleCertificateFile(file, 'cfdi_cert_cer')}
/>
</div>
<div class="grid gap-2">
<Label for="cfdi_cert_key">Archivo .KEY</Label>
<div class="flex items-center gap-2">
<Input
id="cfdi_cert_key"
type="file"
accept=".key"
class="hidden"
onchange={(e) => handleCertificateSelect(e, 'cfdi_cert_key')}
/>
<Label
for="cfdi_cert_key"
class={buttonVariants({ variant: 'outline', size: 'default' }) +
' w-full cursor-pointer justify-start text-left font-normal'}
>
<Upload class="mr-2 h-4 w-4" />
<span class="truncate">
{getFileDisplayName(
formData.cfdi_cert_key,
'KEY',
'Seleccionar archivo .key'
)}
</span>
</Label>
</div>
<FilePickerInput
value={getFileDisplayName(formData.cfdi_cert_key, 'KEY', 'Seleccionar archivo .key')}
placeholder="Seleccionar archivo .key"
accept=".key"
disabled={loading}
onchange={(file) => handleCertificateFile(file, 'cfdi_cert_key')}
/>
</div>
<div class="grid gap-2">
<Label for="cfdi_cert_pass">Contraseña</Label>
@@ -1760,47 +1674,23 @@
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="cancel_cer">Archivo .CER</Label>
<div class="flex items-center gap-2">
<Input
id="cancel_cer"
type="file"
accept=".cer"
class="hidden"
onchange={(e) => handleCertificateSelect(e, 'cancel_cer')}
/>
<Label
for="cancel_cer"
class={buttonVariants({ variant: 'outline', size: 'default' }) +
' w-full cursor-pointer justify-start text-left font-normal'}
>
<Upload class="mr-2 h-4 w-4" />
<span class="truncate">
{getFileDisplayName(formData.cancel_cer, 'CER', 'Seleccionar archivo .cer')}
</span>
</Label>
</div>
<FilePickerInput
value={getFileDisplayName(formData.cancel_cer, 'CER', 'Seleccionar archivo .cer')}
placeholder="Seleccionar archivo .cer"
accept=".cer"
disabled={loading}
onchange={(file) => handleCertificateFile(file, 'cancel_cer')}
/>
</div>
<div class="grid gap-2">
<Label for="cancel_key">Archivo .KEY</Label>
<div class="flex items-center gap-2">
<Input
id="cancel_key"
type="file"
accept=".key"
class="hidden"
onchange={(e) => handleCertificateSelect(e, 'cancel_key')}
/>
<Label
for="cancel_key"
class={buttonVariants({ variant: 'outline', size: 'default' }) +
' w-full cursor-pointer justify-start text-left font-normal'}
>
<Upload class="mr-2 h-4 w-4" />
<span class="truncate">
{getFileDisplayName(formData.cancel_key, 'KEY', 'Seleccionar archivo .key')}
</span>
</Label>
</div>
<FilePickerInput
value={getFileDisplayName(formData.cancel_key, 'KEY', 'Seleccionar archivo .key')}
placeholder="Seleccionar archivo .key"
accept=".key"
disabled={loading}
onchange={(file) => handleCertificateFile(file, 'cancel_key')}
/>
</div>
<div class="grid gap-2">
<Label for="cancel_pass">Contraseña</Label>