Merge origin/development into fix/bug-pedimento
This commit is contained in:
@@ -20,7 +20,7 @@
|
||||
} from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import { itemsApi, type Item } from '$lib/api/dashboard/a76/items';
|
||||
import { itemsApi, type Item as InvoiceItem } from '$lib/api/dashboard/a76/items';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import ItemSheetFa from './fa/item-sheet-fa.svelte';
|
||||
import ItemSheetInv from './inv/item-sheet-inv.svelte';
|
||||
@@ -43,7 +43,7 @@
|
||||
} = $props();
|
||||
|
||||
// 1. Core State
|
||||
let items = $state<Item[]>([]);
|
||||
let items = $state<InvoiceItem[]>([]);
|
||||
let displayedItems = $state<any[]>([]);
|
||||
let imported = $state(0);
|
||||
let net_weight = $state(0);
|
||||
@@ -129,9 +129,9 @@
|
||||
let showItemSheet = $state(false);
|
||||
let isEditMode = $state(false);
|
||||
let showDeleteDialog = $state(false);
|
||||
let selectedItem = $state<Item | null>(null);
|
||||
let originalItemData = $state<Partial<Item> | null>(null);
|
||||
let editingItem = $state<Partial<Item>>({
|
||||
let selectedItem = $state<InvoiceItem | null>(null);
|
||||
let originalItemData = $state<Partial<InvoiceItem> | null>(null);
|
||||
let editingItem = $state<Partial<InvoiceItem>>({
|
||||
invoice_id: undefined,
|
||||
reference_number: '',
|
||||
order: '',
|
||||
@@ -400,7 +400,7 @@
|
||||
return cleanLineData({ ...rest });
|
||||
}
|
||||
|
||||
function cloneItemForPreset(item: Item) {
|
||||
function cloneItemForPreset(item: InvoiceItem) {
|
||||
const { id, tenant_id, company_id, created_at, updated_at, temp_id, ...rest } = item as any;
|
||||
return {
|
||||
...sanitizeLineForPreset(rest),
|
||||
@@ -502,7 +502,7 @@
|
||||
isSavingPreset = true;
|
||||
try {
|
||||
// We group everything as items for the template
|
||||
const lines = builderItems.map((item: Item, idx: number) => {
|
||||
const lines = builderItems.map((item: InvoiceItem, idx: number) => {
|
||||
return {
|
||||
...cleanLineData(item),
|
||||
line_number: item.line_number || idx + 1, // Ensure line_number is present
|
||||
@@ -552,7 +552,7 @@
|
||||
}
|
||||
|
||||
// Enrich item with descriptive data for display
|
||||
async function enrichItemData(item: Partial<Item>) {
|
||||
async function enrichItemData(item: Partial<InvoiceItem>) {
|
||||
if (!item || !activeCompanyId) return;
|
||||
|
||||
// Load class data
|
||||
@@ -697,7 +697,7 @@
|
||||
}
|
||||
|
||||
// Normalize numeric values from strings to numbers
|
||||
function normalizeItemData(item: Partial<Item>): Partial<Item> {
|
||||
function normalizeItemData(item: Partial<InvoiceItem>): Partial<InvoiceItem> {
|
||||
if (item) {
|
||||
const normalizedItem = { ...item };
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Search, Loader2, Factory } from 'lucide-svelte';
|
||||
import { sectorsApi, type Sector } from '$lib/api/dashboard/reference_data/sectors';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// --- PROPS ---
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onSelect
|
||||
}: {
|
||||
open: boolean;
|
||||
onSelect: (item: Sector) => void;
|
||||
} = $props();
|
||||
|
||||
// --- ESTADO ---
|
||||
let items = $state<Sector[]>([]);
|
||||
let loading = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let previousSearchTerm = '';
|
||||
let page = $state(1);
|
||||
let pageSize = 50;
|
||||
let hasMore = $state(true);
|
||||
let totalItems = $state(0);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
let bottomSentinel: HTMLElement | null = $state(null);
|
||||
let searchTimeout: any;
|
||||
let isInitialized = false;
|
||||
|
||||
// Cargar datos iniciales al abrir
|
||||
$effect(() => {
|
||||
if (open && !isInitialized) {
|
||||
isInitialized = true;
|
||||
previousSearchTerm = searchTerm;
|
||||
resetAndLoad();
|
||||
} else if (!open) {
|
||||
isInitialized = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Manejar búsqueda con debouncing
|
||||
$effect(() => {
|
||||
const term = searchTerm;
|
||||
if (isInitialized && term !== previousSearchTerm) {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
previousSearchTerm = term;
|
||||
resetAndLoad();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Configurar IntersectionObserver para infinite scroll
|
||||
$effect(() => {
|
||||
if (bottomSentinel && hasMore && !loading && !loadingMore && open) {
|
||||
if (observer) observer.disconnect();
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(bottomSentinel);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observer) observer.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
async function resetAndLoad() {
|
||||
page = 1;
|
||||
items = [];
|
||||
hasMore = true;
|
||||
await loadSectors(true);
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!hasMore || loading || loadingMore) return;
|
||||
page += 1;
|
||||
await loadSectors(false);
|
||||
}
|
||||
|
||||
async function loadSectors(isInitial: boolean) {
|
||||
if (isInitial) {
|
||||
loading = true;
|
||||
} else {
|
||||
loadingMore = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sectorsApi.list(page, pageSize);
|
||||
|
||||
if (response.error) {
|
||||
toast.error(`Error: ${response.error}`);
|
||||
hasMore = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let newItems = response.data?.items || [];
|
||||
totalItems = response.data?.total || 0;
|
||||
|
||||
if (searchTerm) {
|
||||
newItems = newItems.filter(
|
||||
(item) =>
|
||||
item.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.key.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (isInitial) {
|
||||
items = newItems;
|
||||
} else {
|
||||
items = [...items, ...newItems];
|
||||
}
|
||||
|
||||
hasMore = items.length < totalItems && newItems.length > 0;
|
||||
} catch (e: any) {
|
||||
console.error('Error loading sectors:', e);
|
||||
toast.error('Error al conectar con el servidor');
|
||||
hasMore = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(item: Sector) {
|
||||
if (onSelect) onSelect(item);
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="flex max-h-[90vh] flex-col sm:max-w-[800px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Seleccionar Sector PROSEC</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Seleccione el sector del catálogo. Escrolea para ver más.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="relative my-2 w-full">
|
||||
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Filtrar por clave o descripción..."
|
||||
class="pl-9"
|
||||
bind:value={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-h-[300px] flex-1 overflow-y-auto rounded-md border">
|
||||
{#if loading && items.length === 0}
|
||||
<div class="flex h-48 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-primary" />
|
||||
<p>Cargando catálogo...</p>
|
||||
</div>
|
||||
{:else if items.length === 0}
|
||||
<div class="flex h-48 flex-col items-center justify-center text-muted-foreground">
|
||||
<p>No se encontraron sectores.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[100px]">Clave</Table.Head>
|
||||
<Table.Head>Descripción</Table.Head>
|
||||
<Table.Head class="w-[100px]">Autorizado</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each items as item}
|
||||
<Table.Row
|
||||
class="cursor-pointer transition-colors hover:bg-accent/50"
|
||||
onclick={() => handleSelect(item)}
|
||||
>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-1">
|
||||
<Factory class="h-3 w-3 text-orange-500" />
|
||||
<span class="font-mono text-xs font-bold">
|
||||
{item.key}
|
||||
</span>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-sm font-medium">
|
||||
{item.description}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs {item.authorized
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'}"
|
||||
>
|
||||
{item.authorized ? 'Sí' : 'No'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
<div bind:this={bottomSentinel} class="flex h-10 items-center justify-center">
|
||||
{#if loadingMore}
|
||||
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<div class="mr-auto self-center text-xs text-muted-foreground">
|
||||
{items.length} de {totalItems} registros
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,224 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Search, Loader2, MapPin } from 'lucide-svelte';
|
||||
import { statesApi, type State } from '$lib/api/dashboard/reference_data/states';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// --- PROPS ---
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onSelect
|
||||
}: {
|
||||
open: boolean;
|
||||
onSelect: (item: State) => void;
|
||||
} = $props();
|
||||
|
||||
// --- ESTADO ---
|
||||
let items = $state<State[]>([]);
|
||||
let loading = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let previousSearchTerm = '';
|
||||
let page = $state(1);
|
||||
let pageSize = 50;
|
||||
let hasMore = $state(true);
|
||||
let totalItems = $state(0);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
let bottomSentinel: HTMLElement | null = $state(null);
|
||||
let searchTimeout: any;
|
||||
let isInitialized = false;
|
||||
|
||||
// Cargar datos iniciales al abrir
|
||||
$effect(() => {
|
||||
if (open && !isInitialized) {
|
||||
isInitialized = true;
|
||||
previousSearchTerm = searchTerm;
|
||||
resetAndLoad();
|
||||
} else if (!open) {
|
||||
isInitialized = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Manejar búsqueda con debouncing
|
||||
$effect(() => {
|
||||
const term = searchTerm;
|
||||
if (isInitialized && term !== previousSearchTerm) {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
previousSearchTerm = term;
|
||||
resetAndLoad();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Configurar IntersectionObserver para infinite scroll
|
||||
$effect(() => {
|
||||
if (bottomSentinel && hasMore && !loading && !loadingMore && open) {
|
||||
if (observer) observer.disconnect();
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(bottomSentinel);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observer) observer.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
async function resetAndLoad() {
|
||||
page = 1;
|
||||
items = [];
|
||||
hasMore = true;
|
||||
await loadStates(true);
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!hasMore || loading || loadingMore) return;
|
||||
page += 1;
|
||||
await loadStates(false);
|
||||
}
|
||||
|
||||
async function loadStates(isInitial: boolean) {
|
||||
if (isInitial) {
|
||||
loading = true;
|
||||
} else {
|
||||
loadingMore = true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Note: statesApi.list takes page, pageSize, and searchTerm?
|
||||
// Wait, let me check statesApi.list signature again.
|
||||
// It only takes page and pageSize! I need to check if it supports search.
|
||||
const response = await statesApi.list(page, pageSize);
|
||||
|
||||
if (response.error) {
|
||||
toast.error(`Error: ${response.error}`);
|
||||
hasMore = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Local filtering if search term exists (temporary workaround if API doesn't support it)
|
||||
let newItems = response.data?.items || [];
|
||||
totalItems = response.data?.total || 0;
|
||||
|
||||
if (searchTerm) {
|
||||
newItems = newItems.filter(
|
||||
(item) =>
|
||||
item.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.m3_key.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (isInitial) {
|
||||
items = newItems;
|
||||
} else {
|
||||
items = [...items, ...newItems];
|
||||
}
|
||||
|
||||
hasMore = items.length < totalItems && newItems.length > 0;
|
||||
} catch (e: any) {
|
||||
console.error('Error loading states:', e);
|
||||
toast.error('Error al conectar con el servidor');
|
||||
hasMore = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(item: State) {
|
||||
if (onSelect) onSelect(item);
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="flex max-h-[90vh] flex-col sm:max-w-[800px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Seleccionar Estado</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Seleccione el estado del catálogo. Escrolea para ver más.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="relative my-2 w-full">
|
||||
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Filtrar por clave o descripción..."
|
||||
class="pl-9"
|
||||
bind:value={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-h-[300px] flex-1 overflow-y-auto rounded-md border">
|
||||
{#if loading && items.length === 0}
|
||||
<div class="flex h-48 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-primary" />
|
||||
<p>Cargando catálogo...</p>
|
||||
</div>
|
||||
{:else if items.length === 0}
|
||||
<div class="flex h-48 flex-col items-center justify-center text-muted-foreground">
|
||||
<p>No se encontraron estados.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[100px]">Clave M3</Table.Head>
|
||||
<Table.Head>Descripción</Table.Head>
|
||||
<Table.Head class="w-[80px]">MEX</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each items as item}
|
||||
<Table.Row
|
||||
class="cursor-pointer transition-colors hover:bg-accent/50"
|
||||
onclick={() => handleSelect(item)}
|
||||
>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-1">
|
||||
<MapPin class="h-3 w-3 text-red-500" />
|
||||
<span class="font-mono text-xs font-bold">
|
||||
{item.m3_key}
|
||||
</span>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-sm font-medium">
|
||||
{item.description}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs">
|
||||
{item.mex_key || '-'}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
<div bind:this={bottomSentinel} class="flex h-10 items-center justify-center">
|
||||
{#if loadingMore}
|
||||
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<div class="mr-auto self-center text-xs text-muted-foreground">
|
||||
{items.length} de {totalItems} registros
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Definición de columnas para la tabla de Trailers
|
||||
*/
|
||||
import type { Trailer } from '$lib/api/dashboard/a76/trailers';
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<Trailer>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'trailer_number',
|
||||
header: 'Número de Trailer',
|
||||
cell: ({ row }) => {
|
||||
return row.original.trailer_number;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'plate_number',
|
||||
header: 'Placas',
|
||||
cell: ({ row }) => {
|
||||
return row.original.plate_number || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'trailer_type_key',
|
||||
header: 'Tipo de Trailer',
|
||||
cell: ({ row }) => {
|
||||
return row.original.trailer_type_key || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'container_key',
|
||||
header: 'Contenedor',
|
||||
cell: ({ row }) => {
|
||||
return row.original.container_key || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'state',
|
||||
header: 'Estado',
|
||||
cell: ({ row }) => {
|
||||
return row.original.state || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'country',
|
||||
header: 'País',
|
||||
cell: ({ row }) => {
|
||||
return row.original.country || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { trailersApi, type Trailer } from '$lib/api/dashboard/a76/trailers';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: Trailer | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? 'Editar Trailer' : 'Nuevo Trailer');
|
||||
|
||||
let formData = $state<Trailer>({
|
||||
trailer_number: '',
|
||||
ace_trailer_number: '',
|
||||
trailer_type_key: '',
|
||||
seal: '',
|
||||
entity_code: '',
|
||||
plate_number: '',
|
||||
state: '',
|
||||
country: '',
|
||||
container_key: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (item) {
|
||||
formData = { ...item };
|
||||
} else {
|
||||
formData = {
|
||||
trailer_number: '',
|
||||
ace_trailer_number: '',
|
||||
trailer_type_key: '',
|
||||
seal: '',
|
||||
entity_code: '',
|
||||
plate_number: '',
|
||||
state: '',
|
||||
country: '',
|
||||
container_key: ''
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
error = null;
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
throw new Error('No hay una compañía seleccionada');
|
||||
}
|
||||
|
||||
if (!formData.trailer_number.trim()) {
|
||||
throw new Error('El número de trailer es requerido');
|
||||
}
|
||||
|
||||
let response;
|
||||
if (isEdit && item) {
|
||||
response = await trailersApi.update(item.trailer_number, formData, companyId);
|
||||
} else {
|
||||
response = await trailersApi.create(formData, companyId);
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar el trailer';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
open = false;
|
||||
error = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{isEdit
|
||||
? 'Modifica los datos del trailer'
|
||||
: 'Completa los datos para crear un nuevo trailer'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="space-y-6"
|
||||
>
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="trailer_number"
|
||||
>Número de Trailer <span class="text-destructive">*</span></Label
|
||||
>
|
||||
<Input
|
||||
id="trailer_number"
|
||||
bind:value={formData.trailer_number}
|
||||
disabled={isEdit}
|
||||
required
|
||||
maxlength={20}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="plate_number">Placas</Label>
|
||||
<Input id="plate_number" bind:value={formData.plate_number} maxlength={17} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="ace_trailer_number">Número Trailer ACE</Label>
|
||||
<Input id="ace_trailer_number" bind:value={formData.ace_trailer_number} maxlength={10} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="trailer_type_key">Tipo de Trailer (Clave)</Label>
|
||||
<Input
|
||||
id="trailer_type_key"
|
||||
bind:value={formData.trailer_type_key}
|
||||
maxlength={2}
|
||||
placeholder="2 car."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="container_key">Contenedor (Clave)</Label>
|
||||
<Input
|
||||
id="container_key"
|
||||
bind:value={formData.container_key}
|
||||
maxlength={3}
|
||||
placeholder="3 car."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="seal">Sello</Label>
|
||||
<Input id="seal" bind:value={formData.seal} maxlength={15} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="entity_code">Código Entidad</Label>
|
||||
<Input
|
||||
id="entity_code"
|
||||
bind:value={formData.entity_code}
|
||||
maxlength={1}
|
||||
placeholder="1 car."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="state">Estado</Label>
|
||||
<Input id="state" bind:value={formData.state} maxlength={30} placeholder="Ej: TX" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="country">País</Label>
|
||||
<Input
|
||||
id="country"
|
||||
bind:value={formData.country}
|
||||
maxlength={3}
|
||||
placeholder="MEX / USA"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={handleCancel} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { trailersApi, type Trailer } from '$lib/api/dashboard/a76/trailers';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: Trailer;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
let selectedItem = $state<Trailer | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
if (
|
||||
!confirm(
|
||||
`¿Estás seguro de eliminar el trailer "${item.trailer_number}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
alert('❌ Error: No hay una compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await trailersApi.delete(item.trailer_number, companyStore.activeCompany.id);
|
||||
|
||||
if (response.error) {
|
||||
if (response.status === 401) {
|
||||
error = 'Sesión expirada. Recargando página...';
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
error = response.error;
|
||||
alert(`❌ Error al eliminar:\n\n${response.error}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Éxito
|
||||
alert(`✅ Trailer "${item.trailer_number}" eliminado correctamente`);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al eliminar';
|
||||
alert(`❌ Error: ${error}`);
|
||||
console.error('Error deleting:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
selectedItem = item;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function handleDialogSuccess() {
|
||||
dialogOpen = false;
|
||||
selectedItem = null;
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="relative size-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical size={16} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-[160px]">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleEdit}>
|
||||
<Pencil size={16} class="mr-2" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleDelete} class="text-destructive" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 size={16} class="mr-2" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog bind:open={dialogOpen} item={selectedItem} onSuccess={handleDialogSuccess} />
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import { type ColumnDef, getCoreRowModel } from '@tanstack/table-core';
|
||||
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let { data, columns, pageCount, totalItems }: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() {
|
||||
return data;
|
||||
},
|
||||
get columns() {
|
||||
return columns;
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() {
|
||||
return pageCount;
|
||||
}
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border bg-card">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && 'selected'}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems}
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) - 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) + 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,306 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { transportersApi, type Transporter } from '$lib/api/dashboard/a76/transporters';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: Transporter | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
// Determinar si es modo edición o creación
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? 'Editar Transportista' : 'Nuevo Transportista');
|
||||
|
||||
// Estado del formulario
|
||||
let formData = $state<Transporter>({
|
||||
transporter_key: '',
|
||||
name: '',
|
||||
short_name: '',
|
||||
responsible: '',
|
||||
rfc: '',
|
||||
streets: '',
|
||||
postal_code: '',
|
||||
city: '',
|
||||
state: '',
|
||||
country: '',
|
||||
loader_code: '',
|
||||
caat_code: '',
|
||||
transport_code: '',
|
||||
transport_interface_type: '',
|
||||
ftp_server: '',
|
||||
ftp_user: '',
|
||||
ftp_password: '',
|
||||
ftp_directory: '',
|
||||
filler_code: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Resetear formulario cuando cambia el item
|
||||
$effect(() => {
|
||||
if (item) {
|
||||
formData = { ...item };
|
||||
} else {
|
||||
formData = {
|
||||
transporter_key: '',
|
||||
name: '',
|
||||
short_name: '',
|
||||
responsible: '',
|
||||
rfc: '',
|
||||
streets: '',
|
||||
postal_code: '',
|
||||
city: '',
|
||||
state: '',
|
||||
country: '',
|
||||
loader_code: '',
|
||||
caat_code: '',
|
||||
transport_code: '',
|
||||
transport_interface_type: '',
|
||||
ftp_server: '',
|
||||
ftp_user: '',
|
||||
ftp_password: '',
|
||||
ftp_directory: '',
|
||||
filler_code: ''
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
error = null;
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
throw new Error('No hay una compañía seleccionada');
|
||||
}
|
||||
|
||||
// Validación básica
|
||||
if (!formData.transporter_key.trim()) {
|
||||
throw new Error('La clave es requerida');
|
||||
}
|
||||
|
||||
let response;
|
||||
if (isEdit && item) {
|
||||
response = await transportersApi.update(item.transporter_key, formData, companyId);
|
||||
} else {
|
||||
response = await transportersApi.create(formData, companyId);
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
// Cerrar diálogo y notificar éxito
|
||||
open = false;
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar el transportista';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
open = false;
|
||||
error = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{isEdit
|
||||
? 'Modifica los datos del transportista'
|
||||
: 'Completa los datos para crear un nuevo transportista'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="space-y-6"
|
||||
>
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Información General -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="border-b pb-2 text-sm font-semibold">Información General</h3>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="transporter_key">Clave <span class="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="transporter_key"
|
||||
bind:value={formData.transporter_key}
|
||||
disabled={isEdit}
|
||||
required
|
||||
maxlength={23}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Nombre / Razón Social</Label>
|
||||
<Input id="name" bind:value={formData.name} maxlength={256} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="short_name">Nombre Corto</Label>
|
||||
<Input
|
||||
id="short_name"
|
||||
bind:value={formData.short_name}
|
||||
maxlength={10}
|
||||
placeholder="Máx. 10 car."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="rfc">RFC</Label>
|
||||
<Input id="rfc" bind:value={formData.rfc} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="responsible">Responsable</Label>
|
||||
<Input id="responsible" bind:value={formData.responsible} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Códigos y Transporte -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="border-b pb-2 text-sm font-semibold">Códigos de Transporte</h3>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="caat_code">Código CAAT</Label>
|
||||
<Input id="caat_code" bind:value={formData.caat_code} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="transport_code">Código de Transporte</Label>
|
||||
<Input
|
||||
id="transport_code"
|
||||
bind:value={formData.transport_code}
|
||||
maxlength={8}
|
||||
placeholder="Máx. 8 car."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="loader_code">Código Cargador</Label>
|
||||
<Input
|
||||
id="loader_code"
|
||||
bind:value={formData.loader_code}
|
||||
maxlength={9}
|
||||
placeholder="Máx. 9 car."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="transport_interface_type">Tipo Interfaz</Label>
|
||||
<Input
|
||||
id="transport_interface_type"
|
||||
bind:value={formData.transport_interface_type}
|
||||
maxlength={20}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="filler_code">Código Relleno</Label>
|
||||
<Input id="filler_code" bind:value={formData.filler_code} maxlength={20} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Dirección -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="border-b pb-2 text-sm font-semibold">Dirección</h3>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="streets">Calle y Número</Label>
|
||||
<Input id="streets" bind:value={formData.streets} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="city">Ciudad</Label>
|
||||
<Input id="city" bind:value={formData.city} maxlength={30} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="state">Estado</Label>
|
||||
<Input id="state" bind:value={formData.state} maxlength={30} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="country">País</Label>
|
||||
<Input
|
||||
id="country"
|
||||
bind:value={formData.country}
|
||||
maxlength={3}
|
||||
placeholder="MEX / USA"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="postal_code">C.P.</Label>
|
||||
<Input id="postal_code" bind:value={formData.postal_code} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Configuración FTP -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="border-b pb-2 text-sm font-semibold">Configuración FTP</h3>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="ftp_server">Servidor FTP</Label>
|
||||
<Input id="ftp_server" bind:value={formData.ftp_server} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="ftp_user">Usuario</Label>
|
||||
<Input id="ftp_user" bind:value={formData.ftp_user} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="ftp_password">Contraseña</Label>
|
||||
<Input id="ftp_password" type="password" bind:value={formData.ftp_password} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="ftp_directory">Directorio</Label>
|
||||
<Input id="ftp_directory" bind:value={formData.ftp_directory} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={handleCancel} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { transportersApi, type Transporter } from '$lib/api/dashboard/a76/transporters';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: Transporter;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
let selectedItem = $state<Transporter | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
if (
|
||||
!confirm(
|
||||
`¿Estás seguro de eliminar el transportista "${item.transporter_key}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
alert('❌ Error: No hay una compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await transportersApi.delete(
|
||||
item.transporter_key,
|
||||
companyStore.activeCompany.id
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
if (response.status === 401) {
|
||||
error = 'Sesión expirada. Recargando página...';
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
error = response.error;
|
||||
alert(`❌ Error al eliminar:\n\n${response.error}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Éxito
|
||||
alert(`✅ Transportista "${item.transporter_key}" eliminado correctamente`);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al eliminar';
|
||||
alert(`❌ Error: ${error}`);
|
||||
console.error('Error deleting:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
selectedItem = item;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function handleDialogSuccess() {
|
||||
dialogOpen = false;
|
||||
selectedItem = null;
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="relative size-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical size={16} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-[160px]">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleEdit}>
|
||||
<Pencil size={16} class="mr-2" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleDelete} class="text-destructive" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 size={16} class="mr-2" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog bind:open={dialogOpen} item={selectedItem} onSuccess={handleDialogSuccess} />
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import { type ColumnDef, getCoreRowModel } from '@tanstack/table-core';
|
||||
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let { data, columns, pageCount, totalItems }: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() {
|
||||
return data;
|
||||
},
|
||||
get columns() {
|
||||
return columns;
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() {
|
||||
return pageCount;
|
||||
}
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border bg-card">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && 'selected'}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems}
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) - 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) + 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Definición de columnas para la tabla de Transportistas
|
||||
*/
|
||||
import type { Transporter } from '$lib/api/dashboard/a76/transporters';
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<Transporter>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'transporter_key',
|
||||
header: 'Clave',
|
||||
cell: ({ row }) => {
|
||||
return row.original.transporter_key;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Nombre',
|
||||
cell: ({ row }) => {
|
||||
return row.original.name || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'short_name',
|
||||
header: 'Nombre Corto',
|
||||
cell: ({ row }) => {
|
||||
return row.original.short_name || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'rfc',
|
||||
header: 'RFC',
|
||||
cell: ({ row }) => {
|
||||
return row.original.rfc || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'caat_code',
|
||||
header: 'CAAT',
|
||||
cell: ({ row }) => {
|
||||
return row.original.caat_code || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'transport_code',
|
||||
header: 'Código Transporte',
|
||||
cell: ({ row }) => {
|
||||
return row.original.transport_code || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Definición de columnas para la tabla de Vehículos
|
||||
*/
|
||||
import type { Vehicle } from '$lib/api/dashboard/a76/vehicles';
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<Vehicle>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'vehicle_key',
|
||||
header: 'Clave',
|
||||
cell: ({ row }) => {
|
||||
return row.original.vehicle_key;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'brand',
|
||||
header: 'Marca',
|
||||
cell: ({ row }) => {
|
||||
return row.original.brand || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'year',
|
||||
header: 'Año',
|
||||
cell: ({ row }) => {
|
||||
return row.original.year || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'plate_number',
|
||||
header: 'Placas',
|
||||
cell: ({ row }) => {
|
||||
return row.original.plate_number || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'transporter_key',
|
||||
header: 'Transportista',
|
||||
cell: ({ row }) => {
|
||||
return row.original.transporter_key || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'transport_type',
|
||||
header: 'Tipo Transporte',
|
||||
cell: ({ row }) => {
|
||||
return row.original.transport_type || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { vehiclesApi, type Vehicle } from '$lib/api/dashboard/a76/vehicles';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: Vehicle | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
// Determinar si es modo edición o creación
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? 'Editar Vehículo' : 'Nuevo Vehículo');
|
||||
|
||||
// Estado del formulario
|
||||
let formData = $state<Vehicle>({
|
||||
vehicle_key: '',
|
||||
ace_vehicle_key: '',
|
||||
transporter_key: '',
|
||||
transport_identifier: '',
|
||||
transport_type: '',
|
||||
entity_code: '',
|
||||
transponder_number: '',
|
||||
dot_number: '',
|
||||
plate_number: '',
|
||||
city: '',
|
||||
state: '',
|
||||
country: '',
|
||||
seal: '',
|
||||
insurance_company_name: '',
|
||||
insurance_number: '',
|
||||
insurance_amount: undefined,
|
||||
insurance_date: undefined,
|
||||
box_number: '',
|
||||
brand: '',
|
||||
year: '',
|
||||
series: '',
|
||||
description: '',
|
||||
engine_number: '',
|
||||
sct_permission: '',
|
||||
color: '',
|
||||
container_key: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Resetear formulario cuando cambia el item
|
||||
$effect(() => {
|
||||
if (item) {
|
||||
formData = { ...item };
|
||||
} else {
|
||||
formData = {
|
||||
vehicle_key: '',
|
||||
ace_vehicle_key: '',
|
||||
transporter_key: '',
|
||||
transport_identifier: '',
|
||||
transport_type: '',
|
||||
entity_code: '',
|
||||
transponder_number: '',
|
||||
dot_number: '',
|
||||
plate_number: '',
|
||||
city: '',
|
||||
state: '',
|
||||
country: '',
|
||||
seal: '',
|
||||
insurance_company_name: '',
|
||||
insurance_number: '',
|
||||
insurance_amount: undefined,
|
||||
insurance_date: undefined,
|
||||
box_number: '',
|
||||
brand: '',
|
||||
year: '',
|
||||
series: '',
|
||||
description: '',
|
||||
engine_number: '',
|
||||
sct_permission: '',
|
||||
color: '',
|
||||
container_key: ''
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
error = null;
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
throw new Error('No hay una compañía seleccionada');
|
||||
}
|
||||
|
||||
// Validación básica
|
||||
if (!formData.vehicle_key.trim()) {
|
||||
throw new Error('La clave del vehículo es requerida');
|
||||
}
|
||||
|
||||
let response;
|
||||
if (isEdit && item) {
|
||||
response = await vehiclesApi.update(item.vehicle_key, formData, companyId);
|
||||
} else {
|
||||
response = await vehiclesApi.create(formData, companyId);
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
// Cerrar diálogo y notificar éxito
|
||||
open = false;
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar el vehículo';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
open = false;
|
||||
error = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{isEdit
|
||||
? 'Modifica los datos del vehículo'
|
||||
: 'Completa los datos para crear un nuevo vehículo de transporte'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="space-y-6"
|
||||
>
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Información del Vehículo -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="border-b pb-2 text-sm font-semibold">Identificación del Vehículo</h3>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="vehicle_key"
|
||||
>Clave del Vehículo <span class="text-destructive">*</span></Label
|
||||
>
|
||||
<Input
|
||||
id="vehicle_key"
|
||||
bind:value={formData.vehicle_key}
|
||||
disabled={isEdit}
|
||||
required
|
||||
maxlength={14}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand">Marca</Label>
|
||||
<Input id="brand" bind:value={formData.brand} maxlength={20} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="year">Año</Label>
|
||||
<Input id="year" bind:value={formData.year} maxlength={4} placeholder="YYYY" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="plate_number">Placas</Label>
|
||||
<Input id="plate_number" bind:value={formData.plate_number} maxlength={17} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="series">Serie / VIN</Label>
|
||||
<Input id="series" bind:value={formData.series} maxlength={30} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Datos de Transporte -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="border-b pb-2 text-sm font-semibold">Datos de Transporte</h3>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="transporter_key">Clave Transportista</Label>
|
||||
<Input id="transporter_key" bind:value={formData.transporter_key} maxlength={23} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="transport_identifier">Identificador Transporte</Label>
|
||||
<Input
|
||||
id="transport_identifier"
|
||||
bind:value={formData.transport_identifier}
|
||||
maxlength={30}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="transport_type">Tipo de Transporte</Label>
|
||||
<Input
|
||||
id="transport_type"
|
||||
bind:value={formData.transport_type}
|
||||
maxlength={2}
|
||||
placeholder="2 car."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="sct_permission">Permiso SCT</Label>
|
||||
<Input id="sct_permission" bind:value={formData.sct_permission} maxlength={40} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Seguro y Otros -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="border-b pb-2 text-sm font-semibold">Seguro y Otros</h3>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="insurance_company_name">Aseguradora</Label>
|
||||
<Input
|
||||
id="insurance_company_name"
|
||||
bind:value={formData.insurance_company_name}
|
||||
maxlength={30}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="insurance_number">Póliza</Label>
|
||||
<Input id="insurance_number" bind:value={formData.insurance_number} maxlength={20} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="insurance_amount">Monto Seguro</Label>
|
||||
<Input id="insurance_amount" type="number" bind:value={formData.insurance_amount} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="dot_number">Número DOT</Label>
|
||||
<Input id="dot_number" bind:value={formData.dot_number} maxlength={8} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Ubicación y Detalles -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="border-b pb-2 text-sm font-semibold">Ubicación y Detalles</h3>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="state">Estado</Label>
|
||||
<Input id="state" bind:value={formData.state} maxlength={30} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="country">País</Label>
|
||||
<Input
|
||||
id="country"
|
||||
bind:value={formData.country}
|
||||
maxlength={3}
|
||||
placeholder="MEX / USA"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="description">Descripción</Label>
|
||||
<Input id="description" bind:value={formData.description} maxlength={100} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="color">Color</Label>
|
||||
<Input id="color" bind:value={formData.color} maxlength={20} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="container_key">Contenedor</Label>
|
||||
<Input
|
||||
id="container_key"
|
||||
bind:value={formData.container_key}
|
||||
maxlength={3}
|
||||
placeholder="3 car."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={handleCancel} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { vehiclesApi, type Vehicle } from '$lib/api/dashboard/a76/vehicles';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: Vehicle;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
let selectedItem = $state<Vehicle | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
if (
|
||||
!confirm(
|
||||
`¿Estás seguro de eliminar el vehículo "${item.vehicle_key}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
alert('❌ Error: No hay una compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await vehiclesApi.delete(item.vehicle_key, companyStore.activeCompany.id);
|
||||
|
||||
if (response.error) {
|
||||
if (response.status === 401) {
|
||||
error = 'Sesión expirada. Recargando página...';
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
error = response.error;
|
||||
alert(`❌ Error al eliminar:\n\n${response.error}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Éxito
|
||||
alert(`✅ Vehículo "${item.vehicle_key}" eliminado correctamente`);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al eliminar';
|
||||
alert(`❌ Error: ${error}`);
|
||||
console.error('Error deleting:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
selectedItem = item;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function handleDialogSuccess() {
|
||||
dialogOpen = false;
|
||||
selectedItem = null;
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="relative size-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical size={16} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-[160px]">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleEdit}>
|
||||
<Pencil size={16} class="mr-2" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleDelete} class="text-destructive" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 size={16} class="mr-2" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog bind:open={dialogOpen} item={selectedItem} onSuccess={handleDialogSuccess} />
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import { type ColumnDef, getCoreRowModel } from '@tanstack/table-core';
|
||||
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let { data, columns, pageCount, totalItems }: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() {
|
||||
return data;
|
||||
},
|
||||
get columns() {
|
||||
return columns;
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() {
|
||||
return pageCount;
|
||||
}
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border bg-card">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && 'selected'}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems}
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) - 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) + 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Settings2,
|
||||
Shield,
|
||||
Ship,
|
||||
Truck,
|
||||
Users,
|
||||
} from 'lucide-svelte';
|
||||
import * as m from "$lib/paraglide/messages.js";
|
||||
@@ -192,6 +193,7 @@ export function getSidebarData(): SidebarData {
|
||||
title: m["sidebar.general_catalogs.identifiers"](),
|
||||
url: "/dashboard/general_catalogs/identifiers",
|
||||
},
|
||||
// -------------------------------------
|
||||
{
|
||||
title: m["sidebar.general_catalogs.incoterms"](),
|
||||
url: "/dashboard/reference_data/incoterms",
|
||||
@@ -333,6 +335,25 @@ export function getSidebarData(): SidebarData {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Transportes",
|
||||
url: "#",
|
||||
icon: Truck,
|
||||
items: [
|
||||
{
|
||||
title: "Transportistas",
|
||||
url: "/dashboard/general_catalogs/transporters",
|
||||
},
|
||||
{
|
||||
title: "Trailers",
|
||||
url: "/dashboard/general_catalogs/trailers",
|
||||
},
|
||||
{
|
||||
title: "Vehículos",
|
||||
url: "/dashboard/general_catalogs/vehicles",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: m["sidebar.goods.title"](),
|
||||
url: "#",
|
||||
@@ -522,4 +543,4 @@ export function getSidebarData(): SidebarData {
|
||||
}
|
||||
|
||||
// Exportar también como constante para compatibilidad (deprecado)
|
||||
export const sidebarData: SidebarData = getSidebarData();
|
||||
export const sidebarData: SidebarData = getSidebarData();
|
||||
Reference in New Issue
Block a user