fix: standardize multiplication notation from '×' to 'x' across various files for consistency
This commit is contained in:
2
Jenkinsfile
vendored
2
Jenkinsfile
vendored
@@ -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 "
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
203
frontend/src/lib/components/ui/error-panel-notice.svelte
Normal file
203
frontend/src/lib/components/ui/error-panel-notice.svelte
Normal 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}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user