Merge pull request 'feat: Implement CRUD and UI for transportation entities (vehicles, trailers, and transporters).' (#165) from feature/modulos-transporte into development

Reviewed-on: ADUANASOFT/anexo76#165
This commit is contained in:
2026-02-26 17:55:51 +00:00
31 changed files with 2470 additions and 154 deletions

6
.gitignore vendored
View File

@@ -49,13 +49,15 @@ logs/
*.sqlite3
# Testing
backend/app_data/
.mypy_cache/
.pytest_cache/
.coverage
htmlcov/
backend/app_data/
# Node (para frontend)
node_modules/
**/node_modules/
**/.svelte-kit/
.npm
.yarn

View File

@@ -3,7 +3,7 @@ import logging
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session
@@ -137,6 +137,7 @@ class TenantCRUDRoutes(
description=f"Get paginated list of {self.resource_name}s with optional filters",
)
async def list_resources(
request: Request,
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(
@@ -145,13 +146,6 @@ class TenantCRUDRoutes(
le=self.max_page_size,
description="Page size",
),
status: Optional[str] = Query(None, description="Filter by status"),
operation_type: Optional[str] = Query(
None, description="Filter by operation type"
),
invoice_type: Optional[str] = Query(
None, description="Filter by invoice type"
),
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
):
@@ -164,13 +158,15 @@ class TenantCRUDRoutes(
)
skip = (page - 1) * page_size
filters = {}
if status:
filters["status"] = status
if operation_type:
filters["operation_type"] = operation_type
if invoice_type:
filters["invoice_type"] = invoice_type
# Extraer todos los parámetros de búsqueda dinámicamente
# Excluimos los parámetros estándar de paginación y control
standard_params = {"company_id", "page", "page_size"}
filters = {
k: v
for k, v in request.query_params.items()
if k not in standard_params and v is not None and v != ""
}
items, total = self.service.get_all(
db, tenant_id, company_id, skip, page_size, filters

View File

@@ -25,6 +25,10 @@ class TrailerService:
# Apply filters if provided
if filters:
if filters.get("trailer_number"):
query = query.filter(
models.Trailer.trailer_number.ilike(f"%{filters['trailer_number']}%")
)
if filters.get("plate_number"):
query = query.filter(
models.Trailer.plate_number.ilike(f"%{filters['plate_number']}%")
@@ -75,8 +79,8 @@ class TrailerService:
db: Session,
trailer_number: str,
tenant_id: int,
company_id: int,
trailer_data: dto.TrailerUpdateDTO,
company_id: int,
) -> Optional[models.Trailer]:
"""Update a trailer"""
trailer = TrailerService.get_by_id(db, trailer_number, tenant_id, company_id)

View File

@@ -9,7 +9,7 @@ class Transporter(Base, TenantScopedMixin, TimestampMixin):
{"schema": "a76"},
)
transporter_key = Column(String(5), primary_key=True, nullable=False)
transporter_key = Column(String(23), primary_key=True, nullable=False)
name = Column(String(256), nullable=True)
short_name = Column(String(10), nullable=True)
responsible = Column(String(100), nullable=True)
@@ -27,4 +27,4 @@ class Transporter(Base, TenantScopedMixin, TimestampMixin):
ftp_user = Column(String(200), nullable=True)
ftp_password = Column(String(100), nullable=True)
ftp_directory = Column(String(1000), nullable=True)
filler_code = Column(String(4), nullable=True)
filler_code = Column(String(20), nullable=True)

View File

@@ -25,6 +25,10 @@ class TransporterService:
# Apply filters if provided
if filters:
if filters.get("transporter_key"):
query = query.filter(
models.Transporter.transporter_key.ilike(f"%{filters['transporter_key']}%")
)
if filters.get("name"):
query = query.filter(
models.Transporter.name.ilike(f"%{filters['name']}%")
@@ -75,8 +79,8 @@ class TransporterService:
db: Session,
transporter_key: str,
tenant_id: int,
company_id: int,
transporter_data: dto.TransporterUpdateDTO,
company_id: int,
) -> Optional[models.Transporter]:
"""Update a transporter"""
transporter = TransporterService.get_by_id(

View File

@@ -25,6 +25,10 @@ class VehicleService:
# Apply filters if provided
if filters:
if filters.get("vehicle_key"):
query = query.filter(
models.Vehicle.vehicle_key.ilike(f"%{filters['vehicle_key']}%")
)
if filters.get("plate_number"):
query = query.filter(
models.Vehicle.plate_number.ilike(f"%{filters['plate_number']}%")
@@ -75,8 +79,8 @@ class VehicleService:
db: Session,
vehicle_key: str,
tenant_id: int,
company_id: int,
vehicle_data: dto.VehicleUpdateDTO,
company_id: int,
) -> Optional[models.Vehicle]:
"""Update a vehicle"""
vehicle = VehicleService.get_by_id(db, vehicle_key, tenant_id, company_id)

View File

@@ -58,6 +58,11 @@ from api.v1.modules.a76.clients_and_providers.models import ClientProvider
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.a76.general_catalogs.company.models import Company
# Transportation Modules
from api.v1.modules.a76.transportation.trailers.models import Trailer
from api.v1.modules.a76.transportation.transporters.models import Transporter
from api.v1.modules.a76.transportation.vehicles.models import Vehicle
# Core Modules & Transactional Models
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.items.series.models import Serie
@@ -145,6 +150,137 @@ def run_migrations():
subprocess.run(["alembic", "upgrade", "head"], check=True)
def create_transportation_tables():
"""Crea las tablas de transporte directamente si no existen.
Se usa en lugar de una migración Alembic para evitar gestionar versiones.
"""
from sqlalchemy import text
from core.database import core_engine
ddl_statements = [
"""
CREATE TABLE IF NOT EXISTS a76.transporter (
transporter_key VARCHAR(23) PRIMARY KEY,
name VARCHAR(256),
short_name VARCHAR(10),
responsible VARCHAR(100),
rfc VARCHAR(30),
streets VARCHAR(100),
postal_code VARCHAR(15),
city VARCHAR(30),
state VARCHAR(30),
country VARCHAR(3),
loader_code VARCHAR(9),
caat_code VARCHAR(49),
transport_code VARCHAR(8),
transport_interface_type VARCHAR(20),
ftp_server VARCHAR(200),
ftp_user VARCHAR(200),
ftp_password VARCHAR(100),
ftp_directory VARCHAR(1000),
filler_code VARCHAR(20),
tenant_id INTEGER NOT NULL REFERENCES core.tenants(id),
company_id INTEGER NOT NULL REFERENCES a76.company(id),
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
deleted_at TIMESTAMP
);
""",
"""
CREATE TABLE IF NOT EXISTS a76.trailer (
trailer_number VARCHAR(20) PRIMARY KEY,
ace_trailer_number VARCHAR(10),
trailer_type_key VARCHAR(2),
seal VARCHAR(15),
entity_code VARCHAR(1),
plate_number VARCHAR(17),
state VARCHAR(30),
country VARCHAR(3),
container_key VARCHAR(3),
tenant_id INTEGER NOT NULL REFERENCES core.tenants(id),
company_id INTEGER NOT NULL REFERENCES a76.company(id),
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
deleted_at TIMESTAMP
);
""",
"""
CREATE TABLE IF NOT EXISTS a76.vehicle (
vehicle_key VARCHAR(14) PRIMARY KEY,
ace_vehicle_key VARCHAR(10),
transporter_key VARCHAR(23),
transport_identifier VARCHAR(30),
transport_type VARCHAR(2),
entity_code VARCHAR(1),
transponder_number VARCHAR(16),
dot_number VARCHAR(8),
plate_number VARCHAR(17),
city VARCHAR(30),
state VARCHAR(30),
country VARCHAR(3),
seal VARCHAR(49),
insurance_company_name VARCHAR(30),
insurance_number VARCHAR(20),
insurance_amount NUMERIC(13, 2),
insurance_date INTEGER,
box_number VARCHAR(300),
brand VARCHAR(20),
year VARCHAR(4),
series VARCHAR(30),
description VARCHAR(100),
engine_number VARCHAR(50),
sct_permission VARCHAR(40),
color VARCHAR(20),
container_key VARCHAR(3),
tenant_id INTEGER NOT NULL REFERENCES core.tenants(id),
company_id INTEGER NOT NULL REFERENCES a76.company(id),
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
deleted_at TIMESTAMP
);
""",
]
with core_engine.connect() as conn:
for stmt in ddl_statements:
conn.execute(text(stmt))
# Ampliar columnas que pudieron haberse creado con tamaño incorrecto
conn.execute(text("""
DO $$
BEGIN
-- Fix transporter_key si fue creada como VARCHAR(5)
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema='a76' AND table_name='transporter'
AND column_name='transporter_key'
AND character_maximum_length < 23
) THEN
ALTER TABLE a76.transporter ALTER COLUMN transporter_key TYPE VARCHAR(23);
END IF;
-- Fix filler_code si fue creada como VARCHAR(4)
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema='a76' AND table_name='transporter'
AND column_name='filler_code'
AND character_maximum_length < 20
) THEN
ALTER TABLE a76.transporter ALTER COLUMN filler_code TYPE VARCHAR(20);
END IF;
-- Eliminar constraint de trailer_type_key en trailer si existe
IF EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name='trailer_trailer_type_key_fkey'
AND table_schema='a76' AND table_name='trailer'
) THEN
ALTER TABLE a76.trailer DROP CONSTRAINT trailer_trailer_type_key_fkey;
END IF;
END$$;
"""))
conn.commit()
logger.info("Tablas de transporte verificadas/creadas correctamente.")
# Inicializar la base de datos
@app.on_event("startup")
async def on_startup():
@@ -152,6 +288,7 @@ async def on_startup():
logger.info("Iniciando la aplicación Anexo76...")
init_db()
run_migrations()
create_transportation_tables()
logger.info("Base de datos inicializada correctamente.")
@@ -265,6 +402,10 @@ def register_audit():
CustomsBroker,
Part,
Company,
# Transportation Modules
Trailer,
Transporter,
Vehicle,
# Reference Data
Country,
CurrencyType,

View File

@@ -13,4 +13,4 @@ declare global {
}
}
export {};
export { };

View File

@@ -18,7 +18,7 @@ export interface LineCustoms {
destination_country?: string;
advalorem?: string;
advalorem_numeric?: number;
advalorem_american?: number;
advalorem_american?: number;
advalorem_tlcan?: number;
rate?: string;
depreciation_rate?: number;
@@ -33,7 +33,7 @@ export interface LineFinancials {
unit_cost_mxn?: number;
unit_cost_capture?: number;
unit_cost_commercial_usd?: number;
// Values
value_mc?: number;
value_usd?: number;
@@ -49,7 +49,7 @@ export interface LineQuantities {
line_item_id?: number;
quantity?: number;
unit_of_measure?: string;
// Special quantities
quantity_temp_export?: number;
quantity_returned?: number;
@@ -92,7 +92,7 @@ export interface FaLineItem {
id?: number;
tenant_id?: number;
company_id?: number;
// Asset information (SCAF specific)
asset_number?: string;
asset_photo?: string;
@@ -101,44 +101,46 @@ export interface FaLineItem {
return_import_invoice?: string;
return_import_date?: number;
movement_type_import?: string;
// Cross-references for import repair
search_invoice?: string;
search_line?: number;
// Search type
search_type?: string;
// Subitems
is_subitem?: boolean;
contains_subitems?: boolean;
subitem_number?: number;
// Subitems
is_subitem?: boolean;
contains_subitems?: boolean;
subitem_number?: number;
// Special flags
download?: boolean;
own_equipment?: boolean;
omit_annex31?: boolean;
omit_annex31?: boolean;
// Timestamps
created_at?: string;
updated_at?: string;
}
export interface Item {
id?: number;
invoice_id: number;
id?: number;
invoice_id: number;
line_number: number;
// Identification
part_number?: string;
part_number_id?: number;
component_part_number?: string;
component_part_number_id?: number;
class_id?: number;
identifier?: string;
// Unit of Measure
unit_of_measure?: number;
alternate_unit?: number;
// Permits
permit_number?: string;
page_line?: string;
@@ -151,29 +153,29 @@ export interface Item {
includes_subitems?: boolean;
tax_payment?: boolean;
is_military_mcia?: boolean;
// Payment
payment_method?: string;
igi_amount?: number;
// Additional notes
wildcard_field?: string;
// Computed fields from class_info relation
class_code?: string;
class_description?: string;
// Computed field from unit_of_measure_info relation
unit_of_measure_code?: string;
reference_number?: string;
order?: string;
guide_number?: string;
depreciation_date?: number;
rectification?: number;
warehouse?: string;
location?: string;
created_at?: string;
updated_at?: string;
unit_of_measure_code?: string;
reference_number?: string;
order?: string;
guide_number?: string;
depreciation_date?: number;
rectification?: number;
warehouse?: string;
location?: string;
created_at?: string;
updated_at?: string;
// Nested relations (Singular names to match backend Pydantic models)
customs?: LineCustoms;
@@ -185,86 +187,86 @@ export interface Item {
}
export interface ItemListResponse {
items: Item[];
total: number;
skip: number;
limit: number;
items: Item[];
total: number;
skip: number;
limit: number;
}
export interface CreateItemData extends Omit<Item, 'id' | 'created_at' | 'updated_at'> {
invoice_id: number;
invoice_id: number;
}
export interface UpdateItemData extends Partial<Omit<Item, 'id' | 'invoice_id' | 'created_at' | 'updated_at'>> {}
export interface UpdateItemData extends Partial<Omit<Item, 'id' | 'invoice_id' | 'created_at' | 'updated_at'>> { }
/**
* API para Items
*/
export const itemsApi = {
/**
* Lista todos los items con paginación
*/
list: (companyId: number, skip = 0, limit = 100, invoiceId?: number) => {
const params = new URLSearchParams({
company_id: companyId.toString(),
skip: skip.toString(),
limit: limit.toString()
});
/**
* Lista todos los items con paginación
*/
list: (companyId: number, skip = 0, limit = 100, invoiceId?: number) => {
const params = new URLSearchParams({
company_id: companyId.toString(),
skip: skip.toString(),
limit: limit.toString()
});
if (invoiceId) {
params.append('invoice_id', invoiceId.toString());
}
if (invoiceId) {
params.append('invoice_id', invoiceId.toString());
}
return api.get<ItemListResponse>(`/v1/a76/items/?${params.toString()}`);
},
return api.get<ItemListResponse>(`/v1/a76/items/?${params.toString()}`);
},
/**
* Lista items por invoice ID
*/
listByInvoice: (invoiceId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.get<ItemListResponse>(`/v1/a76/items/invoice/${invoiceId}/items/?${params.toString()}`);
},
/**
* Lista items por invoice ID
*/
listByInvoice: (invoiceId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.get<ItemListResponse>(`/v1/a76/items/invoice/${invoiceId}/items/?${params.toString()}`);
},
/**
* Obtiene un item por ID
*/
get: (itemId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.get<Item>(`/v1/a76/items/${itemId}/?${params.toString()}`);
},
/**
* Obtiene un item por ID
*/
get: (itemId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.get<Item>(`/v1/a76/items/${itemId}/?${params.toString()}`);
},
/**
* Crea un nuevo item
*/
create: (companyId: number, data: CreateItemData) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.post<Item>(`/v1/a76/items/?${params.toString()}`, data);
},
/**
* Crea un nuevo item
*/
create: (companyId: number, data: CreateItemData) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.post<Item>(`/v1/a76/items/?${params.toString()}`, data);
},
/**
* Actualiza un item existente
*/
update: (itemId: number, companyId: number, data: UpdateItemData) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.put<Item>(`/v1/a76/items/${itemId}/?${params.toString()}`, data);
},
/**
* Actualiza un item existente
*/
update: (itemId: number, companyId: number, data: UpdateItemData) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.put<Item>(`/v1/a76/items/${itemId}/?${params.toString()}`, data);
},
/**
* Elimina un item
*/
delete: (itemId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.delete(`/v1/a76/items/${itemId}/?${params.toString()}`);
}
/**
* Elimina un item
*/
delete: (itemId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.delete(`/v1/a76/items/${itemId}/?${params.toString()}`);
}
};

View File

@@ -33,12 +33,19 @@ export interface PedimentoPayments {
}
export interface PedimentoTransportMeans {
id?: number;
destination?: number | null;
entry_exit?: string | null;
arrival?: string | null;
departure?: string | null;
}
export interface PedimentoCustomsOffices {
id?: number;
dispatch_customs?: string | null;
entry_exit_customs?: string | null;
}
export interface PedimentoValidation {
validator?: string | null;
validation_ack?: string | null;
@@ -180,6 +187,7 @@ export interface Pedimento {
pedimento_incrementables?: PedimentoIncrementables | null;
pedimento_decrementables?: PedimentoDecrementables | null;
pedimento_indexes?: PedimentoIndexes | null;
pedimento_customs_offices?: PedimentoCustomsOffices | null;
pedimento_config_additional?: PedimentoConfigAdditional | null;
pedimento_config_calculations?: PedimentoConfigCalculations | null;
pedimento_config_surcharges?: PedimentoConfigSurcharges | null;
@@ -220,6 +228,7 @@ export interface CreatePedimentoData {
pedimento_incrementables?: PedimentoIncrementables | null;
pedimento_decrementables?: PedimentoDecrementables | null;
pedimento_indexes?: PedimentoIndexes | null;
pedimento_customs_offices?: PedimentoCustomsOffices | null;
pedimento_config_additional?: PedimentoConfigAdditional | null;
pedimento_config_calculations?: PedimentoConfigCalculations | null;
pedimento_config_surcharges?: PedimentoConfigSurcharges | null;
@@ -253,6 +262,7 @@ export interface UpdatePedimentoData {
pedimento_incrementables?: PedimentoIncrementables | null;
pedimento_decrementables?: PedimentoDecrementables | null;
pedimento_indexes?: PedimentoIndexes | null;
pedimento_customs_offices?: PedimentoCustomsOffices | null;
pedimento_config_additional?: PedimentoConfigAdditional | null;
pedimento_config_calculations?: PedimentoConfigCalculations | null;
pedimento_config_surcharges?: PedimentoConfigSurcharges | null;

View File

@@ -2,10 +2,17 @@ import { api, type ApiResponse } from '$lib/api';
export interface Trailer {
trailer_number: string;
plate_number?: string;
ace_trailer_number?: string;
trailer_type_key?: string;
is_active: boolean;
tenant_id?: string;
seal?: string;
entity_code?: string;
plate_number?: string;
state?: string;
country?: string;
container_key?: string;
is_active?: boolean;
company_id?: number | string;
tenant_id?: number | string;
}
export interface TrailerResponse {
@@ -16,7 +23,7 @@ export interface TrailerResponse {
}
class TrailersApi {
private baseUrl = '/v1/a76/trailers';
private baseUrl = '/v1/a76/transportation/trailers';
async list(
companyId: string | number,
@@ -35,6 +42,27 @@ class TrailersApi {
});
return api.get<Trailer>(`${this.baseUrl}/${id}?${queryParams.toString()}`);
}
async create(data: Trailer, companyId: string | number): Promise<ApiResponse<Trailer>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.post<Trailer>(`${this.baseUrl}?${queryParams.toString()}`, data);
}
async update(id: string, data: Trailer, companyId: string | number): Promise<ApiResponse<Trailer>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.put<Trailer>(`${this.baseUrl}/${id}/?${queryParams.toString()}`, data);
}
async delete(id: string, companyId: string | number): Promise<ApiResponse<void>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.delete<void>(`${this.baseUrl}/${id}?${queryParams.toString()}`);
}
}
export const trailersApi = new TrailersApi();

View File

@@ -2,10 +2,26 @@ import { api, type ApiResponse } from '$lib/api';
export interface Transporter {
transporter_key: string;
name: string;
name?: string;
short_name?: string;
responsible?: string;
rfc?: string;
is_active: boolean;
tenant_id?: string;
streets?: string;
postal_code?: string;
city?: string;
state?: string;
country?: string;
loader_code?: string;
caat_code?: string;
transport_code?: string;
transport_interface_type?: string;
ftp_server?: string;
ftp_user?: string;
ftp_password?: string;
ftp_directory?: string;
filler_code?: string;
company_id?: number | string;
tenant_id?: number | string;
}
export interface TransporterResponse {
@@ -35,6 +51,27 @@ class TransportersApi {
});
return api.get<Transporter>(`${this.baseUrl}/${id}?${queryParams.toString()}`);
}
async create(data: Transporter, companyId: string | number): Promise<ApiResponse<Transporter>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.post<Transporter>(`${this.baseUrl}?${queryParams.toString()}`, data);
}
async update(id: string, data: Transporter, companyId: string | number): Promise<ApiResponse<Transporter>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.put<Transporter>(`${this.baseUrl}/${id}/?${queryParams.toString()}`, data);
}
async delete(id: string, companyId: string | number): Promise<ApiResponse<void>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.delete<void>(`${this.baseUrl}/${id}?${queryParams.toString()}`);
}
}
export const transportersApi = new TransportersApi();

View File

@@ -1,32 +1,84 @@
import { api } from '$lib/api';
import { api, type ApiResponse } from '$lib/api';
export interface Vehicle {
vehicle_key: string;
brand?: string;
plate_number?: string;
description?: string;
ace_vehicle_key?: string;
transporter_key?: string;
transport_identifier?: string;
transport_type?: string;
entity_code?: string;
transponder_number?: string;
dot_number?: string;
plate_number?: string;
city?: string;
state?: string;
country?: string;
seal?: string;
insurance_company_name?: string;
insurance_number?: string;
insurance_amount?: number;
insurance_date?: number;
box_number?: string;
brand?: string;
year?: string;
series?: string;
description?: string;
engine_number?: string;
sct_permission?: string;
color?: string;
container_key?: string;
company_id?: number | string;
tenant_id?: number | string;
}
export interface VehicleListResponse {
export interface VehicleResponse {
items: Vehicle[];
total: number;
page: number;
page_size: number;
}
/**
* API para Vehículos
*/
export const vehiclesApi = {
list: (companyId: string, page = 1, pageSize = 50) => {
return api.get<VehicleListResponse>(
`/v1/a76/transportation/vehicles?company_id=${companyId}&page=${page}&page_size=${pageSize}`
);
},
class VehiclesApi {
private baseUrl = '/v1/a76/transportation/vehicles';
get: (vehicleKey: string, companyId: string) => {
return api.get<Vehicle>(
`/v1/a76/transportation/vehicles/${vehicleKey}?company_id=${companyId}`
);
async list(
companyId: string | number,
params?: Record<string, any>
): Promise<ApiResponse<VehicleResponse>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString(),
...params
});
return api.get<VehicleResponse>(`${this.baseUrl}?${queryParams.toString()}`);
}
};
async get(id: string, companyId: string | number): Promise<ApiResponse<Vehicle>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.get<Vehicle>(`${this.baseUrl}/${id}?${queryParams.toString()}`);
}
async create(data: Vehicle, companyId: string | number): Promise<ApiResponse<Vehicle>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.post<Vehicle>(`${this.baseUrl}?${queryParams.toString()}`, data);
}
async update(id: string, data: Vehicle, companyId: string | number): Promise<ApiResponse<Vehicle>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.put<Vehicle>(`${this.baseUrl}/${id}/?${queryParams.toString()}`, data);
}
async delete(id: string, companyId: string | number): Promise<ApiResponse<void>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.delete<void>(`${this.baseUrl}/${id}?${queryParams.toString()}`);
}
}
export const vehiclesApi = new VehiclesApi();

View File

@@ -20,7 +20,7 @@
} from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
import { itemsApi, type Item } from '$lib/api/dashboard/a76/items';
import { itemsApi, type Item as InvoiceItem } from '$lib/api/dashboard/a76/items';
import { companyStore } from '$lib/stores/company.svelte';
import ItemSheetFa from './fa/item-sheet-fa.svelte';
import ItemSheetInv from './inv/item-sheet-inv.svelte';
@@ -43,7 +43,7 @@
} = $props();
// 1. Core State
let items = $state<Item[]>([]);
let items = $state<InvoiceItem[]>([]);
let displayedItems = $state<any[]>([]);
let imported = $state(0);
let net_weight = $state(0);
@@ -129,9 +129,9 @@
let showItemSheet = $state(false);
let isEditMode = $state(false);
let showDeleteDialog = $state(false);
let selectedItem = $state<Item | null>(null);
let originalItemData = $state<Partial<Item> | null>(null);
let editingItem = $state<Partial<Item>>({
let selectedItem = $state<InvoiceItem | null>(null);
let originalItemData = $state<Partial<InvoiceItem> | null>(null);
let editingItem = $state<Partial<InvoiceItem>>({
invoice_id: undefined,
reference_number: '',
order: '',
@@ -400,7 +400,7 @@
return cleanLineData({ ...rest });
}
function cloneItemForPreset(item: Item) {
function cloneItemForPreset(item: InvoiceItem) {
const { id, tenant_id, company_id, created_at, updated_at, temp_id, ...rest } = item as any;
return {
...sanitizeLineForPreset(rest),
@@ -502,7 +502,7 @@
isSavingPreset = true;
try {
// We group everything as items for the template
const lines = builderItems.map((item: Item, idx: number) => {
const lines = builderItems.map((item: InvoiceItem, idx: number) => {
return {
...cleanLineData(item),
line_number: item.line_number || idx + 1, // Ensure line_number is present
@@ -552,7 +552,7 @@
}
// Enrich item with descriptive data for display
async function enrichItemData(item: Partial<Item>) {
async function enrichItemData(item: Partial<InvoiceItem>) {
if (!item || !activeCompanyId) return;
// Load class data
@@ -697,7 +697,7 @@
}
// Normalize numeric values from strings to numbers
function normalizeItemData(item: Partial<Item>): Partial<Item> {
function normalizeItemData(item: Partial<InvoiceItem>): Partial<InvoiceItem> {
if (item) {
const normalizedItem = { ...item };

View File

@@ -0,0 +1,64 @@
/**
* Definición de columnas para la tabla de Trailers
*/
import type { Trailer } from '$lib/api/dashboard/a76/trailers';
import type { ColumnDef } from '@tanstack/table-core';
import { renderComponent } from '$lib/components/ui/data-table';
import DataTableActions from './data-table-actions.svelte';
export function createColumns(onSuccess?: () => void): ColumnDef<Trailer>[] {
return [
{
accessorKey: 'trailer_number',
header: 'Número de Trailer',
cell: ({ row }) => {
return row.original.trailer_number;
}
},
{
accessorKey: 'plate_number',
header: 'Placas',
cell: ({ row }) => {
return row.original.plate_number || '-';
}
},
{
accessorKey: 'trailer_type_key',
header: 'Tipo de Trailer',
cell: ({ row }) => {
return row.original.trailer_type_key || '-';
}
},
{
accessorKey: 'container_key',
header: 'Contenedor',
cell: ({ row }) => {
return row.original.container_key || '-';
}
},
{
accessorKey: 'state',
header: 'Estado',
cell: ({ row }) => {
return row.original.state || '-';
}
},
{
accessorKey: 'country',
header: 'País',
cell: ({ row }) => {
return row.original.country || '-';
}
},
{
id: 'actions',
header: 'Acciones',
cell: ({ row }) => {
return renderComponent(DataTableActions, {
item: row.original,
onSuccess
});
}
}
];
}

View File

@@ -0,0 +1,207 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { trailersApi, type Trailer } from '$lib/api/dashboard/a76/trailers';
import { companyStore } from '$lib/stores/company.svelte';
let {
open = $bindable(false),
item = null,
onSuccess
}: {
open: boolean;
item?: Trailer | null;
onSuccess?: () => void;
} = $props();
const isEdit = $derived(!!item);
const title = $derived(isEdit ? 'Editar Trailer' : 'Nuevo Trailer');
let formData = $state<Trailer>({
trailer_number: '',
ace_trailer_number: '',
trailer_type_key: '',
seal: '',
entity_code: '',
plate_number: '',
state: '',
country: '',
container_key: ''
});
let loading = $state(false);
let error = $state<string | null>(null);
$effect(() => {
if (item) {
formData = { ...item };
} else {
formData = {
trailer_number: '',
ace_trailer_number: '',
trailer_type_key: '',
seal: '',
entity_code: '',
plate_number: '',
state: '',
country: '',
container_key: ''
};
}
});
async function handleSubmit() {
error = null;
loading = true;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
throw new Error('No hay una compañía seleccionada');
}
if (!formData.trailer_number.trim()) {
throw new Error('El número de trailer es requerido');
}
let response;
if (isEdit && item) {
response = await trailersApi.update(item.trailer_number, formData, companyId);
} else {
response = await trailersApi.create(formData, companyId);
}
if (response.error) {
throw new Error(response.error);
}
open = false;
if (onSuccess) {
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Error al guardar el trailer';
} finally {
loading = false;
}
}
function handleCancel() {
open = false;
error = null;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>
{isEdit
? 'Modifica los datos del trailer'
: 'Completa los datos para crear un nuevo trailer'}
</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-6"
>
{#if error}
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="trailer_number"
>Número de Trailer <span class="text-destructive">*</span></Label
>
<Input
id="trailer_number"
bind:value={formData.trailer_number}
disabled={isEdit}
required
maxlength={20}
/>
</div>
<div class="grid gap-2">
<Label for="plate_number">Placas</Label>
<Input id="plate_number" bind:value={formData.plate_number} maxlength={17} />
</div>
<div class="grid gap-2">
<Label for="ace_trailer_number">Número Trailer ACE</Label>
<Input id="ace_trailer_number" bind:value={formData.ace_trailer_number} maxlength={10} />
</div>
<div class="grid gap-2">
<Label for="trailer_type_key">Tipo de Trailer (Clave)</Label>
<Input
id="trailer_type_key"
bind:value={formData.trailer_type_key}
maxlength={2}
placeholder="2 car."
/>
</div>
<div class="grid gap-2">
<Label for="container_key">Contenedor (Clave)</Label>
<Input
id="container_key"
bind:value={formData.container_key}
maxlength={3}
placeholder="3 car."
/>
</div>
<div class="grid gap-2">
<Label for="seal">Sello</Label>
<Input id="seal" bind:value={formData.seal} maxlength={15} />
</div>
<div class="grid gap-2">
<Label for="entity_code">Código Entidad</Label>
<Input
id="entity_code"
bind:value={formData.entity_code}
maxlength={1}
placeholder="1 car."
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="state">Estado</Label>
<Input id="state" bind:value={formData.state} maxlength={30} placeholder="Ej: TX" />
</div>
<div class="grid gap-2">
<Label for="country">País</Label>
<Input
id="country"
bind:value={formData.country}
maxlength={3}
placeholder="MEX / USA"
/>
</div>
</div>
</div>
<Dialog.Footer>
<Button type="button" variant="outline" onclick={handleCancel} disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

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 { trailersApi, type Trailer } from '$lib/api/dashboard/a76/trailers';
import { companyStore } from '$lib/stores/company.svelte';
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
import CreateEditDialog from './create-edit-dialog.svelte';
let {
item,
onSuccess
}: {
item: Trailer;
onSuccess?: () => void;
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let dialogOpen = $state(false);
let selectedItem = $state<Trailer | null>(null);
async function handleDelete() {
if (
!confirm(
`¿Estás seguro de eliminar el trailer "${item.trailer_number}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`
)
) {
return;
}
if (!companyStore.activeCompany) {
alert('❌ Error: No hay una compañía seleccionada');
return;
}
loading = true;
error = null;
try {
const response = await trailersApi.delete(item.trailer_number, companyStore.activeCompany.id);
if (response.error) {
if (response.status === 401) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
error = response.error;
alert(`❌ Error al eliminar:\n\n${response.error}`);
}
return;
}
// Éxito
alert(`✅ Trailer "${item.trailer_number}" eliminado correctamente`);
if (onSuccess) {
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Error al eliminar';
alert(`❌ Error: ${error}`);
console.error('Error deleting:', e);
} finally {
loading = false;
}
}
function handleEdit() {
selectedItem = item;
dialogOpen = true;
}
function handleDialogSuccess() {
dialogOpen = false;
selectedItem = null;
if (onSuccess) {
onSuccess();
}
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="relative size-8 p-0">
<span class="sr-only">Abrir menú</span>
<EllipsisVertical size={16} />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-[160px]">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleEdit}>
<Pencil size={16} class="mr-2" />
Editar
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleDelete} class="text-destructive" disabled={loading}>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Trash2 size={16} class="mr-2" />
{/if}
Eliminar
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<CreateEditDialog bind:open={dialogOpen} item={selectedItem} onSuccess={handleDialogSuccess} />

View File

@@ -0,0 +1,99 @@
<script lang="ts" generics="TData, TValue">
import { type ColumnDef, getCoreRowModel } from '@tanstack/table-core';
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Button } from '$lib/components/ui/button';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
pageCount: number;
totalItems: number;
};
let { data, columns, pageCount, totalItems }: DataTableProps<TData, TValue> = $props();
const table = createSvelteTable({
get data() {
return data;
},
get columns() {
return columns;
},
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
get pageCount() {
return pageCount;
}
});
function handlePageChange(newPage: number) {
const url = new URL($page.url);
url.searchParams.set('page', newPage.toString());
goto(url);
}
</script>
<div class="rounded-md border bg-card">
<Table.Root>
<Table.Header>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head>
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
</Table.Head>
{/each}
</Table.Row>
{/each}
</Table.Header>
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
<Table.Row data-state={row.getIsSelected() && 'selected'}>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell>
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</Table.Cell>
{/each}
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={columns.length} class="h-24 text-center">
No hay resultados.
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
<div class="flex items-center justify-end space-x-2 py-4">
<div class="flex-1 text-sm text-muted-foreground">
Total: {totalItems}
</div>
<div class="space-x-2">
<Button
variant="outline"
size="sm"
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) - 1)}
disabled={Number($page.url.searchParams.get('page') || 1) <= 1}
>
Anterior
</Button>
<Button
variant="outline"
size="sm"
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) + 1)}
disabled={Number($page.url.searchParams.get('page') || 1) >= pageCount}
>
Siguiente
</Button>
</div>
</div>

View File

@@ -0,0 +1,306 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { transportersApi, type Transporter } from '$lib/api/dashboard/a76/transporters';
import { companyStore } from '$lib/stores/company.svelte';
let {
open = $bindable(false),
item = null,
onSuccess
}: {
open: boolean;
item?: Transporter | null;
onSuccess?: () => void;
} = $props();
// Determinar si es modo edición o creación
const isEdit = $derived(!!item);
const title = $derived(isEdit ? 'Editar Transportista' : 'Nuevo Transportista');
// Estado del formulario
let formData = $state<Transporter>({
transporter_key: '',
name: '',
short_name: '',
responsible: '',
rfc: '',
streets: '',
postal_code: '',
city: '',
state: '',
country: '',
loader_code: '',
caat_code: '',
transport_code: '',
transport_interface_type: '',
ftp_server: '',
ftp_user: '',
ftp_password: '',
ftp_directory: '',
filler_code: ''
});
let loading = $state(false);
let error = $state<string | null>(null);
// Resetear formulario cuando cambia el item
$effect(() => {
if (item) {
formData = { ...item };
} else {
formData = {
transporter_key: '',
name: '',
short_name: '',
responsible: '',
rfc: '',
streets: '',
postal_code: '',
city: '',
state: '',
country: '',
loader_code: '',
caat_code: '',
transport_code: '',
transport_interface_type: '',
ftp_server: '',
ftp_user: '',
ftp_password: '',
ftp_directory: '',
filler_code: ''
};
}
});
async function handleSubmit() {
error = null;
loading = true;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
throw new Error('No hay una compañía seleccionada');
}
// Validación básica
if (!formData.transporter_key.trim()) {
throw new Error('La clave es requerida');
}
let response;
if (isEdit && item) {
response = await transportersApi.update(item.transporter_key, formData, companyId);
} else {
response = await transportersApi.create(formData, companyId);
}
if (response.error) {
throw new Error(response.error);
}
// Cerrar diálogo y notificar éxito
open = false;
if (onSuccess) {
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Error al guardar el transportista';
} finally {
loading = false;
}
}
function handleCancel() {
open = false;
error = null;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>
{isEdit
? 'Modifica los datos del transportista'
: 'Completa los datos para crear un nuevo transportista'}
</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-6"
>
{#if error}
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Información General -->
<section class="space-y-4">
<h3 class="border-b pb-2 text-sm font-semibold">Información General</h3>
<div class="grid gap-2">
<Label for="transporter_key">Clave <span class="text-destructive">*</span></Label>
<Input
id="transporter_key"
bind:value={formData.transporter_key}
disabled={isEdit}
required
maxlength={23}
/>
</div>
<div class="grid gap-2">
<Label for="name">Nombre / Razón Social</Label>
<Input id="name" bind:value={formData.name} maxlength={256} />
</div>
<div class="grid gap-2">
<Label for="short_name">Nombre Corto</Label>
<Input
id="short_name"
bind:value={formData.short_name}
maxlength={10}
placeholder="Máx. 10 car."
/>
</div>
<div class="grid gap-2">
<Label for="rfc">RFC</Label>
<Input id="rfc" bind:value={formData.rfc} />
</div>
<div class="grid gap-2">
<Label for="responsible">Responsable</Label>
<Input id="responsible" bind:value={formData.responsible} />
</div>
</section>
<!-- Códigos y Transporte -->
<section class="space-y-4">
<h3 class="border-b pb-2 text-sm font-semibold">Códigos de Transporte</h3>
<div class="grid gap-2">
<Label for="caat_code">Código CAAT</Label>
<Input id="caat_code" bind:value={formData.caat_code} />
</div>
<div class="grid gap-2">
<Label for="transport_code">Código de Transporte</Label>
<Input
id="transport_code"
bind:value={formData.transport_code}
maxlength={8}
placeholder="Máx. 8 car."
/>
</div>
<div class="grid gap-2">
<Label for="loader_code">Código Cargador</Label>
<Input
id="loader_code"
bind:value={formData.loader_code}
maxlength={9}
placeholder="Máx. 9 car."
/>
</div>
<div class="grid gap-2">
<Label for="transport_interface_type">Tipo Interfaz</Label>
<Input
id="transport_interface_type"
bind:value={formData.transport_interface_type}
maxlength={20}
/>
</div>
<div class="grid gap-2">
<Label for="filler_code">Código Relleno</Label>
<Input id="filler_code" bind:value={formData.filler_code} maxlength={20} />
</div>
</section>
<!-- Dirección -->
<section class="space-y-4">
<h3 class="border-b pb-2 text-sm font-semibold">Dirección</h3>
<div class="grid gap-2">
<Label for="streets">Calle y Número</Label>
<Input id="streets" bind:value={formData.streets} />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="city">Ciudad</Label>
<Input id="city" bind:value={formData.city} maxlength={30} />
</div>
<div class="grid gap-2">
<Label for="state">Estado</Label>
<Input id="state" bind:value={formData.state} maxlength={30} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="country">País</Label>
<Input
id="country"
bind:value={formData.country}
maxlength={3}
placeholder="MEX / USA"
/>
</div>
<div class="grid gap-2">
<Label for="postal_code">C.P.</Label>
<Input id="postal_code" bind:value={formData.postal_code} />
</div>
</div>
</section>
<!-- Configuración FTP -->
<section class="space-y-4">
<h3 class="border-b pb-2 text-sm font-semibold">Configuración FTP</h3>
<div class="grid gap-2">
<Label for="ftp_server">Servidor FTP</Label>
<Input id="ftp_server" bind:value={formData.ftp_server} />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="ftp_user">Usuario</Label>
<Input id="ftp_user" bind:value={formData.ftp_user} />
</div>
<div class="grid gap-2">
<Label for="ftp_password">Contraseña</Label>
<Input id="ftp_password" type="password" bind:value={formData.ftp_password} />
</div>
</div>
<div class="grid gap-2">
<Label for="ftp_directory">Directorio</Label>
<Input id="ftp_directory" bind:value={formData.ftp_directory} />
</div>
</section>
</div>
<Dialog.Footer>
<Button type="button" variant="outline" onclick={handleCancel} disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,114 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { transportersApi, type Transporter } from '$lib/api/dashboard/a76/transporters';
import { companyStore } from '$lib/stores/company.svelte';
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
import CreateEditDialog from './create-edit-dialog.svelte';
let {
item,
onSuccess
}: {
item: Transporter;
onSuccess?: () => void;
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let dialogOpen = $state(false);
let selectedItem = $state<Transporter | null>(null);
async function handleDelete() {
if (
!confirm(
`¿Estás seguro de eliminar el transportista "${item.transporter_key}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`
)
) {
return;
}
if (!companyStore.activeCompany) {
alert('❌ Error: No hay una compañía seleccionada');
return;
}
loading = true;
error = null;
try {
const response = await transportersApi.delete(
item.transporter_key,
companyStore.activeCompany.id
);
if (response.error) {
if (response.status === 401) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
error = response.error;
alert(`❌ Error al eliminar:\n\n${response.error}`);
}
return;
}
// Éxito
alert(`✅ Transportista "${item.transporter_key}" eliminado correctamente`);
if (onSuccess) {
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Error al eliminar';
alert(`❌ Error: ${error}`);
console.error('Error deleting:', e);
} finally {
loading = false;
}
}
function handleEdit() {
selectedItem = item;
dialogOpen = true;
}
function handleDialogSuccess() {
dialogOpen = false;
selectedItem = null;
if (onSuccess) {
onSuccess();
}
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="relative size-8 p-0">
<span class="sr-only">Abrir menú</span>
<EllipsisVertical size={16} />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-[160px]">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleEdit}>
<Pencil size={16} class="mr-2" />
Editar
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleDelete} class="text-destructive" disabled={loading}>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Trash2 size={16} class="mr-2" />
{/if}
Eliminar
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<CreateEditDialog bind:open={dialogOpen} item={selectedItem} onSuccess={handleDialogSuccess} />

View File

@@ -0,0 +1,99 @@
<script lang="ts" generics="TData, TValue">
import { type ColumnDef, getCoreRowModel } from '@tanstack/table-core';
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Button } from '$lib/components/ui/button';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
pageCount: number;
totalItems: number;
};
let { data, columns, pageCount, totalItems }: DataTableProps<TData, TValue> = $props();
const table = createSvelteTable({
get data() {
return data;
},
get columns() {
return columns;
},
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
get pageCount() {
return pageCount;
}
});
function handlePageChange(newPage: number) {
const url = new URL($page.url);
url.searchParams.set('page', newPage.toString());
goto(url);
}
</script>
<div class="rounded-md border bg-card">
<Table.Root>
<Table.Header>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head>
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
</Table.Head>
{/each}
</Table.Row>
{/each}
</Table.Header>
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
<Table.Row data-state={row.getIsSelected() && 'selected'}>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell>
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</Table.Cell>
{/each}
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={columns.length} class="h-24 text-center">
No hay resultados.
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
<div class="flex items-center justify-end space-x-2 py-4">
<div class="flex-1 text-sm text-muted-foreground">
Total: {totalItems}
</div>
<div class="space-x-2">
<Button
variant="outline"
size="sm"
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) - 1)}
disabled={Number($page.url.searchParams.get('page') || 1) <= 1}
>
Anterior
</Button>
<Button
variant="outline"
size="sm"
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) + 1)}
disabled={Number($page.url.searchParams.get('page') || 1) >= pageCount}
>
Siguiente
</Button>
</div>
</div>

View File

@@ -0,0 +1,64 @@
/**
* Definición de columnas para la tabla de Transportistas
*/
import type { Transporter } from '$lib/api/dashboard/a76/transporters';
import type { ColumnDef } from '@tanstack/table-core';
import { renderComponent } from '$lib/components/ui/data-table';
import DataTableActions from './data-table-actions.svelte';
export function createColumns(onSuccess?: () => void): ColumnDef<Transporter>[] {
return [
{
accessorKey: 'transporter_key',
header: 'Clave',
cell: ({ row }) => {
return row.original.transporter_key;
}
},
{
accessorKey: 'name',
header: 'Nombre',
cell: ({ row }) => {
return row.original.name || '-';
}
},
{
accessorKey: 'short_name',
header: 'Nombre Corto',
cell: ({ row }) => {
return row.original.short_name || '-';
}
},
{
accessorKey: 'rfc',
header: 'RFC',
cell: ({ row }) => {
return row.original.rfc || '-';
}
},
{
accessorKey: 'caat_code',
header: 'CAAT',
cell: ({ row }) => {
return row.original.caat_code || '-';
}
},
{
accessorKey: 'transport_code',
header: 'Código Transporte',
cell: ({ row }) => {
return row.original.transport_code || '-';
}
},
{
id: 'actions',
header: 'Acciones',
cell: ({ row }) => {
return renderComponent(DataTableActions, {
item: row.original,
onSuccess
});
}
}
];
}

View File

@@ -0,0 +1,64 @@
/**
* Definición de columnas para la tabla de Vehículos
*/
import type { Vehicle } from '$lib/api/dashboard/a76/vehicles';
import type { ColumnDef } from '@tanstack/table-core';
import { renderComponent } from '$lib/components/ui/data-table';
import DataTableActions from './data-table-actions.svelte';
export function createColumns(onSuccess?: () => void): ColumnDef<Vehicle>[] {
return [
{
accessorKey: 'vehicle_key',
header: 'Clave',
cell: ({ row }) => {
return row.original.vehicle_key;
}
},
{
accessorKey: 'brand',
header: 'Marca',
cell: ({ row }) => {
return row.original.brand || '-';
}
},
{
accessorKey: 'year',
header: 'Año',
cell: ({ row }) => {
return row.original.year || '-';
}
},
{
accessorKey: 'plate_number',
header: 'Placas',
cell: ({ row }) => {
return row.original.plate_number || '-';
}
},
{
accessorKey: 'transporter_key',
header: 'Transportista',
cell: ({ row }) => {
return row.original.transporter_key || '-';
}
},
{
accessorKey: 'transport_type',
header: 'Tipo Transporte',
cell: ({ row }) => {
return row.original.transport_type || '-';
}
},
{
id: 'actions',
header: 'Acciones',
cell: ({ row }) => {
return renderComponent(DataTableActions, {
item: row.original,
onSuccess
});
}
}
];
}

View File

@@ -0,0 +1,317 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { vehiclesApi, type Vehicle } from '$lib/api/dashboard/a76/vehicles';
import { companyStore } from '$lib/stores/company.svelte';
let {
open = $bindable(false),
item = null,
onSuccess
}: {
open: boolean;
item?: Vehicle | null;
onSuccess?: () => void;
} = $props();
// Determinar si es modo edición o creación
const isEdit = $derived(!!item);
const title = $derived(isEdit ? 'Editar Vehículo' : 'Nuevo Vehículo');
// Estado del formulario
let formData = $state<Vehicle>({
vehicle_key: '',
ace_vehicle_key: '',
transporter_key: '',
transport_identifier: '',
transport_type: '',
entity_code: '',
transponder_number: '',
dot_number: '',
plate_number: '',
city: '',
state: '',
country: '',
seal: '',
insurance_company_name: '',
insurance_number: '',
insurance_amount: undefined,
insurance_date: undefined,
box_number: '',
brand: '',
year: '',
series: '',
description: '',
engine_number: '',
sct_permission: '',
color: '',
container_key: ''
});
let loading = $state(false);
let error = $state<string | null>(null);
// Resetear formulario cuando cambia el item
$effect(() => {
if (item) {
formData = { ...item };
} else {
formData = {
vehicle_key: '',
ace_vehicle_key: '',
transporter_key: '',
transport_identifier: '',
transport_type: '',
entity_code: '',
transponder_number: '',
dot_number: '',
plate_number: '',
city: '',
state: '',
country: '',
seal: '',
insurance_company_name: '',
insurance_number: '',
insurance_amount: undefined,
insurance_date: undefined,
box_number: '',
brand: '',
year: '',
series: '',
description: '',
engine_number: '',
sct_permission: '',
color: '',
container_key: ''
};
}
});
async function handleSubmit() {
error = null;
loading = true;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
throw new Error('No hay una compañía seleccionada');
}
// Validación básica
if (!formData.vehicle_key.trim()) {
throw new Error('La clave del vehículo es requerida');
}
let response;
if (isEdit && item) {
response = await vehiclesApi.update(item.vehicle_key, formData, companyId);
} else {
response = await vehiclesApi.create(formData, companyId);
}
if (response.error) {
throw new Error(response.error);
}
// Cerrar diálogo y notificar éxito
open = false;
if (onSuccess) {
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Error al guardar el vehículo';
} finally {
loading = false;
}
}
function handleCancel() {
open = false;
error = null;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] max-w-4xl overflow-y-auto">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>
{isEdit
? 'Modifica los datos del vehículo'
: 'Completa los datos para crear un nuevo vehículo de transporte'}
</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-6"
>
{#if error}
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Información del Vehículo -->
<section class="space-y-4">
<h3 class="border-b pb-2 text-sm font-semibold">Identificación del Vehículo</h3>
<div class="grid gap-2">
<Label for="vehicle_key"
>Clave del Vehículo <span class="text-destructive">*</span></Label
>
<Input
id="vehicle_key"
bind:value={formData.vehicle_key}
disabled={isEdit}
required
maxlength={14}
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="brand">Marca</Label>
<Input id="brand" bind:value={formData.brand} maxlength={20} />
</div>
<div class="grid gap-2">
<Label for="year">Año</Label>
<Input id="year" bind:value={formData.year} maxlength={4} placeholder="YYYY" />
</div>
</div>
<div class="grid gap-2">
<Label for="plate_number">Placas</Label>
<Input id="plate_number" bind:value={formData.plate_number} maxlength={17} />
</div>
<div class="grid gap-2">
<Label for="series">Serie / VIN</Label>
<Input id="series" bind:value={formData.series} maxlength={30} />
</div>
</section>
<!-- Datos de Transporte -->
<section class="space-y-4">
<h3 class="border-b pb-2 text-sm font-semibold">Datos de Transporte</h3>
<div class="grid gap-2">
<Label for="transporter_key">Clave Transportista</Label>
<Input id="transporter_key" bind:value={formData.transporter_key} maxlength={23} />
</div>
<div class="grid gap-2">
<Label for="transport_identifier">Identificador Transporte</Label>
<Input
id="transport_identifier"
bind:value={formData.transport_identifier}
maxlength={30}
/>
</div>
<div class="grid gap-2">
<Label for="transport_type">Tipo de Transporte</Label>
<Input
id="transport_type"
bind:value={formData.transport_type}
maxlength={2}
placeholder="2 car."
/>
</div>
<div class="grid gap-2">
<Label for="sct_permission">Permiso SCT</Label>
<Input id="sct_permission" bind:value={formData.sct_permission} maxlength={40} />
</div>
</section>
<!-- Seguro y Otros -->
<section class="space-y-4">
<h3 class="border-b pb-2 text-sm font-semibold">Seguro y Otros</h3>
<div class="grid gap-2">
<Label for="insurance_company_name">Aseguradora</Label>
<Input
id="insurance_company_name"
bind:value={formData.insurance_company_name}
maxlength={30}
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="insurance_number">Póliza</Label>
<Input id="insurance_number" bind:value={formData.insurance_number} maxlength={20} />
</div>
<div class="grid gap-2">
<Label for="insurance_amount">Monto Seguro</Label>
<Input id="insurance_amount" type="number" bind:value={formData.insurance_amount} />
</div>
</div>
<div class="grid gap-2">
<Label for="dot_number">Número DOT</Label>
<Input id="dot_number" bind:value={formData.dot_number} maxlength={8} />
</div>
</section>
<!-- Ubicación y Detalles -->
<section class="space-y-4">
<h3 class="border-b pb-2 text-sm font-semibold">Ubicación y Detalles</h3>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="state">Estado</Label>
<Input id="state" bind:value={formData.state} maxlength={30} />
</div>
<div class="grid gap-2">
<Label for="country">País</Label>
<Input
id="country"
bind:value={formData.country}
maxlength={3}
placeholder="MEX / USA"
/>
</div>
</div>
<div class="grid gap-2">
<Label for="description">Descripción</Label>
<Input id="description" bind:value={formData.description} maxlength={100} />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="color">Color</Label>
<Input id="color" bind:value={formData.color} maxlength={20} />
</div>
<div class="grid gap-2">
<Label for="container_key">Contenedor</Label>
<Input
id="container_key"
bind:value={formData.container_key}
maxlength={3}
placeholder="3 car."
/>
</div>
</div>
</section>
</div>
<Dialog.Footer>
<Button type="button" variant="outline" onclick={handleCancel} disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

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 { vehiclesApi, type Vehicle } from '$lib/api/dashboard/a76/vehicles';
import { companyStore } from '$lib/stores/company.svelte';
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
import CreateEditDialog from './create-edit-dialog.svelte';
let {
item,
onSuccess
}: {
item: Vehicle;
onSuccess?: () => void;
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let dialogOpen = $state(false);
let selectedItem = $state<Vehicle | null>(null);
async function handleDelete() {
if (
!confirm(
`¿Estás seguro de eliminar el vehículo "${item.vehicle_key}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`
)
) {
return;
}
if (!companyStore.activeCompany) {
alert('❌ Error: No hay una compañía seleccionada');
return;
}
loading = true;
error = null;
try {
const response = await vehiclesApi.delete(item.vehicle_key, companyStore.activeCompany.id);
if (response.error) {
if (response.status === 401) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
error = response.error;
alert(`❌ Error al eliminar:\n\n${response.error}`);
}
return;
}
// Éxito
alert(`✅ Vehículo "${item.vehicle_key}" eliminado correctamente`);
if (onSuccess) {
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Error al eliminar';
alert(`❌ Error: ${error}`);
console.error('Error deleting:', e);
} finally {
loading = false;
}
}
function handleEdit() {
selectedItem = item;
dialogOpen = true;
}
function handleDialogSuccess() {
dialogOpen = false;
selectedItem = null;
if (onSuccess) {
onSuccess();
}
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="relative size-8 p-0">
<span class="sr-only">Abrir menú</span>
<EllipsisVertical size={16} />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-[160px]">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleEdit}>
<Pencil size={16} class="mr-2" />
Editar
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleDelete} class="text-destructive" disabled={loading}>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Trash2 size={16} class="mr-2" />
{/if}
Eliminar
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<CreateEditDialog bind:open={dialogOpen} item={selectedItem} onSuccess={handleDialogSuccess} />

View File

@@ -0,0 +1,99 @@
<script lang="ts" generics="TData, TValue">
import { type ColumnDef, getCoreRowModel } from '@tanstack/table-core';
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Button } from '$lib/components/ui/button';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
pageCount: number;
totalItems: number;
};
let { data, columns, pageCount, totalItems }: DataTableProps<TData, TValue> = $props();
const table = createSvelteTable({
get data() {
return data;
},
get columns() {
return columns;
},
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
get pageCount() {
return pageCount;
}
});
function handlePageChange(newPage: number) {
const url = new URL($page.url);
url.searchParams.set('page', newPage.toString());
goto(url);
}
</script>
<div class="rounded-md border bg-card">
<Table.Root>
<Table.Header>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head>
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
</Table.Head>
{/each}
</Table.Row>
{/each}
</Table.Header>
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
<Table.Row data-state={row.getIsSelected() && 'selected'}>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell>
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</Table.Cell>
{/each}
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={columns.length} class="h-24 text-center">
No hay resultados.
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
<div class="flex items-center justify-end space-x-2 py-4">
<div class="flex-1 text-sm text-muted-foreground">
Total: {totalItems}
</div>
<div class="space-x-2">
<Button
variant="outline"
size="sm"
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) - 1)}
disabled={Number($page.url.searchParams.get('page') || 1) <= 1}
>
Anterior
</Button>
<Button
variant="outline"
size="sm"
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) + 1)}
disabled={Number($page.url.searchParams.get('page') || 1) >= pageCount}
>
Siguiente
</Button>
</div>
</div>

View File

@@ -15,6 +15,7 @@ import {
Settings2,
Shield,
Ship,
Truck,
Users,
} from 'lucide-svelte';
import * as m from "$lib/paraglide/messages.js";
@@ -192,6 +193,7 @@ export function getSidebarData(): SidebarData {
title: m["sidebar.general_catalogs.identifiers"](),
url: "/dashboard/general_catalogs/identifiers",
},
// -------------------------------------
{
title: m["sidebar.general_catalogs.incoterms"](),
url: "/dashboard/reference_data/incoterms",
@@ -333,6 +335,25 @@ export function getSidebarData(): SidebarData {
},
],
},
{
title: "Transportes",
url: "#",
icon: Truck,
items: [
{
title: "Transportistas",
url: "/dashboard/general_catalogs/transporters",
},
{
title: "Trailers",
url: "/dashboard/general_catalogs/trailers",
},
{
title: "Vehículos",
url: "/dashboard/general_catalogs/vehicles",
},
],
},
{
title: m["sidebar.goods.title"](),
url: "#",
@@ -522,4 +543,4 @@ export function getSidebarData(): SidebarData {
}
// Exportar también como constante para compatibilidad (deprecado)
export const sidebarData: SidebarData = getSidebarData();
export const sidebarData: SidebarData = getSidebarData();

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus } from 'lucide-svelte';
// Importar componentes de la librería
import DataTable from '$lib/components/dashboard/transportation/trailers/data-table.svelte';
import CreateEditDialog from '$lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte';
import { createColumns } from '$lib/components/dashboard/transportation/trailers/columns';
import { trailersApi, type Trailer } from '$lib/api/dashboard/a76/trailers';
import { companyStore } from '$lib/stores/company.svelte';
import { browser } from '$app/environment';
// --- ESTADO ---
let data = $state<Trailer[]>([]);
let totalItems = $state(0);
let pageCount = $state(0);
let loading = $state(false);
let createDialogOpen = $state(false);
let currentPage = $derived(Number($page.url.searchParams.get('page')) || 1);
let pageSize = 10;
// Filtros
let searchNumber = $state($page.url.searchParams.get('trailer_number') || '');
let searchPlate = $state($page.url.searchParams.get('plate_number') || '');
let searchTimeout: NodeJS.Timeout;
// --- LOGICA ---
async function loadData() {
if (!companyStore.activeCompany) return;
loading = true;
try {
const response = await trailersApi.list(companyStore.activeCompany.id, {
page: currentPage,
page_size: pageSize,
trailer_number: searchNumber,
plate_number: searchPlate
});
if (response.data) {
data = response.data.items;
totalItems = response.data.total;
pageCount = Math.ceil(response.data.total / response.data.page_size);
}
} catch (error) {
console.error('Error loading trailers:', error);
} finally {
loading = false;
}
}
function handleSearch() {
if (!browser) return;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const url = new URL($page.url);
url.searchParams.set('page', '1');
if (searchNumber) url.searchParams.set('trailer_number', searchNumber);
else url.searchParams.delete('trailer_number');
if (searchPlate) url.searchParams.set('plate_number', searchPlate);
else url.searchParams.delete('plate_number');
goto(url, { keepFocus: true, noScroll: true });
}, 500);
}
// Recargar datos cuando cambia el contexto de compañía o la página/filtros
$effect(() => {
const _ = { p: $page.url.href, c: companyStore.activeCompany?.id };
loadData();
});
const columns = createColumns(loadData);
</script>
<div class="flex h-full flex-col space-y-6 p-8">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">Trailers</h2>
<p class="text-muted-foreground">Gestión del catálogo de trailers de la compañía</p>
</div>
<Button onclick={() => (createDialogOpen = true)}>
<Plus class="mr-2 h-4 w-4" /> Nuevo Trailer
</Button>
</div>
<div class="flex items-center space-x-2">
<Input
placeholder="Buscar por número..."
class="h-8 w-[250px] bg-card"
bind:value={searchNumber}
oninput={handleSearch}
/>
<Input
placeholder="Buscar por placas..."
class="h-8 w-[250px] bg-card"
bind:value={searchPlate}
oninput={handleSearch}
/>
</div>
{#if loading && data.length === 0}
<div class="flex h-64 items-center justify-center text-muted-foreground">
Cargando trailers...
</div>
{:else}
<DataTable {data} {columns} {pageCount} {totalItems} />
{/if}
<CreateEditDialog bind:open={createDialogOpen} onSuccess={loadData} />
</div>

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus } from 'lucide-svelte';
// Importar componentes de la librería
import DataTable from '$lib/components/dashboard/transportation/transporters/data-table.svelte';
import CreateEditDialog from '$lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte';
import { createColumns } from '$lib/components/dashboard/transportation/transporters/transporter-columns';
import { transportersApi, type Transporter } from '$lib/api/dashboard/a76/transporters';
import { companyStore } from '$lib/stores/company.svelte';
import { browser } from '$app/environment';
// --- ESTADO ---
let data = $state<Transporter[]>([]);
let totalItems = $state(0);
let pageCount = $state(0);
let loading = $state(false);
let createDialogOpen = $state(false);
let currentPage = $derived(Number($page.url.searchParams.get('page')) || 1);
let pageSize = 10;
// Filtros
let searchKey = $state($page.url.searchParams.get('transporter_key') || '');
let searchName = $state($page.url.searchParams.get('name') || '');
let searchTimeout: NodeJS.Timeout;
// --- LOGICA ---
async function loadData() {
if (!companyStore.activeCompany) return;
loading = true;
try {
const response = await transportersApi.list(companyStore.activeCompany.id, {
page: currentPage,
page_size: pageSize,
transporter_key: searchKey,
name: searchName
});
if (response.data) {
data = response.data.items;
totalItems = response.data.total;
pageCount = Math.ceil(response.data.total / response.data.page_size);
}
} catch (error) {
console.error('Error loading transporters:', error);
} finally {
loading = false;
}
}
function handleSearch() {
if (!browser) return;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const url = new URL($page.url);
url.searchParams.set('page', '1');
if (searchKey) url.searchParams.set('transporter_key', searchKey);
else url.searchParams.delete('transporter_key');
if (searchName) url.searchParams.set('name', searchName);
else url.searchParams.delete('name');
goto(url, { keepFocus: true, noScroll: true });
}, 500);
}
// Recargar datos cuando cambia el contexto de compañía o la página/filtros
$effect(() => {
const _ = { p: $page.url.href, c: companyStore.activeCompany?.id };
loadData();
});
const columns = createColumns(loadData);
</script>
<div class="flex h-full flex-col space-y-6 p-8">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">Transportistas</h2>
<p class="text-muted-foreground">Gestión del catálogo de líneas transportistas</p>
</div>
<Button onclick={() => (createDialogOpen = true)}>
<Plus class="mr-2 h-4 w-4" /> Nuevo Transportista
</Button>
</div>
<div class="flex items-center space-x-2">
<Input
placeholder="Buscar por clave..."
class="h-8 w-[250px] bg-card"
bind:value={searchKey}
oninput={handleSearch}
/>
<Input
placeholder="Buscar por nombre..."
class="h-8 w-[250px] bg-card"
bind:value={searchName}
oninput={handleSearch}
/>
</div>
{#if loading && data.length === 0}
<div class="flex h-64 items-center justify-center text-muted-foreground">
Cargando transportistas...
</div>
{:else}
<DataTable {data} {columns} {pageCount} {totalItems} />
{/if}
<CreateEditDialog bind:open={createDialogOpen} onSuccess={loadData} />
</div>

View File

@@ -0,0 +1,119 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus } from 'lucide-svelte';
// Importar componentes de la librería
import DataTable from '$lib/components/dashboard/transportation/vehicles/data-table.svelte';
import CreateEditDialog from '$lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte';
import { createColumns } from '$lib/components/dashboard/transportation/vehicles/columns';
import { vehiclesApi, type Vehicle } from '$lib/api/dashboard/a76/vehicles';
import { companyStore } from '$lib/stores/company.svelte';
import { browser } from '$app/environment';
// --- ESTADO ---
let data = $state<Vehicle[]>([]);
let totalItems = $state(0);
let pageCount = $state(0);
let loading = $state(false);
let createDialogOpen = $state(false);
let currentPage = $derived(Number($page.url.searchParams.get('page')) || 1);
let pageSize = 10;
// Filtros
let searchKey = $state($page.url.searchParams.get('vehicle_key') || '');
let searchPlate = $state($page.url.searchParams.get('plate_number') || '');
let searchTimeout: NodeJS.Timeout;
// --- LOGICA ---
async function loadData() {
if (!companyStore.activeCompany) return;
loading = true;
try {
const response = await vehiclesApi.list(companyStore.activeCompany.id, {
page: currentPage,
page_size: pageSize,
vehicle_key: searchKey,
plate_number: searchPlate
});
if (response.data) {
data = response.data.items;
totalItems = response.data.total;
pageCount = Math.ceil(response.data.total / response.data.page_size);
}
} catch (error) {
console.error('Error loading vehicles:', error);
} finally {
loading = false;
}
}
function handleSearch() {
if (!browser) return;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const url = new URL($page.url);
url.searchParams.set('page', '1');
if (searchKey) url.searchParams.set('vehicle_key', searchKey);
else url.searchParams.delete('vehicle_key');
if (searchPlate) url.searchParams.set('plate_number', searchPlate);
else url.searchParams.delete('plate_number');
goto(url, { keepFocus: true, noScroll: true });
}, 500);
}
// Recargar datos cuando cambia el contexto de compañía o la página/filtros
$effect(() => {
const _ = { p: $page.url.href, c: companyStore.activeCompany?.id };
loadData();
});
const columns = createColumns(loadData);
</script>
<div class="flex h-full flex-col space-y-6 p-8">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">Vehículos (Transporte)</h2>
<p class="text-muted-foreground">
Gestión del catálogo de camiones y vehículos de transporte
</p>
</div>
<Button onclick={() => (createDialogOpen = true)}>
<Plus class="mr-2 h-4 w-4" /> Nuevo Vehículo
</Button>
</div>
<div class="flex items-center space-x-2">
<Input
placeholder="Buscar por clave..."
class="h-8 w-[250px] bg-card"
bind:value={searchKey}
oninput={handleSearch}
/>
<Input
placeholder="Buscar por placas..."
class="h-8 w-[250px] bg-card"
bind:value={searchPlate}
oninput={handleSearch}
/>
</div>
{#if loading && data.length === 0}
<div class="flex h-64 items-center justify-center text-muted-foreground">
Cargando vehículos...
</div>
{:else}
<DataTable {data} {columns} {pageCount} {totalItems} />
{/if}
<CreateEditDialog bind:open={createDialogOpen} onSuccess={loadData} />
</div>

7
frontend/src/svelte-shims.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
// Ambient type declarations for .svelte files
// This must be a script (no top-level import/export) to be globally ambient
declare module "*.svelte" {
import type { Component } from "svelte";
const component: Component<any>;
export default component;
}