Refactor item management: Update schemas, models, and API routes

- Renamed customs-related schemas in line_items to LineCustomCreate, LineCustomUpdate, and LineCustomResponse.
- Adjusted models to streamline invoice_id mapping in Item model.
- Enhanced item routes to include company_id in summary statistics endpoint.
- Refactored ItemService to improve item creation and update logic, removing redundant methods.
- Updated frontend components for item management, including new item creation and editing functionalities.
- Added API client for items with CRUD operations and improved error handling.
This commit is contained in:
AlexeerCT
2025-12-30 09:22:52 -06:00
parent 304f7b07d4
commit 30b8daf16e
8 changed files with 623 additions and 481 deletions

View File

@@ -0,0 +1,133 @@
/**
* API Client para Items
* Gestiona las operaciones CRUD para items de facturas
*/
import { api } from '$lib/api';
// --- Interfaces ---
export interface Item {
id?: number;
invoice_id: number;
item_type: string;
system_origin: string;
invoice_number?: string;
reference_number?: string;
order?: string;
guide_number?: string;
invoice_date?: number;
depreciation_date?: number;
rectification?: number;
warehouse?: string;
location?: string;
created_at?: string;
updated_at?: string;
}
export interface ItemListResponse {
items: Item[];
total: number;
skip: number;
limit: number;
}
export interface CreateItemData {
invoice_id: number;
item_type: string;
system_origin: string;
invoice_number?: string;
reference_number?: string;
order?: string;
guide_number?: string;
invoice_date?: number;
depreciation_date?: number;
rectification?: number;
warehouse?: string;
location?: string;
}
export interface UpdateItemData {
item_type?: string;
system_origin?: string;
invoice_number?: string;
reference_number?: string;
order?: string;
guide_number?: string;
invoice_date?: number;
depreciation_date?: number;
rectification?: boolean;
warehouse?: string;
location?: string;
}
/**
* API para Items
*/
export const itemsApi = {
/**
* Lista todos los items con paginación
*/
list: (companyId: number, skip = 0, limit = 100, invoiceId?: number) => {
const params = new URLSearchParams({
company_id: companyId.toString(),
skip: skip.toString(),
limit: limit.toString()
});
if (invoiceId) {
params.append('invoice_id', invoiceId.toString());
}
return api.get<ItemListResponse>(`/v1/a76/items/?${params.toString()}`);
},
/**
* Lista items por invoice ID
*/
listByInvoice: (invoiceId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.get<ItemListResponse>(`/v1/a76/items/invoice/${invoiceId}/items?${params.toString()}`);
},
/**
* Obtiene un item por ID
*/
get: (itemId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.get<Item>(`/v1/a76/items/${itemId}?${params.toString()}`);
},
/**
* Crea un nuevo item
*/
create: (companyId: number, data: CreateItemData) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.post<Item>(`/v1/a76/items/?${params.toString()}`, data);
},
/**
* Actualiza un item existente
*/
update: (itemId: number, companyId: number, data: UpdateItemData) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.put<Item>(`/v1/a76/items/${itemId}?${params.toString()}`, data);
},
/**
* Elimina un item
*/
delete: (itemId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.delete(`/v1/a76/items/${itemId}?${params.toString()}`);
}
};

View File

@@ -1,14 +1,16 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import * as Dialog from '$lib/components/ui/dialog';
import * as Sheet from '$lib/components/ui/sheet';
import * as Tabs from '$lib/components/ui/tabs';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { RadioGroup, RadioGroupItem } from '$lib/components/ui/radio-group';
import * as Select from '$lib/components/ui/select';
import { Textarea } from '$lib/components/ui/textarea';
import { Plus, Upload } from 'lucide-svelte';
import { Plus, Pencil, Trash2, X, Loader2 } 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 { companyStore } from '$lib/stores/company.svelte';
let {
invoice,
@@ -23,22 +25,303 @@
let imported = 0;
let net_weight = 0;
let gross_weight = 0;
let items = $state<Item[]>([]);
let displayedItems = $state<Item[]>([]);
let itemsPerPage = 20;
let currentPage = $state(1);
let tableContainer: HTMLDivElement | undefined = $state();
let isLoadingMore = $state(false);
let isLoadingItems = $state(false);
let isSaving = $state(false);
// Sheet states
let showItemSheet = $state(false);
let isEditMode = $state(false);
let showDeleteDialog = $state(false);
let selectedItem = $state<Item | null>(null);
let editingItem = $state<Partial<Item>>({
invoice_id: undefined,
item_type: '',
invoice_number: '',
reference_number: '',
order: '',
warehouse: '',
location: ''
});
// Derived value para company ID
const activeCompanyId = $derived(companyStore.activeCompany?.id);
// Cargar items cuando la factura tenga ID
$effect(() => {
if (invoice?.id && activeCompanyId) {
loadItems();
}
});
async function loadItems() {
if (!invoice?.id || !activeCompanyId) return;
isLoadingItems = true;
try {
const response = await itemsApi.listByInvoice(invoice.id, activeCompanyId);
if (response.data) {
items = response.data.items || [];
currentPage = 1;
loadMoreItems();
}
} catch (error: any) {
console.error('Error loading items:', error);
const errorMessage = error?.response?.data?.detail || 'No se pudieron cargar los items de la factura.';
toast.error('Error al cargar items', {
description: errorMessage
});
} finally {
isLoadingItems = false;
}
}
function loadMoreItems() {
const start = 0;
const end = currentPage * itemsPerPage;
displayedItems = items.slice(start, end);
isLoadingMore = false;
}
function handleScroll(e: Event) {
const target = e.target as HTMLDivElement;
const threshold = 100;
const scrolledToBottom = target.scrollHeight - target.scrollTop - target.clientHeight < threshold;
if (scrolledToBottom && !isLoadingMore && displayedItems.length < items.length) {
isLoadingMore = true;
currentPage++;
loadMoreItems();
}
}
function handleAdd() {
// Validar que la factura esté guardada (tiene ID)
if (!invoice?.id) {
toast.warning('Factura no guardada', {
description: 'Debes guardar la factura primero antes de agregar partidas.',
duration: 5000,
});
return;
}
isEditMode = false;
showItemSheet = true;
// Auto-asignar valores desde la factura
editingItem = {
invoice_id: invoice.id,
item_type: invoice.operation_type || '',
invoice_number: invoice.invoice_number || '',
reference_number: '',
order: invoice.purchase_order || '',
warehouse: '',
location: ''
};
}
function handleEdit(item: Item) {
isEditMode = true;
selectedItem = item;
editingItem = { ...item };
showItemSheet = true;
}
function handleDelete(item: Item) {
selectedItem = item;
showDeleteDialog = true;
}
async function saveNewItem() {
if (!invoice?.id || !activeCompanyId) return;
isSaving = true;
try {
const response = await itemsApi.create(activeCompanyId, {
invoice_id: invoice.id,
item_type: editingItem.item_type || '',
system_origin: 'SCAF', // Por defecto SCAF, podrías hacerlo configurable
invoice_number: editingItem.invoice_number,
reference_number: editingItem.reference_number,
order: editingItem.order,
warehouse: editingItem.warehouse,
location: editingItem.location
});
// Recargar items
await loadItems();
showItemSheet = false;
toast.success('Item creado', {
description: 'El item se ha creado correctamente.'
});
} catch (error: any) {
console.error('Error creating item:', error);
const errorMessage = error?.response?.data?.detail || 'No se pudo crear el item. Intenta de nuevo.';
toast.error('Error al crear item', {
description: errorMessage
});
} finally {
isSaving = false;
}
}
async function saveEditedItem() {
if (!selectedItem?.id || !activeCompanyId) return;
isSaving = true;
try {
await itemsApi.update(selectedItem.id, activeCompanyId, {
item_type: editingItem.item_type,
system_origin: editingItem.system_origin,
invoice_number: editingItem.invoice_number,
reference_number: editingItem.reference_number,
order: editingItem.order,
warehouse: editingItem.warehouse,
location: editingItem.location
});
// Recargar items
await loadItems();
showItemSheet = false;
toast.success('Item actualizado', {
description: 'El item se ha actualizado correctamente.'
});
} catch (error: any) {
console.error('Error updating item:', error);
const errorMessage = error?.response?.data?.detail || 'No se pudo actualizar el item. Intenta de nuevo.';
toast.error('Error al actualizar item', {
description: errorMessage
});
} finally {
isSaving = false;
}
}
function saveItem() {
if (isEditMode) {
saveEditedItem();
} else {
saveNewItem();
}
}
async function confirmDelete() {
if (!selectedItem?.id || !activeCompanyId) return;
isSaving = true;
try {
await itemsApi.delete(selectedItem.id, activeCompanyId);
// Recargar items
await loadItems();
showDeleteDialog = false;
toast.success('Item eliminado', {
description: 'El item se ha eliminado correctamente.'
});
} catch (error: any) {
console.error('Error deleting item:', error);
const errorMessage = error?.response?.data?.detail || 'No se pudo eliminar el item. Intenta de nuevo.';
toast.error('Error al eliminar item', {
description: errorMessage
});
} finally {
isSaving = false;
}
}
</script>
<div class="grid grid-cols-4 grid-rows-1 gap-3">
<div class="border rounded-md p-3 space-y-3 col-span-3">
1
<div class="border rounded-md p-3 space-y-3 col-span-3">
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-semibold">Items de la Factura</h3>
<Button size="sm" onclick={handleAdd}>
<Plus class="w-4 h-4 mr-1" />
Agregar Item
</Button>
</div>
<div
bind:this={tableContainer}
onscroll={handleScroll}
class="max-h-[500px] overflow-auto border rounded-md"
>
<Table.Root>
<Table.Header class="sticky top-0 bg-background z-10">
<Table.Row>
<Table.Head class="w-[100px]">Tipo</Table.Head>
<Table.Head>Factura</Table.Head>
<Table.Head>Referencia</Table.Head>
<Table.Head>Orden</Table.Head>
<Table.Head>Almacén</Table.Head>
<Table.Head>Ubicación</Table.Head>
<Table.Head class="text-right w-[120px]">Acciones</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if displayedItems.length === 0}
<Table.Row>
<Table.Cell colspan={7} class="text-center text-muted-foreground py-8">
No hay items disponibles
</Table.Cell>
</Table.Row>
{:else}
{#each displayedItems as item (item.id)}
<Table.Row>
<Table.Cell class="font-medium">{item.item_type}</Table.Cell>
<Table.Cell>{item.invoice_number || '-'}</Table.Cell>
<Table.Cell>{item.reference_number || '-'}</Table.Cell>
<Table.Cell>{item.order || '-'}</Table.Cell>
<Table.Cell>{item.warehouse || '-'}</Table.Cell>
<Table.Cell>{item.location || '-'}</Table.Cell>
<Table.Cell class="text-right">
<div class="flex justify-end gap-2">
<Button size="icon" variant="ghost" onclick={() => handleEdit(item)}>
<Pencil class="w-4 h-4" />
</Button>
<Button size="icon" variant="ghost" onclick={() => handleDelete(item)}>
<Trash2 class="w-4 h-4 text-destructive" />
</Button>
</div>
</Table.Cell>
</Table.Row>
{/each}
{#if isLoadingMore}
<Table.Row>
<Table.Cell colspan={7} class="text-center py-4">
<span class="text-sm text-muted-foreground">Cargando más items...</span>
</Table.Cell>
</Table.Row>
{/if}
{/if}
</Table.Body>
</Table.Root>
</div>
{#if items.length > 0}
<div class="text-xs text-muted-foreground text-right">
Mostrando {displayedItems.length} de {items.length} items
</div>
{/if}
</div>
<div class="border rounded-md p-3 space-y-3 col-start-4">
<div class="border rounded-md p-3 space-y-3 col-start-4">
<div>
<h4 class="text-xs font-semibold text-muted-foreground uppercase">Cantidades:</h4>
<div>
<div class="grid grid-cols-2 gap-3">
<div>
Partidas: <span class="text-blue-400">{invoice?.items?.length || 0}</span>
Partidas: <span class="text-blue-400">{items.length || 0}</span>
</div>
<div>
Bultos: <span class="text-blue-400">{invoice?.packages || 0}</span>
Bultos: <span class="text-blue-400">0</span>
</div>
</div>
</div>
@@ -48,13 +331,184 @@
</div>
<h4 class="text-xs font-semibold text-muted-foreground uppercase col-span-2">Valores de importacion:</h4>
Dolares: <span class="text-blue-400">{invoice?.items || 0}</span> <span class="text-red-400">USD</span> <br>
Pesos: <span class="text-blue-400">{invoice?.items || 0}</span> <span class="text-red-400">MXN</span><br>
De Captura: <span class="text-blue-400">{invoice?.items || 0}</span> <span class="text-red-400">USD</span>
Dolares: <span class="text-blue-400">0</span> <span class="text-red-400">USD</span> <br>
Pesos: <span class="text-blue-400">0</span> <span class="text-red-400">MXN</span><br>
De Captura: <span class="text-blue-400">0</span> <span class="text-red-400">USD</span>
<h4 class="text-xs font-semibold text-muted-foreground uppercase col-span-2 opacity-0">spacer</h4>
Aduana: <span class="text-blue-400">{invoice?.items || 0}</span> <span class="text-red-400">USD</span><br>
Aduana: <span class="text-blue-400">{invoice?.items || 0}</span> <span class="text-red-400">MXN</span><br>
Aduana: <span class="text-blue-400">0</span> <span class="text-red-400">USD</span><br>
Aduana: <span class="text-blue-400">0</span> <span class="text-red-400">MXN</span><br>
</div>
</div>
<!-- Item Sheet (Panel lateral para agregar/editar) -->
<Sheet.Root bind:open={showItemSheet}>
<Sheet.Content side="right" class="w-full sm:max-w-2xl overflow-y-auto">
<Sheet.Header>
<Sheet.Title>{isEditMode ? 'Editar Item' : 'Agregar Nuevo Item'}</Sheet.Title>
<Sheet.Description>
{isEditMode ? 'Modifica los campos del item y guarda los cambios.' : 'Completa la información del nuevo item.'}
</Sheet.Description>
</Sheet.Header>
<Tabs.Root value="general" class="mt-6">
<Tabs.List class="grid w-full grid-cols-4">
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="clasificacion">Clasificación</Tabs.Trigger>
<Tabs.Trigger value="cantidades">Cantidades</Tabs.Trigger>
<Tabs.Trigger value="otros">Otros</Tabs.Trigger>
</Tabs.List>
<!-- Tab: General -->
<Tabs.Content value="general" class="space-y-4 mt-4">
<!-- Información de la Factura (Solo lectura) -->
<div class="rounded-lg border bg-muted/50 p-4 space-y-3">
<h4 class="text-sm font-medium">Información de la Factura</h4>
{#if !invoice?.id}
<div class="text-sm text-amber-600 bg-amber-50 dark:bg-amber-950/20 p-3 rounded">
⚠️ Esta factura aún no se ha guardado. Los items se asociarán cuando guardes la factura.
</div>
{:else}
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-muted-foreground">ID Factura:</span>
<span class="ml-2 font-medium">{invoice.id}</span>
</div>
<div>
<span class="text-muted-foreground">Tipo Operación:</span>
<span class="ml-2 font-medium uppercase">{invoice.operation_type || 'N/A'}</span>
</div>
<div class="col-span-2">
<span class="text-muted-foreground">Número de Factura:</span>
<span class="ml-2 font-medium">{invoice.invoice_number || 'Pendiente'}</span>
</div>
</div>
{/if}
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="reference_number">Número de Referencia</Label>
<Input id="reference_number" bind:value={editingItem.reference_number} />
</div>
<div class="space-y-2">
<Label for="order">Orden de Compra/Venta</Label>
<Input
id="order"
bind:value={editingItem.order}
placeholder={invoice?.purchase_order || ''}
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="warehouse">Almacén</Label>
<Input id="warehouse" bind:value={editingItem.warehouse} />
</div>
<div class="space-y-2">
<Label for="location">Ubicación</Label>
<Input id="location" bind:value={editingItem.location} />
</div>
</div>
<div class="space-y-2">
<Label for="invoice_date">Fecha de Factura</Label>
<Input
id="invoice_date"
type="date"
bind:value={editingItem.invoice_date}
/>
</div>
</Tabs.Content>
<!-- Tab: Clasificación -->
<Tabs.Content value="clasificacion" class="space-y-4 mt-4">
<div class="rounded-lg border p-4">
<p class="text-sm text-muted-foreground">
Aquí puedes agregar campos de clasificación como:
</p>
<ul class="mt-2 text-sm text-muted-foreground list-disc list-inside">
<li>Fracción arancelaria</li>
<li>Código de producto</li>
<li>Clasificación SCAC</li>
<li>Material type</li>
<li>Categoría de mercancía</li>
</ul>
</div>
</Tabs.Content>
<!-- Tab: Cantidades -->
<Tabs.Content value="cantidades" class="space-y-4 mt-4">
<div class="rounded-lg border p-4">
<p class="text-sm text-muted-foreground">
Aquí puedes agregar campos de cantidades como:
</p>
<ul class="mt-2 text-sm text-muted-foreground list-disc list-inside">
<li>Cantidad</li>
<li>Unidad de medida</li>
<li>Peso neto</li>
<li>Peso bruto</li>
<li>Valor unitario</li>
<li>Valor total</li>
</ul>
</div>
</Tabs.Content>
<!-- Tab: Otros -->
<Tabs.Content value="otros" class="space-y-4 mt-4">
<div class="rounded-lg border p-4">
<p class="text-sm text-muted-foreground">
Aquí puedes agregar otros campos como:
</p>
<ul class="mt-2 text-sm text-muted-foreground list-disc list-inside">
<li>País de origen</li>
<li>Observaciones</li>
<li>Documentos adjuntos</li>
<li>Información adicional</li>
</ul>
</div>
</Tabs.Content>
</Tabs.Root>
<Sheet.Footer class="mt-6 gap-2">
<Button variant="outline" onclick={() => showItemSheet = false} disabled={isSaving}>
Cancelar
</Button>
<Button onclick={saveItem} disabled={isSaving}>
{#if isSaving}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Guardando...
{:else}
{isEditMode ? 'Guardar Cambios' : 'Agregar Item'}
{/if}
</Button>
</Sheet.Footer>
</Sheet.Content>
</Sheet.Root>
<!-- Delete Confirmation Dialog -->
<Dialog.Root bind:open={showDeleteDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Confirmar Eliminación</Dialog.Title>
<Dialog.Description>
¿Está seguro que desea eliminar este item? Esta acción no se puede deshacer.
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button variant="outline" onclick={() => showDeleteDialog = false} disabled={isSaving}>
Cancelar
</Button>
<Button variant="destructive" onclick={confirmDelete} disabled={isSaving}>
{#if isSaving}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Eliminando...
{:else}
Eliminar
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
import { Toaster } from 'svelte-sonner';
let { children } = $props();
</script>
@@ -9,4 +10,5 @@
<link rel="icon" href={favicon} />
</svelte:head>
<Toaster richColors position="top-right" />
{@render children?.()}