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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
141
backend/main.py
141
backend/main.py
@@ -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,
|
||||
|
||||
2
frontend/src/app.d.ts
vendored
2
frontend/src/app.d.ts
vendored
@@ -13,4 +13,4 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
export { };
|
||||
|
||||
@@ -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()}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
} from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import { itemsApi, type Item } from '$lib/api/dashboard/a76/items';
|
||||
import { itemsApi, type Item as InvoiceItem } from '$lib/api/dashboard/a76/items';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import ItemSheetFa from './fa/item-sheet-fa.svelte';
|
||||
import ItemSheetInv from './inv/item-sheet-inv.svelte';
|
||||
@@ -43,7 +43,7 @@
|
||||
} = $props();
|
||||
|
||||
// 1. Core State
|
||||
let items = $state<Item[]>([]);
|
||||
let items = $state<InvoiceItem[]>([]);
|
||||
let displayedItems = $state<any[]>([]);
|
||||
let imported = $state(0);
|
||||
let net_weight = $state(0);
|
||||
@@ -129,9 +129,9 @@
|
||||
let showItemSheet = $state(false);
|
||||
let isEditMode = $state(false);
|
||||
let showDeleteDialog = $state(false);
|
||||
let selectedItem = $state<Item | null>(null);
|
||||
let originalItemData = $state<Partial<Item> | null>(null);
|
||||
let editingItem = $state<Partial<Item>>({
|
||||
let selectedItem = $state<InvoiceItem | null>(null);
|
||||
let originalItemData = $state<Partial<InvoiceItem> | null>(null);
|
||||
let editingItem = $state<Partial<InvoiceItem>>({
|
||||
invoice_id: undefined,
|
||||
reference_number: '',
|
||||
order: '',
|
||||
@@ -400,7 +400,7 @@
|
||||
return cleanLineData({ ...rest });
|
||||
}
|
||||
|
||||
function cloneItemForPreset(item: Item) {
|
||||
function cloneItemForPreset(item: InvoiceItem) {
|
||||
const { id, tenant_id, company_id, created_at, updated_at, temp_id, ...rest } = item as any;
|
||||
return {
|
||||
...sanitizeLineForPreset(rest),
|
||||
@@ -502,7 +502,7 @@
|
||||
isSavingPreset = true;
|
||||
try {
|
||||
// We group everything as items for the template
|
||||
const lines = builderItems.map((item: Item, idx: number) => {
|
||||
const lines = builderItems.map((item: InvoiceItem, idx: number) => {
|
||||
return {
|
||||
...cleanLineData(item),
|
||||
line_number: item.line_number || idx + 1, // Ensure line_number is present
|
||||
@@ -552,7 +552,7 @@
|
||||
}
|
||||
|
||||
// Enrich item with descriptive data for display
|
||||
async function enrichItemData(item: Partial<Item>) {
|
||||
async function enrichItemData(item: Partial<InvoiceItem>) {
|
||||
if (!item || !activeCompanyId) return;
|
||||
|
||||
// Load class data
|
||||
@@ -697,7 +697,7 @@
|
||||
}
|
||||
|
||||
// Normalize numeric values from strings to numbers
|
||||
function normalizeItemData(item: Partial<Item>): Partial<Item> {
|
||||
function normalizeItemData(item: Partial<InvoiceItem>): Partial<InvoiceItem> {
|
||||
if (item) {
|
||||
const normalizedItem = { ...item };
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Definición de columnas para la tabla de Trailers
|
||||
*/
|
||||
import type { Trailer } from '$lib/api/dashboard/a76/trailers';
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<Trailer>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'trailer_number',
|
||||
header: 'Número de Trailer',
|
||||
cell: ({ row }) => {
|
||||
return row.original.trailer_number;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'plate_number',
|
||||
header: 'Placas',
|
||||
cell: ({ row }) => {
|
||||
return row.original.plate_number || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'trailer_type_key',
|
||||
header: 'Tipo de Trailer',
|
||||
cell: ({ row }) => {
|
||||
return row.original.trailer_type_key || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'container_key',
|
||||
header: 'Contenedor',
|
||||
cell: ({ row }) => {
|
||||
return row.original.container_key || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'state',
|
||||
header: 'Estado',
|
||||
cell: ({ row }) => {
|
||||
return row.original.state || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'country',
|
||||
header: 'País',
|
||||
cell: ({ row }) => {
|
||||
return row.original.country || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { trailersApi, type Trailer } from '$lib/api/dashboard/a76/trailers';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: Trailer | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? 'Editar Trailer' : 'Nuevo Trailer');
|
||||
|
||||
let formData = $state<Trailer>({
|
||||
trailer_number: '',
|
||||
ace_trailer_number: '',
|
||||
trailer_type_key: '',
|
||||
seal: '',
|
||||
entity_code: '',
|
||||
plate_number: '',
|
||||
state: '',
|
||||
country: '',
|
||||
container_key: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (item) {
|
||||
formData = { ...item };
|
||||
} else {
|
||||
formData = {
|
||||
trailer_number: '',
|
||||
ace_trailer_number: '',
|
||||
trailer_type_key: '',
|
||||
seal: '',
|
||||
entity_code: '',
|
||||
plate_number: '',
|
||||
state: '',
|
||||
country: '',
|
||||
container_key: ''
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
error = null;
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
throw new Error('No hay una compañía seleccionada');
|
||||
}
|
||||
|
||||
if (!formData.trailer_number.trim()) {
|
||||
throw new Error('El número de trailer es requerido');
|
||||
}
|
||||
|
||||
let response;
|
||||
if (isEdit && item) {
|
||||
response = await trailersApi.update(item.trailer_number, formData, companyId);
|
||||
} else {
|
||||
response = await trailersApi.create(formData, companyId);
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar el trailer';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
open = false;
|
||||
error = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{isEdit
|
||||
? 'Modifica los datos del trailer'
|
||||
: 'Completa los datos para crear un nuevo trailer'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="space-y-6"
|
||||
>
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="trailer_number"
|
||||
>Número de Trailer <span class="text-destructive">*</span></Label
|
||||
>
|
||||
<Input
|
||||
id="trailer_number"
|
||||
bind:value={formData.trailer_number}
|
||||
disabled={isEdit}
|
||||
required
|
||||
maxlength={20}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="plate_number">Placas</Label>
|
||||
<Input id="plate_number" bind:value={formData.plate_number} maxlength={17} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="ace_trailer_number">Número Trailer ACE</Label>
|
||||
<Input id="ace_trailer_number" bind:value={formData.ace_trailer_number} maxlength={10} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="trailer_type_key">Tipo de Trailer (Clave)</Label>
|
||||
<Input
|
||||
id="trailer_type_key"
|
||||
bind:value={formData.trailer_type_key}
|
||||
maxlength={2}
|
||||
placeholder="2 car."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="container_key">Contenedor (Clave)</Label>
|
||||
<Input
|
||||
id="container_key"
|
||||
bind:value={formData.container_key}
|
||||
maxlength={3}
|
||||
placeholder="3 car."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="seal">Sello</Label>
|
||||
<Input id="seal" bind:value={formData.seal} maxlength={15} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="entity_code">Código Entidad</Label>
|
||||
<Input
|
||||
id="entity_code"
|
||||
bind:value={formData.entity_code}
|
||||
maxlength={1}
|
||||
placeholder="1 car."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="state">Estado</Label>
|
||||
<Input id="state" bind:value={formData.state} maxlength={30} placeholder="Ej: TX" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="country">País</Label>
|
||||
<Input
|
||||
id="country"
|
||||
bind:value={formData.country}
|
||||
maxlength={3}
|
||||
placeholder="MEX / USA"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={handleCancel} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { trailersApi, type Trailer } from '$lib/api/dashboard/a76/trailers';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: Trailer;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
let selectedItem = $state<Trailer | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
if (
|
||||
!confirm(
|
||||
`¿Estás seguro de eliminar el trailer "${item.trailer_number}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
alert('❌ Error: No hay una compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await trailersApi.delete(item.trailer_number, companyStore.activeCompany.id);
|
||||
|
||||
if (response.error) {
|
||||
if (response.status === 401) {
|
||||
error = 'Sesión expirada. Recargando página...';
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
error = response.error;
|
||||
alert(`❌ Error al eliminar:\n\n${response.error}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Éxito
|
||||
alert(`✅ Trailer "${item.trailer_number}" eliminado correctamente`);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al eliminar';
|
||||
alert(`❌ Error: ${error}`);
|
||||
console.error('Error deleting:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
selectedItem = item;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function handleDialogSuccess() {
|
||||
dialogOpen = false;
|
||||
selectedItem = null;
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="relative size-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical size={16} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-[160px]">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleEdit}>
|
||||
<Pencil size={16} class="mr-2" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleDelete} class="text-destructive" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 size={16} class="mr-2" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog bind:open={dialogOpen} item={selectedItem} onSuccess={handleDialogSuccess} />
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import { type ColumnDef, getCoreRowModel } from '@tanstack/table-core';
|
||||
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let { data, columns, pageCount, totalItems }: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() {
|
||||
return data;
|
||||
},
|
||||
get columns() {
|
||||
return columns;
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() {
|
||||
return pageCount;
|
||||
}
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border bg-card">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && 'selected'}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems}
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) - 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) + 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,306 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { transportersApi, type Transporter } from '$lib/api/dashboard/a76/transporters';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: Transporter | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
// Determinar si es modo edición o creación
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? 'Editar Transportista' : 'Nuevo Transportista');
|
||||
|
||||
// Estado del formulario
|
||||
let formData = $state<Transporter>({
|
||||
transporter_key: '',
|
||||
name: '',
|
||||
short_name: '',
|
||||
responsible: '',
|
||||
rfc: '',
|
||||
streets: '',
|
||||
postal_code: '',
|
||||
city: '',
|
||||
state: '',
|
||||
country: '',
|
||||
loader_code: '',
|
||||
caat_code: '',
|
||||
transport_code: '',
|
||||
transport_interface_type: '',
|
||||
ftp_server: '',
|
||||
ftp_user: '',
|
||||
ftp_password: '',
|
||||
ftp_directory: '',
|
||||
filler_code: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Resetear formulario cuando cambia el item
|
||||
$effect(() => {
|
||||
if (item) {
|
||||
formData = { ...item };
|
||||
} else {
|
||||
formData = {
|
||||
transporter_key: '',
|
||||
name: '',
|
||||
short_name: '',
|
||||
responsible: '',
|
||||
rfc: '',
|
||||
streets: '',
|
||||
postal_code: '',
|
||||
city: '',
|
||||
state: '',
|
||||
country: '',
|
||||
loader_code: '',
|
||||
caat_code: '',
|
||||
transport_code: '',
|
||||
transport_interface_type: '',
|
||||
ftp_server: '',
|
||||
ftp_user: '',
|
||||
ftp_password: '',
|
||||
ftp_directory: '',
|
||||
filler_code: ''
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
error = null;
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
throw new Error('No hay una compañía seleccionada');
|
||||
}
|
||||
|
||||
// Validación básica
|
||||
if (!formData.transporter_key.trim()) {
|
||||
throw new Error('La clave es requerida');
|
||||
}
|
||||
|
||||
let response;
|
||||
if (isEdit && item) {
|
||||
response = await transportersApi.update(item.transporter_key, formData, companyId);
|
||||
} else {
|
||||
response = await transportersApi.create(formData, companyId);
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
// Cerrar diálogo y notificar éxito
|
||||
open = false;
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar el transportista';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
open = false;
|
||||
error = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{isEdit
|
||||
? 'Modifica los datos del transportista'
|
||||
: 'Completa los datos para crear un nuevo transportista'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="space-y-6"
|
||||
>
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Información General -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="border-b pb-2 text-sm font-semibold">Información General</h3>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="transporter_key">Clave <span class="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="transporter_key"
|
||||
bind:value={formData.transporter_key}
|
||||
disabled={isEdit}
|
||||
required
|
||||
maxlength={23}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Nombre / Razón Social</Label>
|
||||
<Input id="name" bind:value={formData.name} maxlength={256} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="short_name">Nombre Corto</Label>
|
||||
<Input
|
||||
id="short_name"
|
||||
bind:value={formData.short_name}
|
||||
maxlength={10}
|
||||
placeholder="Máx. 10 car."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="rfc">RFC</Label>
|
||||
<Input id="rfc" bind:value={formData.rfc} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="responsible">Responsable</Label>
|
||||
<Input id="responsible" bind:value={formData.responsible} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Códigos y Transporte -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="border-b pb-2 text-sm font-semibold">Códigos de Transporte</h3>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="caat_code">Código CAAT</Label>
|
||||
<Input id="caat_code" bind:value={formData.caat_code} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="transport_code">Código de Transporte</Label>
|
||||
<Input
|
||||
id="transport_code"
|
||||
bind:value={formData.transport_code}
|
||||
maxlength={8}
|
||||
placeholder="Máx. 8 car."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="loader_code">Código Cargador</Label>
|
||||
<Input
|
||||
id="loader_code"
|
||||
bind:value={formData.loader_code}
|
||||
maxlength={9}
|
||||
placeholder="Máx. 9 car."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="transport_interface_type">Tipo Interfaz</Label>
|
||||
<Input
|
||||
id="transport_interface_type"
|
||||
bind:value={formData.transport_interface_type}
|
||||
maxlength={20}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="filler_code">Código Relleno</Label>
|
||||
<Input id="filler_code" bind:value={formData.filler_code} maxlength={20} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Dirección -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="border-b pb-2 text-sm font-semibold">Dirección</h3>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="streets">Calle y Número</Label>
|
||||
<Input id="streets" bind:value={formData.streets} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="city">Ciudad</Label>
|
||||
<Input id="city" bind:value={formData.city} maxlength={30} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="state">Estado</Label>
|
||||
<Input id="state" bind:value={formData.state} maxlength={30} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="country">País</Label>
|
||||
<Input
|
||||
id="country"
|
||||
bind:value={formData.country}
|
||||
maxlength={3}
|
||||
placeholder="MEX / USA"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="postal_code">C.P.</Label>
|
||||
<Input id="postal_code" bind:value={formData.postal_code} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Configuración FTP -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="border-b pb-2 text-sm font-semibold">Configuración FTP</h3>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="ftp_server">Servidor FTP</Label>
|
||||
<Input id="ftp_server" bind:value={formData.ftp_server} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="ftp_user">Usuario</Label>
|
||||
<Input id="ftp_user" bind:value={formData.ftp_user} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="ftp_password">Contraseña</Label>
|
||||
<Input id="ftp_password" type="password" bind:value={formData.ftp_password} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="ftp_directory">Directorio</Label>
|
||||
<Input id="ftp_directory" bind:value={formData.ftp_directory} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={handleCancel} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { transportersApi, type Transporter } from '$lib/api/dashboard/a76/transporters';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: Transporter;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
let selectedItem = $state<Transporter | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
if (
|
||||
!confirm(
|
||||
`¿Estás seguro de eliminar el transportista "${item.transporter_key}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
alert('❌ Error: No hay una compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await transportersApi.delete(
|
||||
item.transporter_key,
|
||||
companyStore.activeCompany.id
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
if (response.status === 401) {
|
||||
error = 'Sesión expirada. Recargando página...';
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
error = response.error;
|
||||
alert(`❌ Error al eliminar:\n\n${response.error}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Éxito
|
||||
alert(`✅ Transportista "${item.transporter_key}" eliminado correctamente`);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al eliminar';
|
||||
alert(`❌ Error: ${error}`);
|
||||
console.error('Error deleting:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
selectedItem = item;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function handleDialogSuccess() {
|
||||
dialogOpen = false;
|
||||
selectedItem = null;
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="relative size-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical size={16} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-[160px]">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleEdit}>
|
||||
<Pencil size={16} class="mr-2" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleDelete} class="text-destructive" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 size={16} class="mr-2" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog bind:open={dialogOpen} item={selectedItem} onSuccess={handleDialogSuccess} />
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import { type ColumnDef, getCoreRowModel } from '@tanstack/table-core';
|
||||
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let { data, columns, pageCount, totalItems }: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() {
|
||||
return data;
|
||||
},
|
||||
get columns() {
|
||||
return columns;
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() {
|
||||
return pageCount;
|
||||
}
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border bg-card">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && 'selected'}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems}
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) - 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) + 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Definición de columnas para la tabla de Transportistas
|
||||
*/
|
||||
import type { Transporter } from '$lib/api/dashboard/a76/transporters';
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<Transporter>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'transporter_key',
|
||||
header: 'Clave',
|
||||
cell: ({ row }) => {
|
||||
return row.original.transporter_key;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Nombre',
|
||||
cell: ({ row }) => {
|
||||
return row.original.name || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'short_name',
|
||||
header: 'Nombre Corto',
|
||||
cell: ({ row }) => {
|
||||
return row.original.short_name || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'rfc',
|
||||
header: 'RFC',
|
||||
cell: ({ row }) => {
|
||||
return row.original.rfc || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'caat_code',
|
||||
header: 'CAAT',
|
||||
cell: ({ row }) => {
|
||||
return row.original.caat_code || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'transport_code',
|
||||
header: 'Código Transporte',
|
||||
cell: ({ row }) => {
|
||||
return row.original.transport_code || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Definición de columnas para la tabla de Vehículos
|
||||
*/
|
||||
import type { Vehicle } from '$lib/api/dashboard/a76/vehicles';
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<Vehicle>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'vehicle_key',
|
||||
header: 'Clave',
|
||||
cell: ({ row }) => {
|
||||
return row.original.vehicle_key;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'brand',
|
||||
header: 'Marca',
|
||||
cell: ({ row }) => {
|
||||
return row.original.brand || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'year',
|
||||
header: 'Año',
|
||||
cell: ({ row }) => {
|
||||
return row.original.year || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'plate_number',
|
||||
header: 'Placas',
|
||||
cell: ({ row }) => {
|
||||
return row.original.plate_number || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'transporter_key',
|
||||
header: 'Transportista',
|
||||
cell: ({ row }) => {
|
||||
return row.original.transporter_key || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'transport_type',
|
||||
header: 'Tipo Transporte',
|
||||
cell: ({ row }) => {
|
||||
return row.original.transport_type || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { vehiclesApi, type Vehicle } from '$lib/api/dashboard/a76/vehicles';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: Vehicle | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
// Determinar si es modo edición o creación
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? 'Editar Vehículo' : 'Nuevo Vehículo');
|
||||
|
||||
// Estado del formulario
|
||||
let formData = $state<Vehicle>({
|
||||
vehicle_key: '',
|
||||
ace_vehicle_key: '',
|
||||
transporter_key: '',
|
||||
transport_identifier: '',
|
||||
transport_type: '',
|
||||
entity_code: '',
|
||||
transponder_number: '',
|
||||
dot_number: '',
|
||||
plate_number: '',
|
||||
city: '',
|
||||
state: '',
|
||||
country: '',
|
||||
seal: '',
|
||||
insurance_company_name: '',
|
||||
insurance_number: '',
|
||||
insurance_amount: undefined,
|
||||
insurance_date: undefined,
|
||||
box_number: '',
|
||||
brand: '',
|
||||
year: '',
|
||||
series: '',
|
||||
description: '',
|
||||
engine_number: '',
|
||||
sct_permission: '',
|
||||
color: '',
|
||||
container_key: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Resetear formulario cuando cambia el item
|
||||
$effect(() => {
|
||||
if (item) {
|
||||
formData = { ...item };
|
||||
} else {
|
||||
formData = {
|
||||
vehicle_key: '',
|
||||
ace_vehicle_key: '',
|
||||
transporter_key: '',
|
||||
transport_identifier: '',
|
||||
transport_type: '',
|
||||
entity_code: '',
|
||||
transponder_number: '',
|
||||
dot_number: '',
|
||||
plate_number: '',
|
||||
city: '',
|
||||
state: '',
|
||||
country: '',
|
||||
seal: '',
|
||||
insurance_company_name: '',
|
||||
insurance_number: '',
|
||||
insurance_amount: undefined,
|
||||
insurance_date: undefined,
|
||||
box_number: '',
|
||||
brand: '',
|
||||
year: '',
|
||||
series: '',
|
||||
description: '',
|
||||
engine_number: '',
|
||||
sct_permission: '',
|
||||
color: '',
|
||||
container_key: ''
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
error = null;
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
throw new Error('No hay una compañía seleccionada');
|
||||
}
|
||||
|
||||
// Validación básica
|
||||
if (!formData.vehicle_key.trim()) {
|
||||
throw new Error('La clave del vehículo es requerida');
|
||||
}
|
||||
|
||||
let response;
|
||||
if (isEdit && item) {
|
||||
response = await vehiclesApi.update(item.vehicle_key, formData, companyId);
|
||||
} else {
|
||||
response = await vehiclesApi.create(formData, companyId);
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
// Cerrar diálogo y notificar éxito
|
||||
open = false;
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar el vehículo';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
open = false;
|
||||
error = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{isEdit
|
||||
? 'Modifica los datos del vehículo'
|
||||
: 'Completa los datos para crear un nuevo vehículo de transporte'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="space-y-6"
|
||||
>
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Información del Vehículo -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="border-b pb-2 text-sm font-semibold">Identificación del Vehículo</h3>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="vehicle_key"
|
||||
>Clave del Vehículo <span class="text-destructive">*</span></Label
|
||||
>
|
||||
<Input
|
||||
id="vehicle_key"
|
||||
bind:value={formData.vehicle_key}
|
||||
disabled={isEdit}
|
||||
required
|
||||
maxlength={14}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand">Marca</Label>
|
||||
<Input id="brand" bind:value={formData.brand} maxlength={20} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="year">Año</Label>
|
||||
<Input id="year" bind:value={formData.year} maxlength={4} placeholder="YYYY" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="plate_number">Placas</Label>
|
||||
<Input id="plate_number" bind:value={formData.plate_number} maxlength={17} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="series">Serie / VIN</Label>
|
||||
<Input id="series" bind:value={formData.series} maxlength={30} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Datos de Transporte -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="border-b pb-2 text-sm font-semibold">Datos de Transporte</h3>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="transporter_key">Clave Transportista</Label>
|
||||
<Input id="transporter_key" bind:value={formData.transporter_key} maxlength={23} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="transport_identifier">Identificador Transporte</Label>
|
||||
<Input
|
||||
id="transport_identifier"
|
||||
bind:value={formData.transport_identifier}
|
||||
maxlength={30}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="transport_type">Tipo de Transporte</Label>
|
||||
<Input
|
||||
id="transport_type"
|
||||
bind:value={formData.transport_type}
|
||||
maxlength={2}
|
||||
placeholder="2 car."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="sct_permission">Permiso SCT</Label>
|
||||
<Input id="sct_permission" bind:value={formData.sct_permission} maxlength={40} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Seguro y Otros -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="border-b pb-2 text-sm font-semibold">Seguro y Otros</h3>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="insurance_company_name">Aseguradora</Label>
|
||||
<Input
|
||||
id="insurance_company_name"
|
||||
bind:value={formData.insurance_company_name}
|
||||
maxlength={30}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="insurance_number">Póliza</Label>
|
||||
<Input id="insurance_number" bind:value={formData.insurance_number} maxlength={20} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="insurance_amount">Monto Seguro</Label>
|
||||
<Input id="insurance_amount" type="number" bind:value={formData.insurance_amount} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="dot_number">Número DOT</Label>
|
||||
<Input id="dot_number" bind:value={formData.dot_number} maxlength={8} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Ubicación y Detalles -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="border-b pb-2 text-sm font-semibold">Ubicación y Detalles</h3>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="state">Estado</Label>
|
||||
<Input id="state" bind:value={formData.state} maxlength={30} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="country">País</Label>
|
||||
<Input
|
||||
id="country"
|
||||
bind:value={formData.country}
|
||||
maxlength={3}
|
||||
placeholder="MEX / USA"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="description">Descripción</Label>
|
||||
<Input id="description" bind:value={formData.description} maxlength={100} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="color">Color</Label>
|
||||
<Input id="color" bind:value={formData.color} maxlength={20} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="container_key">Contenedor</Label>
|
||||
<Input
|
||||
id="container_key"
|
||||
bind:value={formData.container_key}
|
||||
maxlength={3}
|
||||
placeholder="3 car."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={handleCancel} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { vehiclesApi, type Vehicle } from '$lib/api/dashboard/a76/vehicles';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: Vehicle;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
let selectedItem = $state<Vehicle | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
if (
|
||||
!confirm(
|
||||
`¿Estás seguro de eliminar el vehículo "${item.vehicle_key}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
alert('❌ Error: No hay una compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await vehiclesApi.delete(item.vehicle_key, companyStore.activeCompany.id);
|
||||
|
||||
if (response.error) {
|
||||
if (response.status === 401) {
|
||||
error = 'Sesión expirada. Recargando página...';
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
error = response.error;
|
||||
alert(`❌ Error al eliminar:\n\n${response.error}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Éxito
|
||||
alert(`✅ Vehículo "${item.vehicle_key}" eliminado correctamente`);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al eliminar';
|
||||
alert(`❌ Error: ${error}`);
|
||||
console.error('Error deleting:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
selectedItem = item;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function handleDialogSuccess() {
|
||||
dialogOpen = false;
|
||||
selectedItem = null;
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="relative size-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical size={16} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-[160px]">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleEdit}>
|
||||
<Pencil size={16} class="mr-2" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleDelete} class="text-destructive" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 size={16} class="mr-2" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog bind:open={dialogOpen} item={selectedItem} onSuccess={handleDialogSuccess} />
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import { type ColumnDef, getCoreRowModel } from '@tanstack/table-core';
|
||||
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let { data, columns, pageCount, totalItems }: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() {
|
||||
return data;
|
||||
},
|
||||
get columns() {
|
||||
return columns;
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() {
|
||||
return pageCount;
|
||||
}
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border bg-card">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && 'selected'}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems}
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) - 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) + 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Settings2,
|
||||
Shield,
|
||||
Ship,
|
||||
Truck,
|
||||
Users,
|
||||
} from 'lucide-svelte';
|
||||
import * as m from "$lib/paraglide/messages.js";
|
||||
@@ -192,6 +193,7 @@ export function getSidebarData(): SidebarData {
|
||||
title: m["sidebar.general_catalogs.identifiers"](),
|
||||
url: "/dashboard/general_catalogs/identifiers",
|
||||
},
|
||||
// -------------------------------------
|
||||
{
|
||||
title: m["sidebar.general_catalogs.incoterms"](),
|
||||
url: "/dashboard/reference_data/incoterms",
|
||||
@@ -333,6 +335,25 @@ export function getSidebarData(): SidebarData {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Transportes",
|
||||
url: "#",
|
||||
icon: Truck,
|
||||
items: [
|
||||
{
|
||||
title: "Transportistas",
|
||||
url: "/dashboard/general_catalogs/transporters",
|
||||
},
|
||||
{
|
||||
title: "Trailers",
|
||||
url: "/dashboard/general_catalogs/trailers",
|
||||
},
|
||||
{
|
||||
title: "Vehículos",
|
||||
url: "/dashboard/general_catalogs/vehicles",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: m["sidebar.goods.title"](),
|
||||
url: "#",
|
||||
@@ -522,4 +543,4 @@ export function getSidebarData(): SidebarData {
|
||||
}
|
||||
|
||||
// Exportar también como constante para compatibilidad (deprecado)
|
||||
export const sidebarData: SidebarData = getSidebarData();
|
||||
export const sidebarData: SidebarData = getSidebarData();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
7
frontend/src/svelte-shims.d.ts
vendored
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user