fix: standardize multiplication notation from '×' to 'x' across various files for consistency

This commit is contained in:
2026-04-28 09:48:20 -05:00
committed by Kevin_Ramirez
parent 30f915eecf
commit 41e1a1767b
15 changed files with 538 additions and 327 deletions

2
Jenkinsfile vendored
View File

@@ -112,7 +112,7 @@ pipeline {
sleep 2
done
if [ "$READY" != "1" ]; then
echo "ERROR: Postgres no quedó listo a tiempo (30 intentos × 2s)."
echo "ERROR: Postgres no quedó listo a tiempo (30 intentos x 2s)."
exit 1
fi
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -c "

View File

@@ -10,7 +10,7 @@ These 4 tables are ALL you need for balances:
a24.balance_movement ← the ledger (append-only, never UPDATE)
a24.discharge_header ← one discharge per export/SM/CTM event
a24.discharge_detail ← one row per (export line × import lot consumed)
a24.discharge_detail ← one row per (export line x import lot consumed)
a24.discharge_scrap ← mermas, desperdicios, destrucciones
Design rules:

View File

@@ -10,7 +10,7 @@ These 4 tables are ALL you need for balances:
a24.balance_movement ← the ledger (append-only, never UPDATE)
a24.discharge_header ← one discharge per export/SM/CTM event
a24.discharge_detail ← one row per (export line × import lot consumed)
a24.discharge_detail ← one row per (export line x import lot consumed)
a24.discharge_scrap ← mermas, desperdicios, destrucciones
Design rules:
@@ -156,7 +156,7 @@ class DischargeHeader(Base, TenantScopedMixin, TimestampMixin):
# The critical traceability link:
# "Export line X consumed Y units from import lot Z"
#
# One row per (export_line × import_lot) pair.
# One row per (export_line x import_lot) pair.
# A single export line can span multiple rows when PEPS pulls from
# more than one import lot.
#
@@ -168,7 +168,7 @@ class DischargeHeader(Base, TenantScopedMixin, TimestampMixin):
class DischargeDetail(Base, TenantScopedMixin, TimestampMixin):
"""
One row per (export line × import lot consumed).
One row per (export line x import lot consumed).
This is the traceability record the SAT asks for:
"Show me which import pedimento covered this export line."

View File

@@ -22,7 +22,7 @@ flowchart TD
V5 --> V6{¿TC de la factura\n== TC del catálogo?}
V6 -- No --> E4([❌ Error\nTipo de cambio incorrecto])
V6 -- Sí --> V7[Marcar todas las partidas\ncomo sin descarga]
V7 --> V8[Calcular valores por partida\nCosto × Cantidad × TC\nen pesos · dólares · moneda cuenta]
V7 --> V8[Calcular valores por partida\nCosto x Cantidad x TC\nen pesos · dólares · moneda cuenta]
V8 --> V9[Validar costo unitario · series · pesos]
V9 --> V10{¿Hay errores\nen partidas?}
V10 -- Sí --> E5([❌ Error\nCosto en cero o series faltantes])

View File

@@ -117,7 +117,7 @@ def _assign_costs_per_line(
line.financial.unit_cost_usd = cost_usd
line.financial.unit_cost_mxn = cost_usd * line_tc
# Values are always: cost × qty
# Values are always: cost x qty
line.financial.value_mxn = (line.financial.unit_cost_mxn or Decimal(0)) * qty
line.financial.value_usd = (line.financial.unit_cost_usd or Decimal(0)) * qty
line.financial.value_mc = capture * qty

View File

@@ -5,7 +5,7 @@ Creates the full Annex-24 discharge record for one export invoice:
1. ONE DischargeHeader (one per export event)
2. N BalanceMovement rows (type=CONSUMPTION, one per lot consumed)
3. N DischargeDetail rows (one per export-line × import-lot pair),
3. N DischargeDetail rows (one per export-line x import-lot pair),
each referencing its BalanceMovement (design rule 3)
Design rules from a24.balance_movement (preserved here):
@@ -163,7 +163,7 @@ def register_discharge_ledger(
import_invoice_number_cache[lot.import_invoice_id] = origin_import_invoice
# ── 2. BalanceMovement (CONSUMPTION) ──────────────────────────
# Proportional value: consume / lot_consumed_total × lot_value
# Proportional value: consume / lot_consumed_total x lot_value
# lot_consumed_total == consume for single-lot entries (most cases)
value_me = _proportional_value(consume, consume, lot.value_me)
value_mn = _proportional_value(consume, consume, lot.value_mn)

View File

@@ -86,7 +86,7 @@ def limit_weight(lines: List[Any]) -> tuple[Decimal, Decimal]:
return total_qty, total_net_weight
def limit_value(lines: List[Any]) -> Decimal:
"""Sums (unit_cost_capture × quantity) across all line items."""
"""Sums (unit_cost_capture x quantity) across all line items."""
total_value = Decimal(0)
for line in lines:
if line.financial and line.quantity:

View File

@@ -21,7 +21,7 @@ flowchart TD
V2 --> V3{¿Errores en\nclases o fracciones?}
V3 -- Sí --> E4([❌ Error\nClase o fracción inválida])
V3 -- No --> V4[Validar pesos por partida\nKGS o LBS según configuración]
V4 --> CALC[Calcular valores sin IVA\nCosto × Cantidad × TC\npesos · dólares · moneda cuenta]
V4 --> CALC[Calcular valores sin IVA\nCosto x Cantidad x TC\npesos · dólares · moneda cuenta]
CALC --> VL[Validar por cada partida\ncosto > 0 · clase activa\nparte activa · series · UMA]
VL --> VL2{¿Errores en\npartidas?}
VL2 -- Sí --> E5([❌ Error\nCosto cero · clase desactivada\nparte desactivada · series faltantes])

View File

@@ -327,7 +327,7 @@ def generate_vencimiento_csv(filters: VencimientoFilter, db: Session) -> bytes:
peso_saldo = peso_neto - peso_usado
# -- Valor según moneda (ME = foreign, MN = national) --
# Legacy (UsarDescargos=1): ValorUsado = (CantUsada × ValorOrig) / CantOrig (proporcional, igual que peso)
# Legacy (UsarDescargos=1): ValorUsado = (CantUsada x ValorOrig) / CantOrig (proporcional, igual que peso)
if use_me:
valor_orig = _d(row["C22"])
else:

View File

@@ -3,9 +3,10 @@
import * as Table from '$lib/components/ui/table';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Search, Loader2, Info } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { Search, Loader2, Info, AlertCircle } from 'lucide-svelte';
import { companyStore } from '$lib/stores/company.svelte';
let classesLoadError = $state('');
import { onMount } from 'svelte';
import { classesApi } from '$lib/api/dashboard/a76/classes';
@@ -57,7 +58,7 @@
currentPage = page;
} catch (error) {
console.error('Error fetching classes:', error);
toast.error('Error al cargar clases');
classesLoadError = 'No se pudieron cargar las clases. Intenta de nuevo.';
if (page === 1) {
classes = [];
totalItems = 0;
@@ -133,6 +134,22 @@
</Dialog.Description>
</Dialog.Header>
{#if classesLoadError}
<div
role="alert"
class="mx-6 mt-2 flex gap-2 rounded-md border border-destructive/25 bg-destructive/5 p-2.5 text-sm text-destructive"
>
<AlertCircle class="mt-0.5 h-4 w-4 shrink-0" />
<p class="font-medium leading-snug">{classesLoadError}</p>
<button
type="button"
class="ml-auto shrink-0 cursor-pointer opacity-60 hover:opacity-100"
onclick={() => (classesLoadError = '')}
aria-label="Cerrar"
>x</button>
</div>
{/if}
<div class="px-6 py-2">
<div class="relative group">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400 transition-colors group-focus-within:text-blue-500" />

View File

@@ -3,10 +3,11 @@
import * as Table from '$lib/components/ui/table';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Search, Loader2, Info, Package } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { Search, Loader2, Info, Package, AlertCircle } from 'lucide-svelte';
import { companyStore } from '$lib/stores/company.svelte';
let partsLoadError = $state('');
let {
open = $bindable(false),
onSelect
@@ -62,7 +63,7 @@
currentPage = page;
} catch (error) {
console.error('Error fetching parts:', error);
toast.error('Error al cargar números de parte');
partsLoadError = 'No se pudieron cargar los números de parte. Intenta de nuevo.';
} finally {
isSearching = false;
isLoadingMore = false;
@@ -131,6 +132,22 @@
</Dialog.Description>
</Dialog.Header>
{#if partsLoadError}
<div
role="alert"
class="mx-6 mt-2 flex gap-2 rounded-md border border-destructive/25 bg-destructive/5 p-2.5 text-sm text-destructive"
>
<AlertCircle class="mt-0.5 h-4 w-4 shrink-0" />
<p class="font-medium leading-snug">{partsLoadError}</p>
<button
type="button"
class="ml-auto shrink-0 cursor-pointer opacity-60 hover:opacity-100"
onclick={() => (partsLoadError = '')}
aria-label="Cerrar"
>x</button>
</div>
{/if}
<div class="px-6 py-2">
<div class="relative group">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400 transition-colors group-focus-within:text-blue-500" />

View File

@@ -4,12 +4,14 @@
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import { Button } from '$lib/components/ui/button';
import { Plus, Pencil, Trash2, CheckCircle2, AlertTriangle } from 'lucide-svelte';
import { Plus, Pencil, Trash2, CheckCircle2, AlertTriangle, AlertCircle } from 'lucide-svelte';
import { itemsApi, type Item, type LineDescriptions, type Serie } from '$lib/api/dashboard/a76/items';
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
import { companyStore } from '$lib/stores/company.svelte';
import { toast } from 'svelte-sonner';
let seriesErrorMessage = $state('');
let {
descriptions = $bindable(),
series = $bindable(),
@@ -93,7 +95,7 @@
selectedSeriesIndex = null;
} catch (err) {
console.error('Error clearing series:', err);
toast.error('Error al intentar borrar las series.');
seriesErrorMessage = 'No se pudieron borrar las series. Intenta de nuevo.';
}
}
@@ -171,6 +173,22 @@
<fieldset class="border rounded-md p-3 space-y-3">
<legend class="text-xs font-semibold px-2 uppercase">Series</legend>
{#if seriesErrorMessage}
<div
role="alert"
class="flex gap-2 rounded-md border border-destructive/25 bg-destructive/5 p-2.5 text-sm text-destructive"
>
<AlertCircle class="mt-0.5 h-4 w-4 shrink-0" />
<p class="font-medium leading-snug">{seriesErrorMessage}</p>
<button
type="button"
class="ml-auto shrink-0 cursor-pointer opacity-60 hover:opacity-100"
onclick={() => (seriesErrorMessage = '')}
aria-label="Cerrar"
>x</button>
</div>
{/if}
<div class="flex items-center justify-between gap-2 flex-wrap">
<div class="flex items-center space-x-1.5">
<Checkbox

View File

@@ -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}
x
{/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}

View File

@@ -144,7 +144,7 @@
├───────────────┬────────┴─┬───────────────┤ row 3
│ Clientes (4) │Proveed(4) │ Accesos (4) │
├────────────────────────┬─────────────────┤ row 4
│ ActivityFeed (8, ×3) │ Documentos (4) │
│ ActivityFeed (8, x3) │ Documentos (4) │
│ ├─────────────────┤ row 5
│ │ Contactos (4) │
│ ├─────────────────┤ row 6

View File

@@ -19,16 +19,13 @@
Globe,
Box,
CheckSquare,
Square,
AlertCircle
} from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { Checkbox } from '$lib/components/ui/checkbox';
import { cn } from '$lib/utils';
// Importaciones de Seguridad y UI de Errores
import { currentUser, userHasPermission } from '$lib/auth';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
let fdaList = $state<any[]>([]);
let loading = $state(false);
let selectedRows = $state<number[]>([]);
@@ -36,18 +33,6 @@
let searchQuery = $state('');
let hoveredRow = $state<number | null>(null);
// Estados de error
let error = $state<string | null>(null);
let status = $state<number>(200);
// 🛡️ Permisos (Usando good_fda)
const canView = $derived(userHasPermission($currentUser, 'goods_fda.view'));
const canCreate = $derived(userHasPermission($currentUser, 'goods_fda.create'));
const canEdit = $derived(userHasPermission($currentUser, 'goods_fda.edit'));
const canDelete = $derived(userHasPermission($currentUser, 'goods_fda.delete'));
const isError = $derived(!canView || status >= 400 || error);
onMount(() => {
mounted = true;
});
@@ -74,8 +59,6 @@
);
async function loadAllFdaData() {
if (!canView) return; // Bloqueo si no hay permiso
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
@@ -86,35 +69,24 @@
const data = await res.json();
fdaList = data.items || [];
selectedRows = [];
status = 200;
error = null;
} else {
const errData = await res.json();
error = errData.error || 'Error al cargar los datos';
status = res.status || 500;
}
} catch (err: any) {
console.error('Error cargando lista de FDA:', err);
error = 'Error de conexión con el servidor';
status = 500;
} catch (error) {
console.error('Error cargando lista de FDA:', error);
} finally {
loading = false;
}
}
function handleCreateClick() {
if (!canCreate) return;
goto('/dashboard/goods/fda-codes/edit/new');
}
function handleEditClick(fdaId: number) {
if (!canEdit) return;
goto(`/dashboard/goods/fda-codes/edit/${fdaId}`);
}
async function deleteFdaCode(id: number, event?: MouseEvent) {
event?.stopPropagation();
if (!canDelete) return; // Bloqueo si no hay permiso
if (!confirm('¿Está seguro de eliminar este código FDA?')) return;
const companyId = companyStore.activeCompany?.id;
@@ -129,8 +101,8 @@
selectedRows = selectedRows.filter((r) => r !== id);
fdaList = fdaList.filter((f) => f.id !== id);
} else {
const errData = await res.json();
toast.error(errData.error || 'Error al eliminar');
const error = await res.json();
toast.error(error.error || 'Error al eliminar');
}
} catch (err) {
console.error('Error:', err);
@@ -155,7 +127,8 @@
}
async function handleDeleteSelected() {
if (!canDelete || selectedRows.length === 0) return;
if (selectedRows.length === 0) return;
if (!confirm(`¿Está seguro de eliminar ${selectedRows.length} código(s) FDA?`)) return;
for (const id of selectedRows) {
@@ -164,7 +137,7 @@
}
function handleEditSelected() {
if (!canEdit || selectedRows.length !== 1) return;
if (selectedRows.length !== 1) return;
const id = selectedRows[0];
handleEditClick(id);
}
@@ -173,6 +146,7 @@
selectedRows = [];
}
// Obtener badge de estado de almacenaje
function getStorageBadge(status: string) {
const styles: Record<string, string> = {
ACTIVO: 'bg-green-100 text-green-700 border-green-200',
@@ -184,6 +158,7 @@
</script>
<div class="min-h-screen bg-gradient-to-b from-background to-muted/20 pb-28">
<!-- Header Principal -->
<div class="sticky top-0 z-20 border-b bg-card/50 backdrop-blur-sm">
<div class="mx-auto max-w-[1600px] px-4 py-6 sm:px-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
@@ -209,305 +184,286 @@
<RefreshCw class="mr-2 h-4 w-4 {loading ? 'animate-spin' : ''}" />
<span class="hidden sm:inline">Actualizar</span>
</Button>
{#if !isError && canCreate}
<Button onclick={handleCreateClick} size="sm" class="h-10 shadow-lg shadow-primary/20">
<Plus class="mr-2 h-4 w-4" />
<span class="hidden sm:inline">Nuevo Código</span>
<span class="sm:hidden">Nuevo</span>
</Button>
{/if}
<Button onclick={handleCreateClick} size="sm" class="h-10 shadow-lg shadow-primary/20">
<Plus class="mr-2 h-4 w-4" />
<span class="hidden sm:inline">Nuevo Código</span>
<span class="sm:hidden">Nuevo</span>
</Button>
</div>
</div>
</div>
</div>
<!-- Contenido Principal -->
<div class="mx-auto max-w-[1600px] px-4 py-6 sm:px-6">
{#if isError}
<ErrorState
status={!canView ? 403 : status}
error={!canView ? 'Permission denied: good_fda.view' : error || ''}
onRetry={loadAllFdaData}
/>
{:else}
<Card.Root class="overflow-hidden border-0 shadow-xl shadow-black/5">
<div class="border-b bg-muted/30 p-4 sm:p-6">
<div class="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-primary/10 p-2">
<Filter class="h-4 w-4 text-primary" />
</div>
<div>
<h3 class="text-lg font-semibold">Registros FDA</h3>
<p class="text-sm text-muted-foreground">
{filteredFdaList.length} de {fdaList.length} registros
</p>
</div>
<Card.Root class="overflow-hidden border-0 shadow-xl shadow-black/5">
<!-- Header de Tabla con Búsqueda -->
<div class="border-b bg-muted/30 p-4 sm:p-6">
<div class="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-primary/10 p-2">
<Filter class="h-4 w-4 text-primary" />
</div>
<div class="relative w-full sm:w-80">
<Search
class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
type="text"
placeholder="Buscar por clave, código, descripción..."
bind:value={searchQuery}
class="h-10 bg-background pl-10"
/>
<div>
<h3 class="text-lg font-semibold">Registros FDA</h3>
<p class="text-sm text-muted-foreground">
{filteredFdaList.length} de {fdaList.length} registros
</p>
</div>
</div>
{#if selectedRows.length > 0}
<div
class="animate-in fade-in slide-in-from-top-2 mt-4 flex items-center justify-between rounded-lg border border-primary/20 bg-primary/5 p-3"
>
<div class="flex items-center gap-3">
<CheckSquare class="h-5 w-5 text-primary" />
<span class="text-sm font-medium">
{selectedRows.length}
{selectedRows.length === 1 ? 'registro seleccionado' : 'registros seleccionados'}
</span>
</div>
<Button variant="ghost" size="sm" onclick={clearSelection} class="h-8">
Limpiar selección
</Button>
</div>
{/if}
<div class="relative w-full sm:w-80">
<Search
class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
type="text"
placeholder="Buscar por clave, código, descripción..."
bind:value={searchQuery}
class="h-10 bg-background pl-10"
/>
</div>
</div>
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row class="bg-muted/50 hover:bg-muted/50">
<Table.Head class="w-12 text-center">
<Checkbox
checked={filteredFdaList.length > 0 &&
selectedRows.length === filteredFdaList.length}
indeterminate={selectedRows.length > 0 &&
selectedRows.length < filteredFdaList.length}
onCheckedChange={toggleAllSelection}
aria-label="Seleccionar todos"
/>
</Table.Head>
<Table.Head class="font-semibold whitespace-nowrap">Clave FDA</Table.Head>
<Table.Head class="font-semibold">Descripción</Table.Head>
<Table.Head class="font-semibold whitespace-nowrap">Código FDA</Table.Head>
<Table.Head class="font-semibold whitespace-nowrap">Fabricante</Table.Head>
<Table.Head class="font-semibold whitespace-nowrap">País</Table.Head>
<Table.Head class="font-semibold whitespace-nowrap">Estado</Table.Head>
<!-- Barra de selección -->
{#if selectedRows.length > 0}
<div
class="animate-in fade-in slide-in-from-top-2 mt-4 flex items-center justify-between rounded-lg border border-primary/20 bg-primary/5 p-3"
>
<div class="flex items-center gap-3">
<CheckSquare class="h-5 w-5 text-primary" />
<span class="text-sm font-medium">
{selectedRows.length}
{selectedRows.length === 1 ? 'registro seleccionado' : 'registros seleccionados'}
</span>
</div>
<Button variant="ghost" size="sm" onclick={clearSelection} class="h-8">
Limpiar selección
</Button>
</div>
{/if}
</div>
<!-- Tabla -->
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row class="bg-muted/50 hover:bg-muted/50">
<Table.Head class="w-12 text-center">
<Checkbox
checked={filteredFdaList.length > 0 &&
selectedRows.length === filteredFdaList.length}
indeterminate={selectedRows.length > 0 &&
selectedRows.length < filteredFdaList.length}
onCheckedChange={toggleAllSelection}
aria-label="Seleccionar todos"
/>
</Table.Head>
<Table.Head class="font-semibold whitespace-nowrap">Clave FDA</Table.Head>
<Table.Head class="font-semibold">Descripción</Table.Head>
<Table.Head class="font-semibold whitespace-nowrap">Código FDA</Table.Head>
<Table.Head class="font-semibold whitespace-nowrap">Fabricante</Table.Head>
<Table.Head class="font-semibold whitespace-nowrap">País</Table.Head>
<Table.Head class="font-semibold whitespace-nowrap">Estado</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading}
<Table.Row>
<Table.Cell colspan={7} class="py-16 text-center">
<div class="flex flex-col items-center gap-4">
<div class="rounded-full bg-primary/10 p-4">
<RefreshCw class="h-8 w-8 animate-spin text-primary" />
</div>
<div>
<p class="text-lg font-medium">Cargando registros...</p>
<p class="text-sm text-muted-foreground">Por favor espere</p>
</div>
</div>
</Table.Cell>
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading}
<Table.Row>
<Table.Cell colspan={7} class="py-16 text-center">
{:else if filteredFdaList.length === 0}
<Table.Row>
<Table.Cell colspan={7} class="py-16 text-center">
{#if searchQuery}
<div class="flex flex-col items-center gap-4">
<div class="rounded-full bg-primary/10 p-4">
<RefreshCw class="h-8 w-8 animate-spin text-primary" />
<div class="rounded-full bg-muted p-4">
<Search class="h-8 w-8 text-muted-foreground" />
</div>
<div>
<p class="text-lg font-medium">Cargando registros...</p>
<p class="text-sm text-muted-foreground">Por favor espere</p>
<p class="text-lg font-medium">No se encontraron resultados</p>
<p class="text-sm text-muted-foreground">Intenta con otra búsqueda</p>
</div>
<Button variant="outline" size="sm" onclick={() => (searchQuery = '')}>
Limpiar búsqueda
</Button>
</div>
{:else}
<div class="flex flex-col items-center gap-4">
<div class="rounded-full bg-muted p-4">
<Box class="h-8 w-8 text-muted-foreground" />
</div>
<div>
<p class="text-lg font-medium">No hay registros FDA</p>
<p class="text-sm text-muted-foreground">
No se encontraron registros para esta compañía
</p>
</div>
</div>
{/if}
</Table.Cell>
</Table.Row>
{:else}
{#each filteredFdaList as fda, i (fda.id)}
<Table.Row
class={cn(
'cursor-pointer transition-all duration-150',
selectedRows.includes(fda.id) && 'bg-primary/5 hover:bg-primary/10',
hoveredRow === fda.id && !selectedRows.includes(fda.id) && 'bg-muted/30',
'hover:shadow-sm'
)}
onmouseenter={() => (hoveredRow = fda.id)}
onmouseleave={() => (hoveredRow = null)}
onclick={() => toggleRowSelection(fda.id)}
>
<Table.Cell class="text-center" onclick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedRows.includes(fda.id)}
onCheckedChange={() => toggleRowSelection(fda.id)}
aria-label="Seleccionar fila"
/>
</Table.Cell>
</Table.Row>
{:else if filteredFdaList.length === 0}
<Table.Row>
<Table.Cell colspan={7} class="py-16 text-center">
{#if searchQuery}
<div class="flex flex-col items-center gap-4">
<div class="rounded-full bg-muted p-4">
<Search class="h-8 w-8 text-muted-foreground" />
</div>
<div>
<p class="text-lg font-medium">No se encontraron resultados</p>
<p class="text-sm text-muted-foreground">Intenta con otra búsqueda</p>
</div>
<Button variant="outline" size="sm" onclick={() => (searchQuery = '')}>
Limpiar búsqueda
</Button>
</div>
<Table.Cell>
<div class="flex items-center gap-2">
<span
class="rounded bg-primary/10 px-2 py-1 font-mono text-sm font-bold text-primary"
>
{fda.fda_key}
</span>
</div>
</Table.Cell>
<Table.Cell>
<div class="max-w-[300px]">
<p class="truncate font-medium" title={fda.description}>
{fda.description || '-'}
</p>
{#if fda.requirements}
<p
class="mt-0.5 truncate text-xs text-muted-foreground"
title={fda.requirements}
>
{fda.requirements}
</p>
{/if}
</div>
</Table.Cell>
<Table.Cell>
<span class="font-mono text-sm">
{fda.fda_code || '-'}
</span>
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-2">
<Building2 class="h-3.5 w-3.5 text-muted-foreground" />
<span class="text-sm">{fda.manufacturer_number || '-'}</span>
</div>
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-2">
<Globe class="h-3.5 w-3.5 text-muted-foreground" />
<span class="text-sm">{fda.country_of_production || '-'}</span>
</div>
</Table.Cell>
<Table.Cell>
{#if fda.storage_status}
<Badge
variant="outline"
class={cn('text-xs font-medium', getStorageBadge(fda.storage_status))}
>
{fda.storage_status}
</Badge>
{:else}
<div class="flex flex-col items-center gap-4">
<div class="rounded-full bg-muted p-4">
<Box class="h-8 w-8 text-muted-foreground" />
</div>
<div>
<p class="text-lg font-medium">No hay registros FDA</p>
<p class="text-sm text-muted-foreground">
No se encontraron registros para esta compañía
</p>
</div>
</div>
<span class="text-sm text-muted-foreground">-</span>
{/if}
</Table.Cell>
</Table.Row>
{:else}
{#each filteredFdaList as fda, i (fda.id)}
<Table.Row
class={cn(
'cursor-pointer transition-all duration-150',
selectedRows.includes(fda.id) && 'bg-primary/5 hover:bg-primary/10',
hoveredRow === fda.id && !selectedRows.includes(fda.id) && 'bg-muted/30',
'hover:shadow-sm'
)}
onmouseenter={() => (hoveredRow = fda.id)}
onmouseleave={() => (hoveredRow = null)}
onclick={() => toggleRowSelection(fda.id)}
>
<Table.Cell class="text-center" onclick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedRows.includes(fda.id)}
onCheckedChange={() => toggleRowSelection(fda.id)}
aria-label="Seleccionar fila"
/>
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-2">
<span
class="rounded bg-primary/10 px-2 py-1 font-mono text-sm font-bold text-primary"
>
{fda.fda_key}
</span>
</div>
</Table.Cell>
<Table.Cell>
<div class="max-w-[300px]">
<p class="truncate font-medium" title={fda.description}>
{fda.description || '-'}
</p>
{#if fda.requirements}
<p
class="mt-0.5 truncate text-xs text-muted-foreground"
title={fda.requirements}
>
{fda.requirements}
</p>
{/if}
</div>
</Table.Cell>
<Table.Cell>
<span class="font-mono text-sm">
{fda.fda_code || '-'}
</span>
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-2">
<Building2 class="h-3.5 w-3.5 text-muted-foreground" />
<span class="text-sm">{fda.manufacturer_number || '-'}</span>
</div>
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-2">
<Globe class="h-3.5 w-3.5 text-muted-foreground" />
<span class="text-sm">{fda.country_of_production || '-'}</span>
</div>
</Table.Cell>
<Table.Cell>
{#if fda.storage_status}
<Badge
variant="outline"
class={cn('text-xs font-medium', getStorageBadge(fda.storage_status))}
>
{fda.storage_status}
</Badge>
{:else}
<span class="text-sm text-muted-foreground">-</span>
{/if}
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
{#if filteredFdaList.length > 0}
<div class="flex items-center justify-between border-t bg-muted/30 px-4 py-3">
<span class="text-sm text-muted-foreground">
Mostrando {filteredFdaList.length} registros
</span>
{#if searchQuery}
<Badge variant="secondary" class="gap-2">
Filtro: "{searchQuery}"
<button onclick={() => (searchQuery = '')} class="hover:text-destructive">
×
</button>
</Badge>
{/each}
{/if}
</div>
{/if}
</Card.Root>
{/if}
</Table.Body>
</Table.Root>
</div>
<!-- Footer de Tabla -->
{#if filteredFdaList.length > 0}
<div class="flex items-center justify-between border-t bg-muted/30 px-4 py-3">
<span class="text-sm text-muted-foreground">
Mostrando {filteredFdaList.length} registros
</span>
{#if searchQuery}
<Badge variant="secondary" class="gap-2">
Filtro: "{searchQuery}"
<button onclick={() => (searchQuery = '')} class="hover:text-destructive"> x </button>
</Badge>
{/if}
</div>
{/if}
</Card.Root>
</div>
{#if !isError}
<div
class="fixed right-0 bottom-0 left-0 z-30 border-t bg-background/95 shadow-[0_-4px_20px_rgba(0,0,0,0.1)] backdrop-blur supports-[backdrop-filter]:bg-background/90"
>
<div class="mx-auto max-w-[1600px] px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
{#if selectedRows.length > 0}
<div class="flex items-center gap-3 rounded-full bg-primary/10 px-4 py-2">
<CheckSquare class="h-4 w-4 text-primary" />
<span class="text-sm font-semibold text-primary">
{selectedRows.length} seleccionado(s)
</span>
</div>
{:else}
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle class="h-4 w-4" />
<span>Selecciona registros para editar o eliminar</span>
</div>
{/if}
</div>
<!-- Footer Fijo con Acciones -->
<div
class="fixed right-0 bottom-0 left-0 z-30 border-t bg-background/95 shadow-[0_-4px_20px_rgba(0,0,0,0.1)] backdrop-blur supports-[backdrop-filter]:bg-background/90"
>
<div class="mx-auto max-w-[1600px] px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<!-- Info de selección -->
<div class="flex items-center gap-4">
{#if selectedRows.length > 0}
<div class="flex items-center gap-3 rounded-full bg-primary/10 px-4 py-2">
<CheckSquare class="h-4 w-4 text-primary" />
<span class="text-sm font-semibold text-primary">
{selectedRows.length} seleccionado(s)
</span>
</div>
{:else}
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle class="h-4 w-4" />
<span>Selecciona registros para editar o eliminar</span>
</div>
{/if}
</div>
<div class="flex items-center gap-2">
{#if canCreate}
<Button
size="default"
onclick={handleCreateClick}
class="shadow-lg shadow-primary/20"
>
<Plus class="mr-2 h-4 w-4" />
<span class="hidden sm:inline">Insertar</span>
<span class="sm:hidden">Nuevo</span>
</Button>
{/if}
{#if canEdit}
<Button
variant="outline"
size="default"
disabled={selectedRows.length !== 1}
onclick={handleEditSelected}
class={selectedRows.length === 1 ? 'border-primary/50' : ''}
>
<Pencil class="mr-2 h-4 w-4" />
<span class="hidden sm:inline">Editar</span>
</Button>
{/if}
{#if canDelete}
<Button
variant="outline"
size="default"
disabled={selectedRows.length === 0}
onclick={handleDeleteSelected}
class={selectedRows.length > 0
? 'border-destructive/50 text-destructive hover:bg-destructive/10'
: ''}
>
<Trash2 class="mr-2 h-4 w-4" />
<span class="hidden sm:inline">Borrar</span>
</Button>
{/if}
</div>
<!-- Botones de acción -->
<div class="flex items-center gap-2">
<Button size="default" onclick={handleCreateClick} class="shadow-lg shadow-primary/20">
<Plus class="mr-2 h-4 w-4" />
<span class="hidden sm:inline">Insertar</span>
<span class="sm:hidden">Nuevo</span>
</Button>
<Button
variant="outline"
size="default"
disabled={selectedRows.length !== 1}
onclick={handleEditSelected}
class={selectedRows.length === 1 ? 'border-primary/50' : ''}
>
<Pencil class="mr-2 h-4 w-4" />
<span class="hidden sm:inline">Editar</span>
</Button>
<Button
variant="outline"
size="default"
disabled={selectedRows.length === 0}
onclick={handleDeleteSelected}
class={selectedRows.length > 0
? 'border-destructive/50 text-destructive hover:bg-destructive/10'
: ''}
>
<Trash2 class="mr-2 h-4 w-4" />
<span class="hidden sm:inline">Borrar</span>
</Button>
</div>
</div>
</div>
{/if}
</div>
</div>