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
__table_args__ = (
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"},
)

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
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")
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")
@@ -17,7 +18,7 @@ class ExchangeRateCreateDTO(ExchangeRateBaseDTO):
class ExchangeRateUpdateDTO(ExchangeRateBaseDTO):
"""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):

View File

@@ -1,3 +1,4 @@
from datetime import datetime
from decimal import Decimal
from typing import Optional
@@ -28,7 +29,7 @@ class ExchangeRate(Base, TenantScopedMixin, TimestampMixin):
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))
local_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).
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
@@ -33,8 +34,8 @@ class PackageResponseDTO(PackageBaseDTO):
id: int
company_id: int
tenant_id: int
created_at: Optional[str] = None
updated_at: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True

View File

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

View File

@@ -75,8 +75,8 @@ class SealService:
db: Session,
seal_id: int,
tenant_id: int,
company_id: int,
seal_data: dto.SealUpdateDTO,
company_id: int,
) -> Optional[models.Seal]:
"""Update a seal"""
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(
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(
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
*/
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 { 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 { clientsProvidersApi, type ClientProviderBasic } from "$lib/api/dashboard/a76/clients-providers";
import { companyStore } from "$lib/stores/company.svelte";
import { onMount } from 'svelte';
import { LoaderCircle, Home } from 'lucide-svelte';
let {
open = $bindable(false),
@@ -27,6 +27,7 @@
// Estado del formulario
let formData = $state({
client_id: item?.client_id || null,
class_code: item?.class_code || '',
description_es: item?.description_es || '',
description_en: item?.description_en || '',
@@ -43,17 +44,23 @@
let error = $state<string | null>(null);
let materialTypes = $state<MaterialType[]>([]);
let loadingMaterialTypes = $state(false);
let clients = $state<ClientProviderBasic[]>([]);
let loadingClients = $state(false);
// Variables para controlar los selects
let selectedUnitValue = $state<string>('KG');
let selectedMaterialValue = $state<string>('');
let selectedPhysicalReviewValue = $state<number>(0);
// Cargar tipos de materiales al montar
// Cargar tipos de materiales y clientes al montar
onMount(async () => {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
// Cargar tipos de materiales
loadingMaterialTypes = true;
try {
const response = await materialTypesApi.list(1, 100); // Cargar los primeros 100
const response = await materialTypesApi.list(1, 100);
if (response.data) {
materialTypes = response.data.items;
}
@@ -62,12 +69,26 @@
} finally {
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
$effect(() => {
if (item) {
formData = {
client_id: item.client_id,
class_code: item.class_code,
description_es: item.description_es || '',
description_en: item.description_en || '',
@@ -86,6 +107,7 @@
} else {
// Reset para modo crear
formData = {
client_id: null,
class_code: '',
description_es: '',
description_en: '',
@@ -122,6 +144,10 @@
}
// Validaciones básicas
if (!formData.client_id) {
error = 'Debes seleccionar un cliente';
return;
}
if (!formData.class_code.trim()) {
error = 'El código de clase es requerido';
return;
@@ -152,6 +178,7 @@
if (isEdit && item) {
// Actualizar
const updateData: A76ClassUpdate = {
client_id: formData.client_id!,
class_code: formData.class_code,
description_es: formData.description_es || null,
description_en: formData.description_en || null,
@@ -165,10 +192,10 @@
};
response = await classesApi.update(item.id, updateData, companyId);
} else {
// Crear - usa el company_id como client_id
// Crear con el client_id seleccionado
const createData: A76ClassCreate = {
company_id: companyId,
client_id: companyId, // Usa el mismo company_id como client_id
client_id: formData.client_id!,
class_code: formData.class_code,
description_es: formData.description_es || null,
description_en: formData.description_en || null,
@@ -249,7 +276,21 @@
{#if companyStore.activeCompany}
<div class="rounded-md bg-blue-50 border border-blue-200 p-3">
<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>
<p class="text-sm font-medium text-blue-900">
{companyStore.activeCompany.name}
@@ -262,6 +303,34 @@
</div>
{/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 -->
<div class="space-y-2">
<Label for="class_code" class="required">Código de Clase</Label>
@@ -413,7 +482,26 @@
</Button>
<Button type="submit" disabled={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...
{:else}
{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"](),
url: "#",
url: "/dashboard/general_catalogs/packages",
},
{
title: m["sidebar.general_catalogs.concepts"](),
@@ -187,7 +187,7 @@ export function getSidebarData(): SidebarData {
},
{
title: m["sidebar.general_catalogs.seals"](),
url: "#",
url: "/dashboard/general_catalogs/seal",
},
{
title: m["sidebar.general_catalogs.valuation_methods"](),
@@ -195,7 +195,7 @@ export function getSidebarData(): SidebarData {
},
{
title: m["sidebar.general_catalogs.countries"](),
url: "#",
url: "/dashboard/reference_data/countries",
},
{
title: m["sidebar.general_catalogs.ports"](),
@@ -231,7 +231,7 @@ export function getSidebarData(): SidebarData {
},
{
title: m["sidebar.general_catalogs.exchange_rates"](),
url: "#",
url: "/dashboard/general_catalogs/exchange-rate",
},
{
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}
/>