Merge branch 'development' into feature/data-entry
This commit is contained in:
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -123,11 +123,13 @@
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open={helpStore.isOpen}>
|
||||
<Sheet.Trigger
|
||||
class="fixed right-6 bottom-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-110 active:scale-95"
|
||||
aria-label="Ayuda"
|
||||
>
|
||||
<HelpCircle size={28} />
|
||||
<Sheet.Trigger asChild>
|
||||
<button
|
||||
class="fixed right-6 bottom-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-110 active:scale-95"
|
||||
aria-label="Ayuda"
|
||||
>
|
||||
<HelpCircle size={28} />
|
||||
</button>
|
||||
</Sheet.Trigger>
|
||||
<Sheet.Content side="right" class="w-[400px] sm:w-[540px]">
|
||||
<Sheet.Header>
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
// Group local shortcuts
|
||||
let localShortcuts = $derived($store.shortcuts);
|
||||
|
||||
let globalList = $state<HTMLDivElement>();
|
||||
let localList = $state<HTMLDivElement>();
|
||||
let modalRef = $state<HTMLDivElement>();
|
||||
let globalList = $state<HTMLDivElement | undefined>();
|
||||
let localList = $state<HTMLDivElement | undefined>();
|
||||
let modalRef = $state<HTMLDivElement | undefined>();
|
||||
|
||||
function handleArrowScroll(event: KeyboardEvent, target: HTMLDivElement | undefined) {
|
||||
if (!target) return;
|
||||
@@ -50,7 +50,8 @@
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
class="max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white p-6 text-gray-900 shadow-2xl dark:bg-gray-900 dark:text-gray-100"
|
||||
class="w-full max-w-2xl rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-900 text-gray-900 dark:text-gray-100 max-h-[80vh] overflow-y-auto"
|
||||
role="document"
|
||||
bind:this={modalRef}
|
||||
role="presentation"
|
||||
onkeydown={handleFocusTrap}
|
||||
@@ -89,7 +90,9 @@
|
||||
</h3>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="max-h-64 space-y-2 overflow-y-auto pr-1"
|
||||
class="space-y-2 max-h-64 overflow-y-auto pr-1"
|
||||
role="region"
|
||||
aria-label="Global Navigation shortcuts"
|
||||
bind:this={globalList}
|
||||
tabindex="-1"
|
||||
role="region"
|
||||
@@ -128,7 +131,9 @@
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="max-h-64 space-y-2 overflow-y-auto pr-1"
|
||||
class="space-y-2 max-h-64 overflow-y-auto pr-1"
|
||||
role="region"
|
||||
aria-label="Active Actions shortcuts"
|
||||
bind:this={localList}
|
||||
tabindex="-1"
|
||||
role="region"
|
||||
|
||||
128
frontend/src/lib/components/session-timeout-warning.svelte
Normal file
128
frontend/src/lib/components/session-timeout-warning.svelte
Normal file
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import {
|
||||
SESSION_WARNING_EVENT,
|
||||
SESSION_EXPIRED_EVENT,
|
||||
SESSION_EXTENDED_EVENT,
|
||||
getSessionManager
|
||||
} from '$lib/session-manager';
|
||||
import type { SessionWarningDetail } from '$lib/session-manager';
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────
|
||||
let open = $state(false);
|
||||
let remainingSeconds = $state(300);
|
||||
let countdownId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
function formatTime(secs: number): string {
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function clearCountdown() {
|
||||
if (countdownId !== null) {
|
||||
clearInterval(countdownId);
|
||||
countdownId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
clearCountdown();
|
||||
countdownId = setInterval(() => {
|
||||
remainingSeconds = Math.max(0, remainingSeconds - 1);
|
||||
if (remainingSeconds === 0) clearCountdown();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// ─── Event handlers ───────────────────────────────────────────────────────
|
||||
function onWarning(e: Event) {
|
||||
const { remainingMs } = (e as CustomEvent<SessionWarningDetail>).detail;
|
||||
remainingSeconds = Math.floor(remainingMs / 1000);
|
||||
open = true;
|
||||
startCountdown();
|
||||
}
|
||||
|
||||
function onExpired() {
|
||||
open = false;
|
||||
clearCountdown();
|
||||
}
|
||||
|
||||
function onExtended() {
|
||||
open = false;
|
||||
clearCountdown();
|
||||
}
|
||||
|
||||
// ─── User actions ─────────────────────────────────────────────────────────
|
||||
function continueSession() {
|
||||
const mgr = getSessionManager();
|
||||
mgr?.extendSession();
|
||||
open = false;
|
||||
clearCountdown();
|
||||
}
|
||||
|
||||
function logoutNow() {
|
||||
open = false;
|
||||
clearCountdown();
|
||||
// Dispara el evento de sesión expirada para que el layout gestione el logout
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SESSION_EXPIRED_EVENT, { detail: { reason: 'manual' } })
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
window.addEventListener(SESSION_WARNING_EVENT, onWarning);
|
||||
window.addEventListener(SESSION_EXPIRED_EVENT, onExpired);
|
||||
window.addEventListener(SESSION_EXTENDED_EVENT, onExtended);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (!browser) return;
|
||||
clearCountdown();
|
||||
window.removeEventListener(SESSION_WARNING_EVENT, onWarning);
|
||||
window.removeEventListener(SESSION_EXPIRED_EVENT, onExpired);
|
||||
window.removeEventListener(SESSION_EXTENDED_EVENT, onExtended);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
session-timeout-warning.svelte
|
||||
Diálogo que avisa al usuario cuando su sesión está a punto de expirar
|
||||
por inactividad. Se controla completamente a través de eventos DOM.
|
||||
-->
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-sm" />
|
||||
<Dialog.Content
|
||||
class="fixed left-1/2 top-1/2 z-[9999] w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-background p-6 shadow-xl"
|
||||
>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2 text-lg font-semibold">
|
||||
⚠️ Sesión por expirar
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="mt-2 text-sm text-muted-foreground">
|
||||
Tu sesión cerrará automáticamente por inactividad en
|
||||
<span class="font-mono font-bold text-foreground">
|
||||
{formatTime(remainingSeconds)}
|
||||
</span>.
|
||||
<br />
|
||||
¿Deseas continuar trabajando?
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<Dialog.Footer class="mt-6 flex gap-3">
|
||||
<Button variant="outline" class="flex-1" onclick={logoutNow}>
|
||||
Cerrar sesión
|
||||
</Button>
|
||||
<Button class="flex-1" onclick={continueSession}>
|
||||
Continuar sesión
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
@@ -85,7 +85,6 @@
|
||||
bind:this={searchInputRef}
|
||||
value={searchQuery}
|
||||
placeholder={searchPlaceholder}
|
||||
autofocus={autoFocusSearch}
|
||||
class="placeholder:text-muted-foreground flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-sm outline-none focus:border-ring focus:ring-1 focus:ring-ring"
|
||||
oninput={(event) => updateQuery((event.currentTarget as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
|
||||
Reference in New Issue
Block a user