Merge branch 'development' into feature/data-entry

This commit is contained in:
2026-03-05 09:36:03 -06:00
141 changed files with 25114 additions and 1608 deletions

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { CircleAlert } from 'lucide-svelte';
let {
open = $bindable(false),
agentsCount = 0,
clientsCount = 0,
onAccept,
onCancel
}: {
open?: boolean;
agentsCount?: number;
clientsCount?: number;
onAccept?: () => void;
onCancel?: () => void;
} = $props();
const showModal = $derived(agentsCount === 0 || clientsCount === 0);
const message = $derived(
agentsCount === 0 && clientsCount === 0
? 'No hay Agentes aduanales ni Clientes registrados. Debes darlos de alta para poder trabajar en este módulo.'
: agentsCount === 0
? 'No hay Agentes aduanales registrados. Debes darlos de alta para poder trabajar en este módulo.'
: 'No hay Clientes registrados. Debes darlos de alta para poder trabajar en este módulo.'
);
function handleOpenChange(newOpen: boolean) {
open = newOpen;
}
function handleCancel() {
onCancel?.();
}
function handleAccept() {
onAccept?.();
}
</script>
<AlertDialog.Root bind:open onOpenChange={handleOpenChange}>
<AlertDialog.Content>
<AlertDialog.Header>
<div class="flex items-center gap-3">
<CircleAlert class="h-6 w-6 shrink-0 text-amber-500" />
<AlertDialog.Title>Aviso</AlertDialog.Title>
</div>
<AlertDialog.Description class="space-y-3 pt-1">
<p>{message}</p>
<p class="text-sm text-muted-foreground">
Puedes registrarlos en
<a
href="/dashboard/customs_brokers"
class="font-medium text-primary underline underline-offset-4 hover:no-underline"
>
Agentes Aduanales
</a>
y
<a
href="/dashboard/clients_and_providers"
class="font-medium text-primary underline underline-offset-4 hover:no-underline"
>
Clientes y Proveedores
</a>.
</p>
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={handleCancel}>Cancelar</AlertDialog.Cancel>
<AlertDialog.Action onclick={handleAccept}>Aceptar</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>

View File

@@ -143,17 +143,53 @@
{#if scanResults.error_count > 0}
<div
class="rounded-md bg-destructive/5 border border-destructive/10 p-4 flex items-start gap-3"
class="rounded-md bg-destructive/5 border border-destructive/10 p-4 flex items-start gap-3 mb-4"
>
<XCircle class="w-5 h-5 text-destructive mt-0.5 shrink-0" />
<div class="text-sm text-destructive-foreground/90">
<p class="font-semibold mb-1">Se detectaron problemas en el archivo</p>
<p>
Las filas con errores serán omitidas automáticamente. Solo se importarán los
registros válidos.
Corrija los datos indicados abajo en su CSV y vuelva a subir, o confirme para
importar solo las filas válidas (las erróneas se omitirán).
</p>
</div>
</div>
{#if scanResults.errors && scanResults.errors.length > 0}
<div class="border rounded-lg overflow-hidden shadow-sm">
<div class="bg-muted/50 px-4 py-2 border-b flex justify-between items-center">
<h5 class="text-xs font-bold text-foreground uppercase tracking-wide">
Detalle de errores (para corregir en el CSV)
</h5>
<span
class="text-[10px] bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full border"
>
{scanResults.errors.length} error(es)
</span>
</div>
<div class="max-h-60 overflow-y-auto bg-card relative">
<table class="w-full text-xs text-left">
<thead
class="text-muted-foreground font-medium bg-muted/30 sticky top-0 z-10 shadow-sm backdrop-blur-sm"
>
<tr>
<th class="px-4 py-2 w-16">Línea</th>
<th class="px-4 py-2 w-40">Columna</th>
<th class="px-4 py-2">Mensaje</th>
</tr>
</thead>
<tbody class="divide-y">
{#each scanResults.errors as err}
<tr class="hover:bg-muted/30 transition-colors">
<td class="px-4 py-2 font-mono text-muted-foreground">{err.line}</td>
<td class="px-4 py-2 font-mono font-medium text-foreground">{err.col || '-'}</td>
<td class="px-4 py-2 text-destructive">{err.msg || '-'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
{:else}
<div class="rounded-md bg-primary/5 border border-primary/10 p-4 flex items-start gap-3">
<CheckCircle2 class="w-5 h-5 text-primary mt-0.5 shrink-0" />

View File

@@ -4,6 +4,7 @@
import { UploadCloud, Lock } from 'lucide-svelte';
import { cn } from '$lib/utils';
import { toast } from 'svelte-sonner';
import { api } from '$lib/api';
let {
items,
@@ -88,23 +89,31 @@
}
}
function handleContextMenu(e: MouseEvent, item: CsvUploadItem) {
async function handleContextMenu(e: MouseEvent, item: CsvUploadItem) {
if (item.disabled) {
e.preventDefault();
return;
}
if (!item.templateUrl) return;
e.preventDefault();
const link = document.createElement('a');
link.href = item.templateUrl;
link.download = item.templateUrl.split('/').pop() || 'plantilla.xls';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
if (!item.templateId) return;
toast.info(`Descargando plantilla para ${item.title}...`);
try {
toast.info(`Descargando plantilla para ${item.title}...`);
const { blob, filename } = await api.getCsvTemplateDownload(item.templateId);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(`Plantilla descargada: ${filename}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Error al descargar la plantilla');
}
}
</script>
@@ -123,7 +132,7 @@
ondragover={(e) => handleDragOver(e, item.disabled)}
ondrop={(e) => handleDrop(e, item)}
oncontextmenu={(e) => handleContextMenu(e, item)}
roles="button"
role="button"
tabindex={item.disabled ? -1 : 0}
onclick={() => handleClick(item.id, item.disabled)}
onkeydown={(e) => !item.disabled && e.key === 'Enter' && handleClick(item.id)}

View File

@@ -8,6 +8,20 @@ export type { CustomsBroker };
export function createColumns(onSuccess?: () => void): ColumnDef<CustomsBroker>[] {
return [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => {
const idSnippet = createRawSnippet<[{ id: number }]>((getId) => {
const { id } = getId();
return {
render: () =>
`<code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm">${id}</code>`
};
});
return renderSnippet(idSnippet, { id: row.original.id });
}
},
{
accessorKey: "broker_key",
header: "Clave",

View File

@@ -275,7 +275,7 @@
<div class="grid grid-cols-4 gap-4">
<div class="space-y-2">
<Label for="postal_code">C.P.</Label>
<Input id="postal_code" bind:value={formData.postal_code} disabled={loading} />
<Input id="postal_code" bind:value={formData.postal_code} disabled={loading} oninput={(e) => { formData.postal_code = e.currentTarget.value.replace(/[^a-zA-Z0-9]/g, ''); }} />
</div>
<div class="space-y-2 col-span-2">
<Label for="city">Ciudad</Label>

View File

@@ -318,6 +318,7 @@
placeholder="C.P."
maxlength={15}
disabled={loading}
oninput={(e) => { formData.postal_code = e.currentTarget.value.replace(/[^a-zA-Z0-9]/g, ''); }}
/>
</div>

View File

@@ -119,7 +119,14 @@
const companyId = companyStore.activeCompany?.id;
if (!companyId) throw new Error('No hay una compañía seleccionada');
if (!formData.date) throw new Error('La fecha es requerida');
if (formData.value === null) throw new Error('El valor es requerido');
if (
formData.value === null ||
formData.value === undefined ||
String(formData.value).trim() === ''
)
throw new Error('El tipo de cambio es requerido');
if (Number(formData.value) <= 0)
throw new Error('El tipo de cambio debe ser un valor mayor a 0');
showConfirmation = true;
} catch (e) {

View File

@@ -181,6 +181,20 @@
return;
}
// Validar tipo de cambio
if (
formData.exchange_rate === null ||
formData.exchange_rate === undefined ||
String(formData.exchange_rate).trim() === ''
) {
error = 'El tipo de cambio es requerido (pestaña Financieros)';
return;
}
if (Number(formData.exchange_rate) <= 0) {
error = 'El tipo de cambio debe ser mayor a 0 (pestaña Financieros)';
return;
}
loading = true;
error = null;