Merge branch 'development' of https://git.aduanasoft.com/ADUANASOFT/anexo76 into feature/manuales
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
<script lang="ts">
|
||||
import { AlertCircle, AlertTriangle, ChevronDown, ChevronUp, X } from 'lucide-svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
export type ErrorPanelNoticeRow = {
|
||||
field: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ErrorPanelNoticeVariant = 'error' | 'warning';
|
||||
|
||||
export type ErrorPanelNoticeLabels = {
|
||||
clear: string;
|
||||
columnType: string;
|
||||
columnField: string;
|
||||
columnMessage: string;
|
||||
emptyField: string;
|
||||
dismissRowAria: string;
|
||||
toggleDetailsAria: string;
|
||||
};
|
||||
|
||||
let {
|
||||
open,
|
||||
variant = 'error',
|
||||
title,
|
||||
rows = [],
|
||||
durationMs = 12000,
|
||||
labels,
|
||||
dismissibleRows = true,
|
||||
class: className = '',
|
||||
onClose,
|
||||
onDismissRow
|
||||
}: {
|
||||
open: boolean;
|
||||
variant?: ErrorPanelNoticeVariant;
|
||||
title: string;
|
||||
rows: ErrorPanelNoticeRow[];
|
||||
durationMs?: number;
|
||||
labels: ErrorPanelNoticeLabels;
|
||||
dismissibleRows?: boolean;
|
||||
class?: string;
|
||||
onClose?: () => void;
|
||||
onDismissRow?: (index: number) => void;
|
||||
} = $props();
|
||||
|
||||
let expanded = $state(true);
|
||||
|
||||
const isWarning = $derived(variant === 'warning');
|
||||
|
||||
/** Re-expand the table when the panel opens or when errors/warnings change (new batch). */
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
void title;
|
||||
void variant;
|
||||
void JSON.stringify(rows);
|
||||
expanded = true;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open || durationMs <= 0) return;
|
||||
void rows.length;
|
||||
const t = setTimeout(() => onClose?.(), durationMs);
|
||||
return () => clearTimeout(t);
|
||||
});
|
||||
|
||||
function toggleExpanded() {
|
||||
expanded = !expanded;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
data-partida-notice-layer
|
||||
role={isWarning ? 'status' : 'alert'}
|
||||
class={cn(
|
||||
// Above sonner; inset-left keeps the panel aligned with empty space in Partidas
|
||||
'pointer-events-auto fixed top-4 left-0 z-[1000000000] flex w-[min(100vw-2rem,30rem)] max-h-[min(72vh,calc(100vh-2rem))] flex-col overflow-hidden rounded-lg border bg-background text-foreground shadow-xl sm:left-1',
|
||||
isWarning ? 'border-amber-400/40' : 'border-border',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class={cn(
|
||||
'flex shrink-0 items-center gap-2 border-b px-3 py-2.5',
|
||||
isWarning
|
||||
? 'border-amber-400/30 bg-amber-500/10 dark:bg-amber-500/15'
|
||||
: 'border-destructive/20 bg-destructive/10 dark:bg-destructive/15'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
class={cn(
|
||||
'flex size-7 shrink-0 items-center justify-center rounded-full',
|
||||
isWarning
|
||||
? 'bg-amber-500 text-white'
|
||||
: 'bg-destructive text-destructive-foreground'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if isWarning}
|
||||
<AlertTriangle class="size-4" />
|
||||
{:else}
|
||||
<AlertCircle class="size-4" />
|
||||
{/if}
|
||||
</span>
|
||||
<p class="min-w-0 flex-1 text-sm font-semibold text-foreground">{title}</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-7 cursor-pointer items-center rounded-md px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onclick={() => onClose?.()}
|
||||
>
|
||||
{labels.clear}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onclick={toggleExpanded}
|
||||
aria-expanded={expanded}
|
||||
aria-label={labels.toggleDetailsAria}
|
||||
>
|
||||
{#if expanded}
|
||||
<ChevronUp class="size-4" />
|
||||
{:else}
|
||||
<ChevronDown class="size-4" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if expanded && rows.length > 0}
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-border bg-muted/30">
|
||||
<th
|
||||
class="w-10 px-2 py-1.5 text-center text-[10px] font-semibold tracking-widest text-muted-foreground uppercase"
|
||||
>
|
||||
{labels.columnType}
|
||||
</th>
|
||||
<th
|
||||
class="w-[38%] px-2 py-1.5 text-center text-[10px] font-semibold tracking-widest text-muted-foreground uppercase"
|
||||
>
|
||||
{labels.columnField}
|
||||
</th>
|
||||
<th
|
||||
class="px-2 py-1.5 text-left text-[10px] font-semibold tracking-widest text-muted-foreground uppercase"
|
||||
>
|
||||
{labels.columnMessage}
|
||||
</th>
|
||||
{#if dismissibleRows && onDismissRow}
|
||||
<th class="w-8"></th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row, i (i)}
|
||||
<tr class="border-b border-border/50 hover:bg-muted/20">
|
||||
<td class="px-2 py-2.5 text-center align-middle">
|
||||
<span
|
||||
class={cn(
|
||||
'inline-flex size-5 items-center justify-center rounded-full text-[11px] font-bold leading-none',
|
||||
isWarning
|
||||
? 'bg-amber-500 text-white'
|
||||
: 'bg-destructive text-destructive-foreground'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if isWarning}
|
||||
<AlertTriangle class="size-3" />
|
||||
{:else}
|
||||
×
|
||||
{/if}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
class="break-words px-2 py-2.5 align-middle text-center font-semibold text-foreground"
|
||||
>
|
||||
{row.field || labels.emptyField}
|
||||
</td>
|
||||
<td class="break-words px-2 py-2.5 align-top text-left leading-relaxed text-muted-foreground">
|
||||
{row.message}
|
||||
</td>
|
||||
{#if dismissibleRows && onDismissRow}
|
||||
<td class="px-1 py-2 text-center align-middle">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-6 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onclick={() => onDismissRow!(i)}
|
||||
aria-label={labels.dismissRowAria}
|
||||
>
|
||||
<X class="size-3" />
|
||||
</button>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
let { data }: { data: any } = $props();
|
||||
|
||||
// State
|
||||
let items = $state<ClientProvider[]>(data.items || []);
|
||||
let allItems = $state<ClientProvider[]>(data.items || []);
|
||||
let selectedItem = $state<ClientProvider | null>(null);
|
||||
let isLoading = $state(false);
|
||||
|
||||
@@ -29,11 +29,16 @@
|
||||
let currentPage = $state(data.page || 1);
|
||||
let pageSize = $state(50);
|
||||
let totalItems = $state(data.total || 0);
|
||||
let hasMore = $derived(allItems.length < totalItems);
|
||||
|
||||
// Filter state
|
||||
let searchName = $state('');
|
||||
let searchRfc = $state('');
|
||||
let searchType = $state<string>($page.url.searchParams.get('type') || 'both');
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
let hasMounted = false;
|
||||
let scrollContainer = $state<HTMLDivElement>();
|
||||
let loadingTrigger = $state<HTMLDivElement>();
|
||||
|
||||
// Estado para el diálogo de crear
|
||||
let showCreateDialog = $state(false);
|
||||
@@ -42,6 +47,7 @@
|
||||
// --- Lifecycle ---
|
||||
|
||||
onMount(() => {
|
||||
hasMounted = true;
|
||||
if (browser) {
|
||||
// Sincronizar token de cookies a localStorage si es necesario
|
||||
const getCookie = (name: string) => {
|
||||
@@ -62,9 +68,41 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const type = searchType;
|
||||
const name = searchName;
|
||||
const rfc = searchRfc;
|
||||
if (!browser || !hasMounted) return;
|
||||
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
void loadItems(1);
|
||||
}, 400);
|
||||
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!browser || !scrollContainer || !loadingTrigger) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [entry] = entries;
|
||||
if (entry.isIntersecting && hasMore && !isLoading) {
|
||||
void loadMore();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollContainer,
|
||||
threshold: 0.1
|
||||
}
|
||||
);
|
||||
observer.observe(loadingTrigger);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
async function loadItems(pageToLoad = 1) {
|
||||
async function loadItems(pageToLoad = 1, append = false) {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) return;
|
||||
|
||||
@@ -93,7 +131,7 @@
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
items = response.data.items;
|
||||
allItems = append ? [...allItems, ...response.data.items] : response.data.items;
|
||||
totalItems = response.data.total;
|
||||
currentPage = response.data.page;
|
||||
}
|
||||
@@ -107,17 +145,22 @@
|
||||
|
||||
function handleTypeChange(value: string) {
|
||||
searchType = value;
|
||||
loadItems(1);
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
loadItems(1);
|
||||
async function loadMore() {
|
||||
if (isLoading || !hasMore) return;
|
||||
await loadItems(currentPage + 1, true);
|
||||
}
|
||||
|
||||
function selectItem(item: ClientProvider) {
|
||||
selectedItem = item;
|
||||
}
|
||||
|
||||
function handleRowDoubleClick(item: ClientProvider) {
|
||||
selectedItem = item;
|
||||
goto(`/dashboard/clients_and_providers/edit/${item.id}`);
|
||||
}
|
||||
|
||||
function taxIdOrRfcLabel(cp: ClientProvider | null): string {
|
||||
if (!cp) return 'RFC';
|
||||
const proc = (cp.type_nat_foreign || 'N').toUpperCase();
|
||||
@@ -139,7 +182,7 @@
|
||||
await clientsProvidersApi.delete(selectedItem.id, companyStore.activeCompany.id);
|
||||
toast.success('Registro eliminado');
|
||||
selectedItem = null;
|
||||
loadItems(currentPage);
|
||||
loadItems(1);
|
||||
} catch (e) {
|
||||
toast.error('Error al eliminar');
|
||||
}
|
||||
@@ -197,7 +240,6 @@
|
||||
bind:value={searchName}
|
||||
placeholder="Buscar por nombre..."
|
||||
class="h-9"
|
||||
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
@@ -206,7 +248,6 @@
|
||||
bind:value={searchRfc}
|
||||
placeholder="RFC / TAX-ID..."
|
||||
class="h-9"
|
||||
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
@@ -227,9 +268,9 @@
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<Button variant="secondary" size="sm" class="w-full" onclick={handleSearch}>
|
||||
Buscar
|
||||
</Button>
|
||||
<div class="text-xs text-muted-foreground pb-2">
|
||||
Los filtros se aplican automáticamente
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -250,7 +291,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto bg-card">
|
||||
<div class="flex-1 overflow-auto bg-card" bind:this={scrollContainer}>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted text-muted-foreground border-b">
|
||||
<tr>
|
||||
@@ -262,25 +303,26 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if isLoading}
|
||||
{#if isLoading && allItems.length === 0}
|
||||
<tr
|
||||
><td colspan="5" class="text-center py-8 text-muted-foreground">Cargando...</td
|
||||
></tr
|
||||
>
|
||||
{:else if items.length === 0}
|
||||
{:else if allItems.length === 0}
|
||||
<tr
|
||||
><td colspan="5" class="text-center py-8 text-muted-foreground"
|
||||
>No se encontraron registros</td
|
||||
></tr
|
||||
>
|
||||
{:else}
|
||||
{#each items as item (item.id)}
|
||||
{#each allItems as item (item.id)}
|
||||
<tr
|
||||
class="border-b cursor-pointer transition-colors hover:bg-muted/50 {selectedItem?.id ===
|
||||
item.id
|
||||
? 'bg-muted'
|
||||
: ''}"
|
||||
onclick={() => selectItem(item)}
|
||||
ondblclick={() => handleRowDoubleClick(item)}
|
||||
>
|
||||
<td class="px-3 py-2 font-mono text-xs text-muted-foreground">{item.id}</td>
|
||||
<td class="px-3 py-2 font-mono font-medium">{item.rfc}</td>
|
||||
@@ -315,30 +357,26 @@
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if hasMore && allItems.length > 0}
|
||||
<tr>
|
||||
<td colspan="5" class="p-0">
|
||||
<div bind:this={loadingTrigger} class="flex items-center justify-center py-5">
|
||||
{#if isLoading}
|
||||
<span class="text-xs text-muted-foreground">Cargando más registros...</span>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground">Desplázate para cargar más</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Simple Pagination Controls -->
|
||||
<div class="p-2 border-t flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === 1 || isLoading}
|
||||
onclick={() => loadItems(currentPage - 1)}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<div class="p-2 border-t flex justify-end">
|
||||
<span class="flex items-center text-xs text-muted-foreground px-2">
|
||||
Página {currentPage} de {Math.ceil(totalItems / pageSize)}
|
||||
Mostrando {allItems.length} de {totalItems}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={items.length < pageSize || isLoading}
|
||||
onclick={() => loadItems(currentPage + 1)}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user