Merge remote-tracking branch 'origin/24-nov' into development

This commit is contained in:
2025-12-08 10:39:22 -06:00
32 changed files with 2633 additions and 23 deletions

View File

@@ -17,7 +17,7 @@ class QClasses(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "fa_classes" # QClases __tablename__ = "fa_classes" # QClases
__table_args__ = ( __table_args__ = (
PrimaryKeyConstraint("id", name="qclases_pk"), PrimaryKeyConstraint("id", name="qclases_pk"),
ForeignKeyConstraint(["class_id"], ["classes.id"], name="fk_qclasses_classes"), ForeignKeyConstraint(["class_id"], ["a76.classes.id"], name="fk_qclasses_classes"),
{"schema": "a24"}, {"schema": "a24"},
) )

View File

@@ -42,6 +42,7 @@ class Class(Base, TenantScopedMixin, TimestampMixin):
UniqueConstraint( UniqueConstraint(
"tenant_id", "tenant_id",
"company_id", "company_id",
"client_id",
"class_code", "class_code",
name="ufa_classes_client_id_class_code", name="ufa_classes_client_id_class_code",
), ),

View File

@@ -102,6 +102,7 @@ class ClassService:
existing = db.query(Class).filter( existing = db.query(Class).filter(
Class.tenant_id == tenant_id, Class.tenant_id == tenant_id,
Class.company_id == company_id, Class.company_id == company_id,
Class.client_id == data_dict["client_id"],
Class.class_code == data_dict["class_code"] Class.class_code == data_dict["class_code"]
).first() ).first()

View File

@@ -1,10 +1,11 @@
from datetime import datetime
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class ExchangeRateBaseDTO(BaseModel): class ExchangeRateBaseDTO(BaseModel):
date: int = Field(..., description="Exchange rate date") date: datetime = Field(..., description="Exchange rate date")
value: Optional[float] = Field(None, description="Exchange rate value") value: Optional[float] = Field(None, description="Exchange rate value")
local_currency: Optional[str] = Field(None, max_length=7, description="Local currency code") local_currency: Optional[str] = Field(None, max_length=7, description="Local currency code")
foreign_currency: Optional[str] = Field(None, max_length=7, description="Foreign currency code") foreign_currency: Optional[str] = Field(None, max_length=7, description="Foreign currency code")
@@ -17,7 +18,7 @@ class ExchangeRateCreateDTO(ExchangeRateBaseDTO):
class ExchangeRateUpdateDTO(ExchangeRateBaseDTO): class ExchangeRateUpdateDTO(ExchangeRateBaseDTO):
"""Schema for updating an exchange rate""" """Schema for updating an exchange rate"""
date: Optional[int] = Field(None, description="Exchange rate date") date: Optional[datetime] = Field(None, description="Exchange rate date")
class ExchangeRateResponseDTO(ExchangeRateBaseDTO): class ExchangeRateResponseDTO(ExchangeRateBaseDTO):

View File

@@ -1,3 +1,4 @@
from datetime import datetime
from decimal import Decimal from decimal import Decimal
from typing import Optional from typing import Optional
@@ -28,7 +29,7 @@ class ExchangeRate(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
date: Mapped[int] = mapped_column(DateTime) date: Mapped[datetime] = mapped_column(DateTime)
value: Mapped[Optional[Decimal]] = mapped_column(DECIMAL(13, 6)) value: Mapped[Optional[Decimal]] = mapped_column(DECIMAL(13, 6))
local_currency: Mapped[Optional[str]] = mapped_column(String(7)) local_currency: Mapped[Optional[str]] = mapped_column(String(7))
foreign_currency: Mapped[Optional[str]] = mapped_column(String(7)) foreign_currency: Mapped[Optional[str]] = mapped_column(String(7))

View File

@@ -2,6 +2,7 @@
DTOs for Packages (GBultos). DTOs for Packages (GBultos).
""" """
from datetime import datetime
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -33,8 +34,8 @@ class PackageResponseDTO(PackageBaseDTO):
id: int id: int
company_id: int company_id: int
tenant_id: int tenant_id: int
created_at: Optional[str] = None created_at: Optional[datetime] = None
updated_at: Optional[str] = None updated_at: Optional[datetime] = None
class Config: class Config:
from_attributes = True from_attributes = True

View File

@@ -13,11 +13,11 @@ router = TenantCRUDRoutes(
create_schema=PackageCreateDTO, create_schema=PackageCreateDTO,
update_schema=PackageUpdateDTO, update_schema=PackageUpdateDTO,
response_schema=PackageResponseDTO, response_schema=PackageResponseDTO,
prefix="/package", prefix="/packages",
tags=[], tags=["a76 / packages"],
resource_name="Package", resource_name="Package",
id_name="id", # Using numeric ID id_name="package_id",
enable_list=True, # Enable GET /package with pagination enable_list=True, # Enable GET /packages with pagination
enable_filters=True, # Enable filtering by key and description_es enable_filters=True, # Enable filtering by key and description_es
default_page_size=50, default_page_size=50,
max_page_size=100, max_page_size=100,

View File

@@ -75,8 +75,8 @@ class SealService:
db: Session, db: Session,
seal_id: int, seal_id: int,
tenant_id: int, tenant_id: int,
company_id: int,
seal_data: dto.SealUpdateDTO, seal_data: dto.SealUpdateDTO,
company_id: int,
) -> Optional[models.Seal]: ) -> Optional[models.Seal]:
"""Update a seal""" """Update a seal"""
seal = SealService.get_by_id(db, seal_id, tenant_id, company_id) seal = SealService.get_by_id(db, seal_id, tenant_id, company_id)

View File

@@ -46,7 +46,7 @@ router.include_router(parts_router, prefix="/a76", tags=["a76 / parts"])
router.include_router( router.include_router(
permission_rule_oct_router, prefix="/a76", tags=["a76 / permission_rule_oct"] permission_rule_oct_router, prefix="/a76", tags=["a76 / permission_rule_oct"]
) )
router.include_router(package_router, prefix="/a76", tags=["a76 / package"]) router.include_router(package_router, prefix="/a76")
router.include_router(seal_router, prefix="/a76", tags=["a76 / seal"]) router.include_router(seal_router, prefix="/a76", tags=["a76 / seal"])
router.include_router( router.include_router(
fraction_rule_octave_router, prefix="/a76", tags=["a76 / fraction_rule_octave"] fraction_rule_octave_router, prefix="/a76", tags=["a76 / fraction_rule_octave"]

View File

@@ -0,0 +1,91 @@
import type { PaginatedResponse } from '$lib/types';
import { api } from '$lib/api';
export interface ExchangeRate {
id: number;
date: string;
value: number | null;
local_currency: string | null;
foreign_currency: string | null;
company_id: number;
tenant_id: number;
}
export interface ExchangeRateCreate {
date: string;
value?: number | null;
local_currency?: string | null;
foreign_currency?: string | null;
}
export interface ExchangeRateUpdate {
date?: string;
value?: number | null;
local_currency?: string | null;
foreign_currency?: string | null;
}
export interface ExchangeRateListResponse extends PaginatedResponse {
items: ExchangeRate[];
}
export interface ExchangeRateFilters {
date?: string;
local_currency?: string;
foreign_currency?: string;
page?: number;
page_size?: number;
}
export async function getExchangeRates(
companyId: number,
filters?: ExchangeRateFilters
): Promise<ExchangeRateListResponse> {
const params = new URLSearchParams({ company_id: companyId.toString() });
if (filters) {
if (filters.date) params.append('date', filters.date);
if (filters.local_currency) params.append('local_currency', filters.local_currency);
if (filters.foreign_currency) params.append('foreign_currency', filters.foreign_currency);
if (filters.page) params.append('page', filters.page.toString());
if (filters.page_size) params.append('page_size', filters.page_size.toString());
}
return api.get<ExchangeRateListResponse>(`/v1/a76/exchange-rate/?${params.toString()}`);
}
export async function getExchangeRate(
exchangeRateId: number,
companyId: number
): Promise<ExchangeRate> {
const params = new URLSearchParams({ company_id: companyId.toString() });
return api.get<ExchangeRate>(`/v1/a76/exchange-rate/${exchangeRateId}?${params.toString()}`);
}
export async function createExchangeRate(
data: ExchangeRateCreate,
companyId: number
): Promise<ExchangeRate> {
const params = new URLSearchParams({ company_id: companyId.toString() });
return api.post<ExchangeRate>(`/v1/a76/exchange-rate/?${params.toString()}`, data);
}
export async function updateExchangeRate(
exchangeRateId: number,
data: ExchangeRateUpdate,
companyId: number
): Promise<ExchangeRate> {
const params = new URLSearchParams({ company_id: companyId.toString() });
return api.put<ExchangeRate>(
`/v1/a76/exchange-rate/${exchangeRateId}?${params.toString()}`,
data
);
}
export async function deleteExchangeRate(
exchangeRateId: number,
companyId: number
): Promise<void> {
const params = new URLSearchParams({ company_id: companyId.toString() });
return api.delete(`/v1/a76/exchange-rate/${exchangeRateId}?${params.toString()}`);
}

View File

@@ -2,3 +2,5 @@
* Exportaciones de APIs para módulo A76 * Exportaciones de APIs para módulo A76
*/ */
export * from './classes'; export * from './classes';
export * from './packages';
export * from './exchange-rate';

View File

@@ -0,0 +1,122 @@
/**
* API para gestión de Packages (Bultos/Embalajes A76)
*/
import { api } from '$lib/api';
import type { ApiResponse } from '$lib/api';
export interface Package {
id: number;
tenant_id: number;
company_id: number;
key: string;
description_es: string | null;
description_en: string | null;
weight_unit: number | null;
plurals: string | null;
plural_in: string | null;
code_ace: string | null;
code_aamex: string | null;
created_at: string | null;
updated_at: string | null;
}
export interface PackageCreate {
key: string;
description_es?: string | null;
description_en?: string | null;
weight_unit?: number | null;
plurals?: string | null;
plural_in?: string | null;
code_ace?: string | null;
code_aamex?: string | null;
}
export interface PackageUpdate {
key?: string;
description_es?: string | null;
description_en?: string | null;
weight_unit?: number | null;
plurals?: string | null;
plural_in?: string | null;
code_ace?: string | null;
code_aamex?: string | null;
}
export interface PackageListResponse {
items: Package[];
total: number;
page: number;
page_size: number;
pages: number;
}
export interface PackageFilters {
key?: string;
description_es?: string;
}
/**
* Obtener lista de packages con paginación
*/
export async function getPackages(
companyId: number,
page: number = 1,
pageSize: number = 50,
filters?: PackageFilters
): Promise<ApiResponse<PackageListResponse>> {
const params = new URLSearchParams({
company_id: companyId.toString(),
page: page.toString(),
page_size: pageSize.toString()
});
if (filters?.key) {
params.append('key', filters.key);
}
if (filters?.description_es) {
params.append('description_es', filters.description_es);
}
return api.get<PackageListResponse>(`/v1/a76/packages/?${params.toString()}`);
}
/**
* Obtener un package por ID
*/
export async function getPackage(
packageId: number,
companyId: number
): Promise<ApiResponse<Package>> {
return api.get<Package>(`/v1/a76/packages/${packageId}?company_id=${companyId}`);
}
/**
* Crear un nuevo package
*/
export async function createPackage(
data: PackageCreate,
companyId: number
): Promise<ApiResponse<Package>> {
return api.post<Package>(`/v1/a76/packages/?company_id=${companyId}`, data);
}
/**
* Actualizar un package existente
*/
export async function updatePackage(
packageId: number,
data: PackageUpdate,
companyId: number
): Promise<ApiResponse<Package>> {
return api.put<Package>(`/v1/a76/packages/${packageId}?company_id=${companyId}`, data);
}
/**
* Eliminar un package
*/
export async function deletePackage(
packageId: number,
companyId: number
): Promise<ApiResponse<void>> {
return api.delete<void>(`/v1/a76/packages/${packageId}?company_id=${companyId}`);
}

View File

@@ -0,0 +1,95 @@
/**
* API client for Seal operations
*/
import { api } from '$lib/api';
export interface Seal {
id: number;
seal: string;
company_id: number;
tenant_id: number;
}
export interface SealListResponse {
items: Seal[];
total: number;
page: number;
page_size: number;
pages: number;
}
export interface SealCreateRequest {
seal: string;
}
export interface SealUpdateRequest {
seal?: string;
}
/**
* Get all seals with pagination and filters
*/
export async function getSeals(
companyId: number,
filters?: {
page?: number;
page_size?: number;
seal?: string;
}
): Promise<{ data: SealListResponse; status: number }> {
const params = new URLSearchParams();
params.append('company_id', companyId.toString());
if (filters?.page) params.append('page', filters.page.toString());
if (filters?.page_size) params.append('page_size', filters.page_size.toString());
if (filters?.seal) params.append('seal', filters.seal);
const response = await api.get(`/v1/a76/seals?${params.toString()}`);
return response;
}
/**
* Get a single seal by ID
*/
export async function getSeal(
id: number,
companyId: number
): Promise<{ data: Seal; status: number }> {
const response = await api.get(`/v1/a76/seals/${id}?company_id=${companyId}`);
return response;
}
/**
* Create a new seal
*/
export async function createSeal(
data: SealCreateRequest,
companyId: number
): Promise<{ data: Seal; status: number }> {
const response = await api.post(`/v1/a76/seals?company_id=${companyId}`, data);
return response;
}
/**
* Update an existing seal
*/
export async function updateSeal(
id: number,
data: SealUpdateRequest,
companyId: number
): Promise<{ data: Seal; status: number }> {
const response = await api.put(`/v1/a76/seals/${id}?company_id=${companyId}`, data);
return response;
}
/**
* Delete a seal
*/
export async function deleteSeal(
id: number,
companyId: number
): Promise<{ data: any; status: number }> {
const response = await api.delete(`/v1/a76/seals/${id}?company_id=${companyId}`);
return response;
}

View File

@@ -0,0 +1,95 @@
/**
* API client for Countries operations
*/
import { api } from '$lib/api';
export interface Country {
m3_key: string;
mex_key: string;
ame_key: string;
description_es: string;
description_en: string;
}
export interface CountryListResponse {
items: Country[];
total: number;
page: number;
page_size: number;
}
export interface CountryCreateRequest {
m3_key: string;
mex_key: string;
ame_key: string;
description_es: string;
description_en: string;
}
export interface CountryUpdateRequest {
m3_key: string;
mex_key: string;
ame_key: string;
description_es: string;
description_en: string;
}
/**
* Get all countries with pagination
*/
export async function getCountries(
filters?: {
page?: number;
page_size?: number;
}
): Promise<{ data: CountryListResponse; status: number }> {
const params = new URLSearchParams();
if (filters?.page) params.append('page', filters.page.toString());
if (filters?.page_size) params.append('page_size', filters.page_size.toString());
const response = await api.get(`/v1/public/refrence_data/countries?${params.toString()}`);
return response;
}
/**
* Get a single country by m3_key
*/
export async function getCountry(
m3_key: string
): Promise<{ data: Country; status: number }> {
const response = await api.get(`/v1/public/refrence_data/countries/${m3_key}`);
return response;
}
/**
* Create a new country
*/
export async function createCountry(
data: CountryCreateRequest
): Promise<{ data: Country; status: number }> {
const response = await api.post(`/v1/public/refrence_data/countries`, data);
return response;
}
/**
* Update an existing country
*/
export async function updateCountry(
m3_key: string,
data: CountryUpdateRequest
): Promise<{ data: Country; status: number }> {
const response = await api.put(`/v1/public/refrence_data/countries/${m3_key}`, data);
return response;
}
/**
* Delete a country
*/
export async function deleteCountry(
m3_key: string
): Promise<{ data: any; status: number }> {
const response = await api.delete(`/v1/public/refrence_data/countries/${m3_key}`);
return response;
}

View File

@@ -7,9 +7,9 @@
import * as Select from "$lib/components/ui/select"; import * as Select from "$lib/components/ui/select";
import { classesApi, type A76Class, type A76ClassCreate, type A76ClassUpdate } from "$lib/api/dashboard/a76/classes"; import { classesApi, type A76Class, type A76ClassCreate, type A76ClassUpdate } from "$lib/api/dashboard/a76/classes";
import { materialTypesApi, type MaterialType } from "$lib/api/dashboard/refrence_data/material_types"; import { materialTypesApi, type MaterialType } from "$lib/api/dashboard/refrence_data/material_types";
import { clientsProvidersApi, type ClientProviderBasic } from "$lib/api/dashboard/a76/clients-providers";
import { companyStore } from "$lib/stores/company.svelte"; import { companyStore } from "$lib/stores/company.svelte";
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { LoaderCircle, Home } from 'lucide-svelte';
let { let {
open = $bindable(false), open = $bindable(false),
@@ -27,6 +27,7 @@
// Estado del formulario // Estado del formulario
let formData = $state({ let formData = $state({
client_id: item?.client_id || null,
class_code: item?.class_code || '', class_code: item?.class_code || '',
description_es: item?.description_es || '', description_es: item?.description_es || '',
description_en: item?.description_en || '', description_en: item?.description_en || '',
@@ -43,17 +44,23 @@
let error = $state<string | null>(null); let error = $state<string | null>(null);
let materialTypes = $state<MaterialType[]>([]); let materialTypes = $state<MaterialType[]>([]);
let loadingMaterialTypes = $state(false); let loadingMaterialTypes = $state(false);
let clients = $state<ClientProviderBasic[]>([]);
let loadingClients = $state(false);
// Variables para controlar los selects // Variables para controlar los selects
let selectedUnitValue = $state<string>('KG'); let selectedUnitValue = $state<string>('KG');
let selectedMaterialValue = $state<string>(''); let selectedMaterialValue = $state<string>('');
let selectedPhysicalReviewValue = $state<number>(0); let selectedPhysicalReviewValue = $state<number>(0);
// Cargar tipos de materiales al montar // Cargar tipos de materiales y clientes al montar
onMount(async () => { onMount(async () => {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
// Cargar tipos de materiales
loadingMaterialTypes = true; loadingMaterialTypes = true;
try { try {
const response = await materialTypesApi.list(1, 100); // Cargar los primeros 100 const response = await materialTypesApi.list(1, 100);
if (response.data) { if (response.data) {
materialTypes = response.data.items; materialTypes = response.data.items;
} }
@@ -62,12 +69,26 @@
} finally { } finally {
loadingMaterialTypes = false; loadingMaterialTypes = false;
} }
// Cargar clientes
loadingClients = true;
try {
const response = await clientsProvidersApi.listClients(companyId, 0, 500);
if (response.data) {
clients = response.data;
}
} catch (e) {
console.error('Error loading clients:', e);
} finally {
loadingClients = false;
}
}); });
// Resetear formulario cuando cambia el item // Resetear formulario cuando cambia el item
$effect(() => { $effect(() => {
if (item) { if (item) {
formData = { formData = {
client_id: item.client_id,
class_code: item.class_code, class_code: item.class_code,
description_es: item.description_es || '', description_es: item.description_es || '',
description_en: item.description_en || '', description_en: item.description_en || '',
@@ -86,6 +107,7 @@
} else { } else {
// Reset para modo crear // Reset para modo crear
formData = { formData = {
client_id: null,
class_code: '', class_code: '',
description_es: '', description_es: '',
description_en: '', description_en: '',
@@ -122,6 +144,10 @@
} }
// Validaciones básicas // Validaciones básicas
if (!formData.client_id) {
error = 'Debes seleccionar un cliente';
return;
}
if (!formData.class_code.trim()) { if (!formData.class_code.trim()) {
error = 'El código de clase es requerido'; error = 'El código de clase es requerido';
return; return;
@@ -152,6 +178,7 @@
if (isEdit && item) { if (isEdit && item) {
// Actualizar // Actualizar
const updateData: A76ClassUpdate = { const updateData: A76ClassUpdate = {
client_id: formData.client_id!,
class_code: formData.class_code, class_code: formData.class_code,
description_es: formData.description_es || null, description_es: formData.description_es || null,
description_en: formData.description_en || null, description_en: formData.description_en || null,
@@ -165,10 +192,10 @@
}; };
response = await classesApi.update(item.id, updateData, companyId); response = await classesApi.update(item.id, updateData, companyId);
} else { } else {
// Crear - usa el company_id como client_id // Crear con el client_id seleccionado
const createData: A76ClassCreate = { const createData: A76ClassCreate = {
company_id: companyId, company_id: companyId,
client_id: companyId, // Usa el mismo company_id como client_id client_id: formData.client_id!,
class_code: formData.class_code, class_code: formData.class_code,
description_es: formData.description_es || null, description_es: formData.description_es || null,
description_en: formData.description_en || null, description_en: formData.description_en || null,
@@ -249,7 +276,21 @@
{#if companyStore.activeCompany} {#if companyStore.activeCompany}
<div class="rounded-md bg-blue-50 border border-blue-200 p-3"> <div class="rounded-md bg-blue-50 border border-blue-200 p-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Home class="h-4 w-4 text-blue-600" /> <svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-blue-600"
>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
<div> <div>
<p class="text-sm font-medium text-blue-900"> <p class="text-sm font-medium text-blue-900">
{companyStore.activeCompany.name} {companyStore.activeCompany.name}
@@ -262,6 +303,34 @@
</div> </div>
{/if} {/if}
<!-- Cliente -->
<div class="space-y-2">
<Label for="client_id" class="required">Cliente</Label>
{#if loadingClients}
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<div class="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
Cargando clientes...
</div>
{:else if clients.length > 0}
<select
bind:value={formData.client_id}
disabled={loading}
class="border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
>
<option value="">Selecciona un cliente</option>
{#each clients as client}
<option value={client.id}>
{client.name} ({client.rfc})
</option>
{/each}
</select>
{:else}
<div class="text-sm text-muted-foreground">
No hay clientes disponibles
</div>
{/if}
</div>
<!-- Código de Clase --> <!-- Código de Clase -->
<div class="space-y-2"> <div class="space-y-2">
<Label for="class_code" class="required">Código de Clase</Label> <Label for="class_code" class="required">Código de Clase</Label>
@@ -413,7 +482,26 @@
</Button> </Button>
<Button type="submit" disabled={loading}> <Button type="submit" disabled={loading}>
{#if loading} {#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" /> <svg
class="mr-2 h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Guardando... Guardando...
{:else} {:else}
{isEdit ? 'Actualizar' : 'Crear'} {isEdit ? 'Actualizar' : 'Crear'}

View File

@@ -0,0 +1,48 @@
import type { ColumnDef } from '@tanstack/table-core';
import type { ExchangeRate } from '$lib/api/dashboard/a76/exchange-rate';
import { renderComponent } from '$lib/components/ui/data-table';
import DataTableActions from './data-table-actions.svelte';
export function createColumns(onSuccess?: () => void): ColumnDef<ExchangeRate>[] {
return [
{
accessorKey: 'date',
header: 'Fecha',
cell: ({ row }) => {
const dateStr = row.original.date;
if (!dateStr) return 'N/A';
const date = new Date(dateStr);
return date.toLocaleDateString('es-MX');
}
},
{
accessorKey: 'value',
header: 'Tipo de Cambio',
cell: ({ row }) => {
const value = row.original.value;
if (value === null || value === undefined) return 'N/A';
return value.toFixed(6);
}
},
{
accessorKey: 'local_currency',
header: 'Moneda Local',
cell: ({ row }) => row.original.local_currency ?? 'N/A'
},
{
accessorKey: 'foreign_currency',
header: 'Moneda Extranjera',
cell: ({ row }) => row.original.foreign_currency ?? 'N/A'
},
{
id: 'actions',
header: 'Acciones',
cell: ({ row }) => {
return renderComponent(DataTableActions, {
item: row.original,
onSuccess
});
}
}
];
}

View File

@@ -0,0 +1,164 @@
<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 { LoaderCircle } from 'lucide-svelte';
import type {
ExchangeRate,
ExchangeRateCreate,
ExchangeRateUpdate
} from '$lib/api/dashboard/a76/exchange-rate';
import { createExchangeRate, updateExchangeRate } from '$lib/api/dashboard/a76/exchange-rate';
import { companyStore } from '$lib/stores/company.svelte';
interface Props {
open: boolean;
item?: ExchangeRate | null;
onOpenChange: (open: boolean) => void;
onSuccess?: (item: ExchangeRate) => void;
}
let { open = $bindable(false), item = null, onOpenChange, onSuccess }: Props = $props();
let formData = $state<ExchangeRateCreate | ExchangeRateUpdate>({
date: '',
value: null,
local_currency: null,
foreign_currency: null
});
let loading = $state(false);
let error = $state<string | null>(null);
let isEdit = $derived(!!item);
$effect(() => {
if (item) {
const date = new Date(item.date);
const dateStr = date.toISOString().split('T')[0];
formData = {
date: dateStr,
value: item.value,
local_currency: item.local_currency,
foreign_currency: item.foreign_currency
};
} else {
const today = new Date();
const dateStr = today.toISOString().split('T')[0];
formData = {
date: dateStr,
value: null,
local_currency: null,
foreign_currency: null
};
}
error = null;
});
async function handleSubmit(e: Event) {
e.preventDefault();
loading = true;
error = null;
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
error = 'No hay una empresa seleccionada';
loading = false;
return;
}
try {
let result: ExchangeRate;
if (isEdit && item) {
result = await updateExchangeRate(item.id, formData as ExchangeRateUpdate, companyId);
} else {
result = await createExchangeRate(formData as ExchangeRateCreate, companyId);
}
if (onSuccess) {
onSuccess(result);
}
onOpenChange(false);
} catch (err: any) {
error = err.message || `Error al ${isEdit ? 'actualizar' : 'crear'} el tipo de cambio`;
} finally {
loading = false;
}
}
</script>
<Dialog.Root {open} onOpenChange={onOpenChange}>
<Dialog.Content class="sm:max-w-[500px]">
<Dialog.Header>
<Dialog.Title>{isEdit ? 'Editar' : 'Crear'} Tipo de Cambio</Dialog.Title>
</Dialog.Header>
<form onsubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<Label for="date">Fecha *</Label>
<Input
id="date"
type="date"
bind:value={formData.date}
required
disabled={loading}
/>
</div>
<div class="space-y-2">
<Label for="value">Tipo de Cambio *</Label>
<Input
id="value"
type="number"
step="0.000001"
bind:value={formData.value}
placeholder="0.000000"
required
disabled={loading}
/>
</div>
<div class="space-y-2">
<Label for="local_currency">Moneda Local</Label>
<Input
id="local_currency"
type="text"
maxlength="7"
bind:value={formData.local_currency}
placeholder="MXN"
disabled={loading}
/>
</div>
<div class="space-y-2">
<Label for="foreign_currency">Moneda Extranjera</Label>
<Input
id="foreign_currency"
type="text"
maxlength="7"
bind:value={formData.foreign_currency}
placeholder="USD"
disabled={loading}
/>
</div>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" onclick={() => onOpenChange(false)} disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{/if}
{isEdit ? 'Actualizar' : 'Crear'}
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,99 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { EllipsisVertical, Pencil, Trash2, LoaderCircle } from 'lucide-svelte';
import type { ExchangeRate } from '$lib/api/dashboard/a76/exchange-rate';
import { deleteExchangeRate } from '$lib/api/dashboard/a76/exchange-rate';
import { companyStore } from '$lib/stores/company.svelte';
import CreateEditDialog from './create-edit-dialog.svelte';
let {
item,
onSuccess
}: {
item: ExchangeRate;
onSuccess?: () => void;
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let dialogOpen = $state(false);
let selectedItem = $state<ExchangeRate | null>(null);
async function handleDelete() {
if (!confirm('¿Está seguro de que desea eliminar este tipo de cambio?')) {
return;
}
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
alert('No hay compañía seleccionada');
return;
}
loading = true;
error = null;
try {
await deleteExchangeRate(item.id, companyId);
// Éxito
if (onSuccess) {
onSuccess();
}
} catch (err: any) {
error = err.message || 'Error al eliminar el tipo de cambio';
alert(`Error: ${error}`);
console.error('Error deleting:', err);
} 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}
/>

View File

@@ -0,0 +1,123 @@
<script lang="ts" generics="TData, TValue">
import { onMount } from 'svelte';
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";
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
loading: boolean;
hasMore: boolean;
loadMore: () => void;
};
let {
data,
columns,
loading,
hasMore,
loadMore
}: DataTableProps<TData, TValue> = $props();
const table = createSvelteTable({
get data() {
return data;
},
columns,
getCoreRowModel: getCoreRowModel()
});
let scrollContainer = $state<HTMLDivElement>();
let loadingTrigger = $state<HTMLDivElement>();
// Intersection Observer para detectar cuando el usuario llega al final
onMount(() => {
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
if (entry.isIntersecting && hasMore && !loading) {
loadMore();
}
},
{
root: scrollContainer,
threshold: 0.1
}
);
if (loadingTrigger) {
observer.observe(loadingTrigger);
}
return () => {
observer.disconnect();
};
});
</script>
<div class="w-full">
<div class="rounded-md border max-h-[600px] overflow-y-auto" bind:this={scrollContainer}>
<Table.Root>
<Table.Header class="sticky top-0 bg-background z-10">
{#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}
<!-- Loading Trigger - Se activa cuando es visible -->
{#if hasMore}
<Table.Row>
<Table.Cell colspan={columns.length} class="h-20 text-center">
<div bind:this={loadingTrigger}>
{#if loading}
<div class="flex items-center justify-center gap-2">
<div class="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
<span class="text-muted-foreground text-sm">Cargando más...</span>
</div>
{:else}
<div class="text-muted-foreground text-sm">
Desplázate para cargar más
</div>
{/if}
</div>
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
</div>

View File

@@ -0,0 +1,71 @@
/**
* Definición de columnas para la tabla de Packages
*/
import type { Package } from '$lib/api/dashboard/a76/packages';
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<Package>[] {
return [
{
accessorKey: 'key',
header: 'Clave',
cell: ({ row }) => {
return row.original.key;
}
},
{
accessorKey: 'description_es',
header: 'Descripción (ES)',
cell: ({ row }) => {
return row.original.description_es || '-';
}
},
{
accessorKey: 'description_en',
header: 'Descripción (EN)',
cell: ({ row }) => {
return row.original.description_en || '-';
}
},
{
accessorKey: 'weight_unit',
header: 'Peso Unitario',
cell: ({ row }) => {
return row.original.weight_unit ? row.original.weight_unit.toString() : '-';
}
},
{
accessorKey: 'plurals',
header: 'Plural',
cell: ({ row }) => {
return row.original.plurals || '-';
}
},
{
accessorKey: 'code_ace',
header: 'Código ACE',
cell: ({ row }) => {
return row.original.code_ace || '-';
}
},
{
accessorKey: 'code_aamex',
header: 'Código AAMEX',
cell: ({ row }) => {
return row.original.code_aamex || '-';
}
},
{
id: 'actions',
header: 'Acciones',
cell: ({ row }) => {
return renderComponent(DataTableActions, {
item: row.original,
onSuccess
});
}
}
];
}

View File

@@ -0,0 +1,260 @@
<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 { createPackage, updatePackage, type Package, type PackageCreate, type PackageUpdate } from "$lib/api/dashboard/a76/packages";
import { companyStore } from "$lib/stores/company.svelte";
let {
open = $bindable(false),
item = null,
onSuccess
}: {
open: boolean;
item?: Package | null;
onSuccess?: () => void;
} = $props();
// Determinar si es modo edición o creación
const isEdit = $derived(!!item);
const title = $derived(isEdit ? "Editar Bulto" : "Nuevo Bulto");
// Estado del formulario
let formData = $state({
key: item?.key || '',
description_es: item?.description_es || '',
description_en: item?.description_en || '',
weight_unit: item?.weight_unit || null,
plurals: item?.plurals || '',
plural_in: item?.plural_in || '',
code_ace: item?.code_ace || '',
code_aamex: item?.code_aamex || ''
});
let loading = $state(false);
let error = $state<string | null>(null);
// Resetear formulario cuando cambia el item
$effect(() => {
if (item) {
formData = {
key: item.key,
description_es: item.description_es || '',
description_en: item.description_en || '',
weight_unit: item.weight_unit,
plurals: item.plurals || '',
plural_in: item.plural_in || '',
code_ace: item.code_ace || '',
code_aamex: item.code_aamex || ''
};
} else {
formData = {
key: '',
description_es: '',
description_en: '',
weight_unit: null,
plurals: '',
plural_in: '',
code_ace: '',
code_aamex: ''
};
}
});
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.key.trim()) {
throw new Error('La clave es requerida');
}
if (formData.key.length > 5) {
throw new Error('La clave no puede tener más de 5 caracteres');
}
// Preparar datos
const dataToSend = {
key: formData.key.trim(),
description_es: formData.description_es.trim() || null,
description_en: formData.description_en.trim() || null,
weight_unit: formData.weight_unit,
plurals: formData.plurals.trim() || null,
plural_in: formData.plural_in.trim() || null,
code_ace: formData.code_ace.trim() || null,
code_aamex: formData.code_aamex.trim() || null
};
let response;
if (isEdit && item) {
response = await updatePackage(item.id, dataToSend as PackageUpdate, companyId);
} else {
response = await createPackage(dataToSend as PackageCreate, 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 bulto';
} finally {
loading = false;
}
}
function handleCancel() {
open = false;
error = null;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-2xl max-h-[90vh] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>
{isEdit ? 'Modifica los datos del bulto' : 'Completa los datos para crear un nuevo bulto'}
</Dialog.Description>
</Dialog.Header>
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
{#if error}
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<div class="grid gap-4">
<!-- Clave -->
<div class="grid gap-2">
<Label for="key">
Clave <span class="text-destructive">*</span>
</Label>
<Input
id="key"
bind:value={formData.key}
placeholder="Ej: CAJA"
maxlength={5}
required
disabled={isEdit}
/>
<p class="text-xs text-muted-foreground">
Máximo 5 caracteres. {isEdit ? 'No se puede modificar en edición.' : ''}
</p>
</div>
<!-- Descripciones -->
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="description_es">Descripción (Español)</Label>
<Input
id="description_es"
bind:value={formData.description_es}
placeholder="Ej: Caja de cartón"
maxlength={40}
/>
</div>
<div class="grid gap-2">
<Label for="description_en">Descripción (Inglés)</Label>
<Input
id="description_en"
bind:value={formData.description_en}
placeholder="Ej: Cardboard box"
maxlength={40}
/>
</div>
</div>
<!-- Peso Unitario -->
<div class="grid gap-2">
<Label for="weight_unit">Peso Unitario</Label>
<Input
id="weight_unit"
type="number"
step="0.00000001"
bind:value={formData.weight_unit}
placeholder="0.00"
/>
<p class="text-xs text-muted-foreground">
Peso unitario del bulto (hasta 8 decimales)
</p>
</div>
<!-- Plurales -->
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="plurals">Plural</Label>
<Input
id="plurals"
bind:value={formData.plurals}
placeholder="Ej: CAJS"
maxlength={4}
/>
</div>
<div class="grid gap-2">
<Label for="plural_in">Plural (Inglés)</Label>
<Input
id="plural_in"
bind:value={formData.plural_in}
placeholder="Ej: BOXS"
maxlength={4}
/>
</div>
</div>
<!-- Códigos -->
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="code_ace">Código ACE</Label>
<Input
id="code_ace"
bind:value={formData.code_ace}
placeholder="Código ACE"
maxlength={4}
/>
</div>
<div class="grid gap-2">
<Label for="code_aamex">Código AAMEX</Label>
<Input
id="code_aamex"
bind:value={formData.code_aamex}
placeholder="Código AAMEX"
maxlength={9}
/>
</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>

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { deletePackage, type Package } from "$lib/api/dashboard/a76/packages";
import { companyStore } from "$lib/stores/company.svelte";
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
import CreateEditDialog from "./create-edit-dialog.svelte";
let {
package: item,
onSuccess
}: {
package: Package;
onSuccess?: () => void;
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let dialogOpen = $state(false);
let selectedItem = $state<Package | null>(null);
async function handleDelete() {
if (!confirm(`¿Estás seguro de eliminar el bulto "${item.key}"?`)) {
return;
}
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
alert('No hay compañía seleccionada');
return;
}
loading = true;
error = null;
try {
const response = await deletePackage(item.id, companyId);
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: ${response.error}`);
}
return;
}
// Éxito
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}
/>

View File

@@ -0,0 +1,123 @@
<script lang="ts" generics="TData, TValue">
import { onMount } from 'svelte';
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";
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
loading: boolean;
hasMore: boolean;
loadMore: () => void;
};
let {
data,
columns,
loading,
hasMore,
loadMore
}: DataTableProps<TData, TValue> = $props();
const table = createSvelteTable({
get data() {
return data;
},
columns,
getCoreRowModel: getCoreRowModel()
});
let scrollContainer = $state<HTMLDivElement>();
let loadingTrigger = $state<HTMLDivElement>();
// Intersection Observer para detectar cuando el usuario llega al final
onMount(() => {
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
if (entry.isIntersecting && hasMore && !loading) {
loadMore();
}
},
{
root: scrollContainer,
threshold: 0.1
}
);
if (loadingTrigger) {
observer.observe(loadingTrigger);
}
return () => {
observer.disconnect();
};
});
</script>
<div class="w-full">
<div class="rounded-md border max-h-[600px] overflow-y-auto" bind:this={scrollContainer}>
<Table.Root>
<Table.Header class="sticky top-0 bg-background z-10">
{#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}
<!-- Loading Trigger - Se activa cuando es visible -->
{#if hasMore}
<Table.Row>
<Table.Cell colspan={columns.length} class="h-20 text-center">
<div bind:this={loadingTrigger}>
{#if loading}
<div class="flex items-center justify-center gap-2">
<div class="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
<span class="text-muted-foreground text-sm">Cargando más...</span>
</div>
{:else}
<div class="text-muted-foreground text-sm">
Desplázate para cargar más
</div>
{/if}
</div>
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
</div>

View File

@@ -0,0 +1,27 @@
/**
* Column definitions for Seal table
*/
import type { ColumnDef } from '@tanstack/table-core';
import type { Seal } from '$lib/api/dashboard/a76/seal';
import { renderComponent } from '$lib/components/ui/data-table';
import DataTableActions from './data-table-actions.svelte';
export function createColumns(onSuccess?: () => void): ColumnDef<Seal>[] {
return [
{
accessorKey: 'seal',
header: 'Sello',
cell: ({ row }) => row.original.seal
},
{
id: 'actions',
header: 'Acciones',
cell: ({ row }) => {
return renderComponent(DataTableActions, {
item: row.original,
onSuccess
});
}
}
];
}

View File

@@ -0,0 +1,132 @@
<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 { LoaderCircle } from 'lucide-svelte';
import type { Seal } from '$lib/api/dashboard/a76/seal';
import { createSeal, updateSeal } from '$lib/api/dashboard/a76/seal';
import { companyStore } from '$lib/stores/company.svelte';
let {
open = $bindable(false),
item = null,
onSuccess
}: {
open?: boolean;
item?: Seal | null;
onSuccess?: (item: Seal) => void;
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let formData = $state({
seal: ''
});
const isEdit = $derived(!!item);
const title = $derived(isEdit ? 'Editar Sello' : 'Crear Sello');
const submitText = $derived(isEdit ? 'Guardar Cambios' : 'Crear');
// Reset form when dialog opens/closes or item changes
$effect(() => {
if (open && item) {
formData.seal = item.seal;
} else if (!open) {
// Reset when closing
formData.seal = '';
error = null;
}
});
async function handleSubmit(e: Event) {
e.preventDefault();
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
error = 'No hay compañía seleccionada';
return;
}
loading = true;
error = null;
try {
let response;
if (isEdit && item) {
response = await updateSeal(item.id, formData, companyId);
} else {
response = await createSeal(formData, companyId);
}
if (onSuccess) {
onSuccess(response.data);
}
open = false;
} catch (err: any) {
error = err.message || 'Error al guardar el sello';
console.error('Error saving seal:', err);
} finally {
loading = false;
}
}
function handleCancel() {
open = false;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[500px]">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>
{#if isEdit}
Modifica los datos del sello
{:else}
Ingresa los datos del nuevo sello
{/if}
</Dialog.Description>
</Dialog.Header>
<form onsubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<Label for="seal">
Sello <span class="text-destructive">*</span>
</Label>
<Input
id="seal"
bind:value={formData.seal}
placeholder="Ej: SEAL123456"
maxlength={15}
required
disabled={loading}
/>
<p class="text-xs text-muted-foreground">
Máximo 15 caracteres
</p>
</div>
{#if error}
<div class="rounded-md bg-destructive/15 p-3">
<p class="text-sm text-destructive">{error}</p>
</div>
{/if}
<Dialog.Footer>
<Button type="button" variant="outline" onclick={handleCancel} disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{/if}
{submitText}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { EllipsisVertical, Pencil, Trash2, LoaderCircle } from 'lucide-svelte';
import type { Seal } from '$lib/api/dashboard/a76/seal';
import { deleteSeal } from '$lib/api/dashboard/a76/seal';
import { companyStore } from '$lib/stores/company.svelte';
import CreateEditDialog from './create-edit-dialog.svelte';
let {
item,
onSuccess
}: {
item: Seal;
onSuccess?: () => void;
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let dialogOpen = $state(false);
let selectedItem = $state<Seal | null>(null);
async function handleDelete() {
if (!confirm('¿Está seguro de que desea eliminar este sello?')) {
return;
}
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
alert('No hay compañía seleccionada');
return;
}
loading = true;
error = null;
try {
await deleteSeal(item.id, companyId);
if (onSuccess) {
onSuccess();
}
} catch (err: any) {
error = err.message || 'Error al eliminar el sello';
alert(`Error: ${error}`);
console.error('Error deleting:', err);
} 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}
/>

View File

@@ -0,0 +1,112 @@
<script lang="ts" generics="TData, TValue">
import { onMount } from 'svelte';
import type { ColumnDef } from '@tanstack/table-core';
import {
getCoreRowModel,
type TableOptions
} from '@tanstack/table-core';
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
import * as Table from '$lib/components/ui/table';
type Props = {
data: TData[];
columns: ColumnDef<TData, TValue>[];
loading?: boolean;
hasMore?: boolean;
loadMore?: () => void;
};
let { data, columns, loading = false, hasMore = false, loadMore }: Props = $props();
let scrollContainer: HTMLDivElement;
let observer: IntersectionObserver;
const options = $derived<TableOptions<TData>>({
get data() {
return data;
},
columns,
getCoreRowModel: getCoreRowModel()
});
const table = createSvelteTable(options);
onMount(() => {
if (!loadMore) return;
// Create intersection observer for infinite scroll
observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
if (entry.isIntersecting && hasMore && !loading && loadMore) {
loadMore();
}
},
{
root: scrollContainer,
threshold: 0.1
}
);
// Observe the last row
const lastRow = scrollContainer?.querySelector('tbody tr:last-child');
if (lastRow) {
observer.observe(lastRow);
}
return () => {
observer?.disconnect();
};
});
</script>
<div class="w-full">
<div class="rounded-md border max-h-[600px] overflow-y-auto" bind:this={scrollContainer}>
<Table.Root>
<Table.Header class="sticky top-0 bg-background z-10">
{#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}
{#if loading}
<Table.Row>
<Table.Cell colspan={columns.length} class="h-12 text-center text-muted-foreground">
Cargando...
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
</div>

View File

@@ -0,0 +1,7 @@
/**
* Seal components
*/
export { default as DataTable } from './data-table.svelte';
export { default as DataTableActions } from './data-table-actions.svelte';
export { default as CreateEditDialog } from './create-edit-dialog.svelte';
export { createColumns } from './columns';

View File

@@ -159,7 +159,7 @@ export function getSidebarData(): SidebarData {
}, },
{ {
title: m["sidebar.general_catalogs.packages"](), title: m["sidebar.general_catalogs.packages"](),
url: "#", url: "/dashboard/general_catalogs/packages",
}, },
{ {
title: m["sidebar.general_catalogs.concepts"](), title: m["sidebar.general_catalogs.concepts"](),
@@ -187,7 +187,7 @@ export function getSidebarData(): SidebarData {
}, },
{ {
title: m["sidebar.general_catalogs.seals"](), title: m["sidebar.general_catalogs.seals"](),
url: "#", url: "/dashboard/general_catalogs/seal",
}, },
{ {
title: m["sidebar.general_catalogs.valuation_methods"](), title: m["sidebar.general_catalogs.valuation_methods"](),
@@ -195,7 +195,7 @@ export function getSidebarData(): SidebarData {
}, },
{ {
title: m["sidebar.general_catalogs.countries"](), title: m["sidebar.general_catalogs.countries"](),
url: "#", url: "/dashboard/reference_data/countries",
}, },
{ {
title: m["sidebar.general_catalogs.ports"](), title: m["sidebar.general_catalogs.ports"](),
@@ -231,7 +231,7 @@ export function getSidebarData(): SidebarData {
}, },
{ {
title: m["sidebar.general_catalogs.exchange_rates"](), title: m["sidebar.general_catalogs.exchange_rates"](),
url: "#", url: "/dashboard/general_catalogs/exchange-rate",
}, },
{ {
title: m["sidebar.general_catalogs.currency_types"](), title: m["sidebar.general_catalogs.currency_types"](),

View File

@@ -0,0 +1,219 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Card from '$lib/components/ui/card';
import * as Alert from '$lib/components/ui/alert';
import { Plus, Search } from 'lucide-svelte';
import DataTable from '$lib/components/dashboard/exchange-rate/data-table.svelte';
import CreateEditDialog from '$lib/components/dashboard/exchange-rate/create-edit-dialog.svelte';
import { createColumns } from '$lib/components/dashboard/exchange-rate/columns';
import type { ExchangeRate } from '$lib/api/dashboard/a76/exchange-rate';
import { getExchangeRates } from '$lib/api/dashboard/a76/exchange-rate';
import { companyStore } from '$lib/stores/company.svelte';
let allItems = $state<ExchangeRate[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let currentPage = $state(1);
const pageSize = 50;
let dialogOpen = $state(false);
let editingItem = $state<ExchangeRate | null>(null);
// Filters
let dateFilter = $state('');
let localCurrencyFilter = $state('');
let foreignCurrencyFilter = $state('');
let hasMore = $derived(allItems.length >= currentPage * pageSize);
onMount(() => {
// Sync access_token from cookies to localStorage
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'access_token') {
localStorage.setItem('access_token', value);
break;
}
}
// Esperar a que el companyStore esté inicializado antes de cargar datos
const checkAndLoad = () => {
if (companyStore.activeCompany) {
loadInitialData();
} else {
// Si no hay compañía, esperar un poco y reintentar
setTimeout(checkAndLoad, 100);
}
};
checkAndLoad();
// Listen for company changes
const handleCompanyChange = () => {
loadInitialData();
};
window.addEventListener('companyChanged', handleCompanyChange);
return () => {
window.removeEventListener('companyChanged', handleCompanyChange);
};
});
async function loadInitialData() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
return;
}
allItems = [];
currentPage = 1;
await loadExchangeRates(1);
}
async function loadExchangeRates(page: number) {
const companyId = companyStore.activeCompany?.id;
if (!companyId || loading) {
return;
}
loading = true;
error = null;
try {
const filters: any = {
page,
page_size: pageSize
};
if (dateFilter) filters.date = dateFilter;
if (localCurrencyFilter) filters.local_currency = localCurrencyFilter;
if (foreignCurrencyFilter) filters.foreign_currency = foreignCurrencyFilter;
const response = await getExchangeRates(companyId, filters);
if (page === 1) {
allItems = response.data.items;
} else {
allItems = [...allItems, ...response.data.items];
}
currentPage = page;
} catch (err: any) {
error = err.message || 'Error al cargar los tipos de cambio';
} finally {
loading = false;
}
}
function loadMore() {
if (!loading && hasMore) {
loadExchangeRates(currentPage + 1);
}
}
function handleSearch() {
loadInitialData();
}
function handleCreate() {
editingItem = null;
dialogOpen = true;
}
function handleEdit(item: ExchangeRate) {
editingItem = item;
dialogOpen = true;
}
function handleSuccess(item?: ExchangeRate) {
// Reload data after create/edit/delete
loadInitialData();
}
const columns = createColumns(handleSuccess);
</script>
<svelte:head>
<title>Tipos de Cambio - Anexo 76</title>
</svelte:head>
<div class="space-y-6">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold tracking-tight">Tipos de Cambio</h1>
<p class="text-muted-foreground">Gestiona los tipos de cambio del sistema</p>
</div>
<Button onclick={handleCreate}>
<Plus class="mr-2 h-4 w-4" />
Crear Tipo de Cambio
</Button>
</div>
<Card.Root>
<Card.Header>
<Card.Title>Filtros</Card.Title>
</Card.Header>
<Card.Content>
<div class="grid gap-4 md:grid-cols-4">
<div class="space-y-2">
<Label for="date-filter">Fecha</Label>
<Input
id="date-filter"
type="date"
bind:value={dateFilter}
placeholder="Buscar por fecha..."
/>
</div>
<div class="space-y-2">
<Label for="local-currency-filter">Moneda Local</Label>
<Input
id="local-currency-filter"
bind:value={localCurrencyFilter}
placeholder="MXN"
/>
</div>
<div class="space-y-2">
<Label for="foreign-currency-filter">Moneda Extranjera</Label>
<Input
id="foreign-currency-filter"
bind:value={foreignCurrencyFilter}
placeholder="USD"
/>
</div>
<div class="flex items-end">
<Button onclick={handleSearch} class="w-full">
<Search class="mr-2 h-4 w-4" />
Buscar
</Button>
</div>
</div>
</Card.Content>
</Card.Root>
{#if error}
<Alert.Root variant="destructive">
<Alert.Title>Error</Alert.Title>
<Alert.Description>{error}</Alert.Description>
</Alert.Root>
{/if}
<DataTable
data={allItems}
{columns}
{loading}
{hasMore}
{loadMore}
/>
</div>
<CreateEditDialog
bind:open={dialogOpen}
item={editingItem}
onOpenChange={(open) => (dialogOpen = open)}
onSuccess={handleSuccess}
/>

View File

@@ -0,0 +1,326 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getPackages, type Package } from '$lib/api/dashboard/a76/packages';
import { companyStore } from '$lib/stores/company.svelte';
import DataTable from '$lib/components/dashboard/packages/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/packages/columns';
import CreateEditDialog from '$lib/components/dashboard/packages/create-edit-dialog.svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Plus, Filter, Trash2, RefreshCw } from 'lucide-svelte';
import { browser } from '$app/environment';
// Estado para la lista de packages
let allItems = $state<Package[]>([]);
let currentPage = $state(1);
let pageSize = $state(50);
let totalItems = $state(0);
let loading = $state(false);
let hasMore = $derived(allItems.length < totalItems);
let error = $state<string | null>(null);
// Estado para filtros
let filters = $state({
key: '',
description_es: ''
});
let showFilters = $state(false);
// Estado para el dialog de crear
let createDialogOpen = $state(false);
// Sincronizar token de cookies a localStorage al montar el componente
onMount(() => {
if (browser) {
// Función para obtener el valor de una cookie
const getCookie = (name: string): string | null => {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
return null;
};
// Verificar si hay token en las cookies
const cookieToken = getCookie('access_token');
const localToken = localStorage.getItem('access_token');
if (cookieToken && cookieToken !== localToken) {
localStorage.setItem('access_token', cookieToken);
}
// También sincronizar refresh_token si existe
const cookieRefreshToken = getCookie('refresh_token');
const localRefreshToken = localStorage.getItem('refresh_token');
if (cookieRefreshToken && cookieRefreshToken !== localRefreshToken) {
localStorage.setItem('refresh_token', cookieRefreshToken);
}
}
// Esperar a que el companyStore esté inicializado antes de cargar datos
const checkAndLoad = () => {
if (companyStore.activeCompany) {
loadInitialData();
} else {
// Si no hay compañía, esperar un poco y reintentar
setTimeout(checkAndLoad, 100);
}
};
checkAndLoad();
// También escuchar cambios de compañía para recargar
const handleCompanyChange = () => {
loadInitialData();
};
window.addEventListener('companyChanged', handleCompanyChange);
return () => {
window.removeEventListener('companyChanged', handleCompanyChange);
};
});
async function loadInitialData() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
error = 'No hay compañía seleccionada. Por favor selecciona una compañía en el sidebar.';
loading = false;
return;
}
loading = true;
error = null;
try {
const activeFilters = {
...(filters.key && { key: filters.key }),
...(filters.description_es && { description_es: filters.description_es })
};
const response = await getPackages(companyId, 1, pageSize, activeFilters);
if (response.error) {
console.error('📦 [Packages Page] Error en loadInitialData:', response.error);
if (response.status === 401 || response.status === 403) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
error = response.error;
}
return;
}
if (response.data) {
allItems = response.data.items;
currentPage = response.data.page;
totalItems = response.data.total;
}
} catch (e) {
error = 'Error cargando los datos';
console.error('📦 [Packages Page] Error loading initial data:', e);
} finally {
loading = false;
}
}
async function loadMore() {
if (loading || !hasMore) return;
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
loading = true;
error = null;
try {
const activeFilters = {
...(filters.key && { key: filters.key }),
...(filters.description_es && { description_es: filters.description_es })
};
const response = await getPackages(companyId, currentPage + 1, pageSize, activeFilters);
if (response.error) {
console.error('📦 [Packages Page] Error en loadMore:', response.error);
if (response.status === 401 || response.status === 403) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
error = response.error;
}
return;
}
if (response.data) {
allItems = [...allItems, ...response.data.items];
currentPage = response.data.page;
}
} catch (e) {
error = 'Error cargando más datos';
console.error('📦 [Packages Page] Error loading more:', e);
} finally {
loading = false;
}
}
function handleCreateSuccess() {
createDialogOpen = false;
loadInitialData();
}
function handleItemSuccess() {
loadInitialData();
}
function handleApplyFilters() {
currentPage = 1;
allItems = [];
loadInitialData();
}
function handleClearFilters() {
filters = {
key: '',
description_es: ''
};
currentPage = 1;
allItems = [];
loadInitialData();
}
// Crear columnas con el callback de éxito
const tableColumns = createColumns(handleItemSuccess);
</script>
<svelte:head>
<title>Bultos / Embalajes - Catálogos Generales</title>
</svelte:head>
<div class="flex flex-col gap-4 p-4 md:p-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Bultos / Embalajes</h1>
<p class="text-muted-foreground">
Gestiona los tipos de bultos y embalajes utilizados en tus operaciones
</p>
</div>
<Button onclick={() => createDialogOpen = true}>
<Plus size={16} class="mr-2" />
Nuevo Bulto
</Button>
</div>
<!-- Filters Card -->
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<Card.Title class="flex items-center gap-2">
<Filter size={20} />
Filtros
</Card.Title>
<Button
variant="ghost"
size="sm"
onclick={() => showFilters = !showFilters}
>
{showFilters ? 'Ocultar' : 'Mostrar'}
</Button>
</div>
</Card.Header>
{#if showFilters}
<Card.Content>
<form onsubmit={(e) => { e.preventDefault(); handleApplyFilters(); }} class="space-y-4">
<div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="filter-key">Clave</Label>
<Input
id="filter-key"
bind:value={filters.key}
placeholder="Ej: CAJA"
/>
</div>
<div class="grid gap-2">
<Label for="filter-description">Descripción</Label>
<Input
id="filter-description"
bind:value={filters.description_es}
placeholder="Buscar en descripción..."
/>
</div>
</div>
<div class="flex gap-2">
<Button type="submit">
<Filter size={16} class="mr-2" />
Aplicar Filtros
</Button>
<Button type="button" variant="outline" onclick={handleClearFilters}>
<Trash2 size={16} class="mr-2" />
Limpiar
</Button>
<Button type="button" variant="outline" onclick={loadInitialData}>
<RefreshCw size={16} class="mr-2" />
Refrescar
</Button>
</div>
</form>
</Card.Content>
{/if}
</Card.Root>
<!-- Error Alert -->
{#if error}
<Card.Root class="border-destructive">
<Card.Content class="pt-6">
<div class="flex items-center gap-2 text-destructive">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
<p class="font-medium">{error}</p>
</div>
</Card.Content>
</Card.Root>
{/if}
<!-- Data Table Card -->
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Lista de Bultos</Card.Title>
<Card.Description>
Total: {totalItems} bulto{totalItems !== 1 ? 's' : ''} |
Mostrando: {allItems.length}
</Card.Description>
</div>
</div>
</Card.Header>
<Card.Content>
<DataTable
data={allItems}
columns={tableColumns}
{loading}
{hasMore}
{loadMore}
/>
</Card.Content>
</Card.Root>
</div>
<!-- Create Dialog -->
<CreateEditDialog
bind:open={createDialogOpen}
onSuccess={handleCreateSuccess}
/>

View File

@@ -0,0 +1,192 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Card from '$lib/components/ui/card';
import { Plus, Search } from 'lucide-svelte';
import DataTable from '$lib/components/dashboard/seal/data-table.svelte';
import CreateEditDialog from '$lib/components/dashboard/seal/create-edit-dialog.svelte';
import { createColumns } from '$lib/components/dashboard/seal/columns';
import type { Seal } from '$lib/api/dashboard/a76/seal';
import { getSeals } from '$lib/api/dashboard/a76/seal';
import { companyStore } from '$lib/stores/company.svelte';
let allItems = $state<Seal[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let currentPage = $state(1);
const pageSize = 50;
let dialogOpen = $state(false);
// Filters
let sealFilter = $state('');
let hasMore = $derived(allItems.length >= currentPage * pageSize);
onMount(() => {
// Sync access_token from cookies to localStorage
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'access_token') {
localStorage.setItem('access_token', value);
break;
}
}
// Wait for companyStore to be initialized
const checkAndLoad = () => {
if (companyStore.activeCompany) {
loadInitialData();
} else {
setTimeout(checkAndLoad, 100);
}
};
checkAndLoad();
// Listen for company changes
const handleCompanyChange = () => {
loadInitialData();
};
window.addEventListener('companyChanged', handleCompanyChange);
return () => {
window.removeEventListener('companyChanged', handleCompanyChange);
};
});
async function loadInitialData() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
return;
}
allItems = [];
currentPage = 1;
await loadSeals(1);
}
async function loadSeals(page: number) {
const companyId = companyStore.activeCompany?.id;
if (!companyId || loading) {
return;
}
loading = true;
error = null;
try {
const filters: any = {
page,
page_size: pageSize
};
if (sealFilter) filters.seal = sealFilter;
const response = await getSeals(companyId, filters);
if (page === 1) {
allItems = response.data.items;
} else {
allItems = [...allItems, ...response.data.items];
}
currentPage = page;
} catch (err: any) {
error = err.message || 'Error al cargar los sellos';
} finally {
loading = false;
}
}
function loadMore() {
if (!loading && hasMore) {
loadSeals(currentPage + 1);
}
}
function handleSearch() {
loadInitialData();
}
function handleCreate() {
dialogOpen = true;
}
function handleSuccess() {
loadInitialData();
}
const columns = createColumns(handleSuccess);
</script>
<svelte:head>
<title>Sellos - Anexo 76</title>
</svelte:head>
<div class="space-y-6">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold tracking-tight">Sellos</h1>
<p class="text-muted-foreground">Gestiona los sellos de tu empresa</p>
</div>
<Button onclick={handleCreate}>
<Plus class="mr-2 h-4 w-4" />
Nuevo Sello
</Button>
</div>
{#if error}
<div class="rounded-md bg-destructive/15 p-4">
<p class="text-sm text-destructive">{error}</p>
</div>
{/if}
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Lista de Sellos</Card.Title>
<Card.Description>
Total: {allItems.length} sello{allItems.length !== 1 ? 's' : ''}
</Card.Description>
</div>
</div>
<!-- Filters -->
<div class="flex gap-4 items-end pt-4">
<div class="flex-1">
<Label for="seal-filter">Buscar por Sello</Label>
<Input
id="seal-filter"
bind:value={sealFilter}
placeholder="Filtrar por sello..."
disabled={loading}
/>
</div>
<Button onclick={handleSearch} disabled={loading}>
<Search class="mr-2 h-4 w-4" />
Buscar
</Button>
</div>
</Card.Header>
<Card.Content>
<DataTable
data={allItems}
{columns}
{loading}
{hasMore}
{loadMore}
/>
</Card.Content>
</Card.Root>
</div>
<CreateEditDialog
bind:open={dialogOpen}
onSuccess={handleSuccess}
/>