feature/continuacion-vista-tablas-location

This commit is contained in:
hreyes
2026-03-12 16:14:34 -06:00
parent 678f91ecdc
commit 8d304fb98b
23 changed files with 1337 additions and 717 deletions

View File

@@ -1,3 +0,0 @@
"""
Módulo de localización
"""

View File

@@ -1,43 +0,0 @@
"""
DTOs (Data Transfer Objects) para módulo de localización
"""
from typing import Optional
from pydantic import BaseModel, Field
class LocationCreateDTO(BaseModel):
"""DTO para crear una localización"""
code: str = Field(..., max_length=5, description="Location code")
description: Optional[str] = Field(
None, max_length=200, description="Location description"
)
class Config:
from_attributes = True
class LocationUpdateDTO(BaseModel):
"""DTO para actualizar una localización"""
code: Optional[str] = Field(
None, max_length=5, description="Location code")
description: Optional[str] = Field(
None, max_length=200, description="Location description"
)
class Config:
from_attributes = True
class LocationResponseDTO(BaseModel):
"""DTO para responder con datos de una localización"""
id: int
code: str
description: Optional[str] = None
class Config:
from_attributes = True

View File

@@ -1,36 +0,0 @@
"""
Modelos ORM para gestión de localización
"""
from typing import Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import Integer, PrimaryKeyConstraint, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
class Location(Base, TenantScopedMixin, TimestampMixin):
"""
Modelo para la tabla Location - Localización
"""
__tablename__ = "location" # SLocalizacion
__table_args__ = (
PrimaryKeyConstraint("id", name="location_pkey"),
UniqueConstraint("code", name="location_code_unique"),
{"schema": "a24"},
)
# Primary key
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
# Location code (unique)
code: Mapped[str] = mapped_column(String(5), nullable=False, unique=True)
# Location description
description: Mapped[Optional[str]] = mapped_column(String(200))
def __repr__(self):
return f"<Location(id={self.id}, code={self.code}, description={self.description})>"

View File

@@ -1,136 +0,0 @@
"""
Rutas para gestión de localización
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from .dto import LocationCreateDTO, LocationResponseDTO, LocationUpdateDTO
from .models import Location
from .service import LocationService
router = APIRouter(prefix="/locations", tags=["locations"])
@router.get(
"",
response_model=dict,
summary="Get all locations",
)
async def get_all_locations(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
code: str = Query(None),
description: str = Query(None),
db: Session = Depends(get_core_db),
):
"""Get all locations with optional filtering and pagination"""
filters = {}
if code:
filters["code"] = code
if description:
filters["description"] = description
locations, total = LocationService.get_all(db, skip, limit, filters)
return {
"data": [LocationResponseDTO.model_validate(location) for location in locations],
"total": total,
"skip": skip,
"limit": limit,
}
@router.get(
"/{location_id}",
response_model=LocationResponseDTO,
summary="Get location by ID",
)
async def get_location(
location_id: int,
db: Session = Depends(get_core_db),
):
"""Get a location by its ID"""
location = LocationService.get_by_id(db, location_id)
if not location:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found",
)
return LocationResponseDTO.model_validate(location)
@router.get(
"/code/{code}",
response_model=LocationResponseDTO,
summary="Get location by code",
)
async def get_location_by_code(
code: str,
db: Session = Depends(get_core_db),
):
"""Get a location by its code"""
location = LocationService.get_by_code(db, code)
if not location:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found",
)
return LocationResponseDTO.model_validate(location)
@router.post(
"",
response_model=LocationResponseDTO,
status_code=status.HTTP_201_CREATED,
summary="Create location",
)
async def create_location(
location_data: LocationCreateDTO,
db: Session = Depends(get_core_db),
):
"""Create a new location"""
location = LocationService.create(db, location_data)
return LocationResponseDTO.model_validate(location)
@router.put(
"/{location_id}",
response_model=LocationResponseDTO,
summary="Update location",
)
async def update_location(
location_id: int,
location_data: LocationUpdateDTO,
db: Session = Depends(get_core_db),
):
"""Update a location"""
location = LocationService.update(db, location_id, location_data)
if not location:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found",
)
return LocationResponseDTO.model_validate(location)
@router.delete(
"/{location_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete location",
)
async def delete_location(
location_id: int,
db: Session = Depends(get_core_db),
):
"""Delete a location"""
success = LocationService.delete(db, location_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found",
)
return None

View File

@@ -1,136 +0,0 @@
"""
Capa de servicio para lógica de negocio de localización
"""
import logging
from typing import Any, Dict, List, Optional, Tuple
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .dto import LocationCreateDTO, LocationResponseDTO, LocationUpdateDTO
from .models import Location
logger = logging.getLogger(__name__)
class LocationService:
"""Servicio para gestión de localización"""
def __init__(self, db: Session):
self.db = db
@staticmethod
def get_all(
db: Session,
skip: int = 0,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[Location], int]:
"""Get all locations with pagination"""
query = db.query(Location)
if filters:
if filters.get("code"):
query = query.filter(
Location.code.ilike(f"%{filters['code']}%"))
if filters.get("description"):
query = query.filter(
Location.description.ilike(f"%{filters['description']}%")
)
total = query.count()
locations = query.offset(skip).limit(limit).all()
return locations, total
@staticmethod
def get_by_id(db: Session, location_id: int) -> Optional[Location]:
"""Get location by ID"""
return db.query(Location).filter(Location.id == location_id).first()
@staticmethod
def get_by_code(db: Session, code: str) -> Optional[Location]:
"""Get location by code"""
return db.query(Location).filter(Location.code == code).first()
@staticmethod
def create(db: Session, location_data: LocationCreateDTO) -> Location:
"""Create a new location"""
try:
db_location = Location(
**location_data.model_dump(exclude_unset=True))
db.add(db_location)
db.commit()
db.refresh(db_location)
return db_location
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError creating location: {str(e)}")
raise HTTPException(
status_code=400,
detail="Location code already exists",
)
except Exception as e:
db.rollback()
logger.error(f"Error creating location: {str(e)}")
raise HTTPException(
status_code=500, detail="Error creating location")
@staticmethod
def update(
db: Session, location_id: int, location_data: LocationUpdateDTO
) -> Optional[Location]:
"""Update a location"""
try:
db_location = db.query(Location).filter(
Location.id == location_id).first()
if not db_location:
return None
for key, value in location_data.model_dump(exclude_unset=True).items():
setattr(db_location, key, value)
db.commit()
db.refresh(db_location)
return db_location
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError updating location: {str(e)}")
raise HTTPException(
status_code=400,
detail="Error updating location",
)
except Exception as e:
db.rollback()
logger.error(f"Error updating location: {str(e)}")
raise HTTPException(
status_code=500, detail="Error updating location")
@staticmethod
def delete(db: Session, location_id: int) -> bool:
"""Delete a location"""
try:
db_location = db.query(Location).filter(
Location.id == location_id).first()
if not db_location:
return False
db.delete(db_location)
db.commit()
return True
except Exception as e:
db.rollback()
logger.error(f"Error deleting location: {str(e)}")
raise HTTPException(
status_code=500, detail="Error deleting location")

View File

@@ -0,0 +1 @@
# a76 general_catalogs.location

View File

@@ -0,0 +1,36 @@
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
from .models import LocationSystem
class LocationBase(BaseModel):
clave_localizacion: str = Field(
..., max_length=20, description="Clave/código de la localización"
)
localizacion: Optional[str] = Field(
None, max_length=200, description="Nombre o descripción"
)
system: LocationSystem = Field(
..., description="Contexto: fixed_asset (FA) o inventory"
)
class LocationCreate(LocationBase):
"""Optional extra fields for fixed_asset; used only when system == FIXED_ASSET."""
department: Optional[str] = Field(None, max_length=100)
responsible: Optional[str] = Field(None, max_length=200)
observations: Optional[str] = Field(None, description="Free text")
class LocationUpdate(BaseModel):
clave_localizacion: Optional[str] = Field(None, max_length=20)
localizacion: Optional[str] = Field(None, max_length=200)
system: Optional[LocationSystem] = None
class LocationResponse(LocationBase):
id: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,84 @@
"""
Modelo ORM para catálogo de localización (a76).
Tabla compartida para contexto Fixed Asset (FA) e inventory.
"""
import enum
from typing import Optional
from sqlalchemy import Integer, String, Text, UniqueConstraint, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class LocationSystem(str, enum.Enum):
"""Contexto de uso de la localización."""
FIXED_ASSET = "fixed_asset"
INVENTORY = "inventory"
class Location(Base, TenantScopedMixin, TimestampMixin):
"""
Catálogo de localización (clave + localizacion), compartido por FA e inventory.
"""
__tablename__ = "location"
__table_args__ = (
UniqueConstraint(
"clave_localizacion",
"tenant_id",
"company_id",
"system",
name="uq_location_clave_tenant_company_system",
),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True
)
clave_localizacion: Mapped[str] = mapped_column(String(20), nullable=False)
localizacion: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
system: Mapped[str] = mapped_column(
String(20), nullable=False
) # 'fixed_asset' | 'inventory'
def __repr__(self) -> str:
return (
f"<Location(id={self.id}, clave_localizacion={self.clave_localizacion!r}, "
f"localizacion={self.localizacion!r}, system={self.system!r})>"
)
class FaLocationExt(Base, TenantScopedMixin, TimestampMixin):
"""
Extra info for Fixed Asset locations only (1:1 with Location).
Table: Fa_Location_Ext (fa_location_ext).
"""
__tablename__ = "fa_location_ext"
__table_args__ = (
UniqueConstraint("location_id", name="uq_fa_location_ext_location_id"),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True
)
location_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("a76.location.id", ondelete="CASCADE"),
nullable=False,
unique=True,
)
department: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
responsible: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
observations: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
def __repr__(self) -> str:
return (
f"<FaLocationExt(id={self.id}, location_id={self.location_id}, "
f"department={self.department!r})>"
)

View File

@@ -0,0 +1,20 @@
from fastapi import APIRouter
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from .dto import LocationCreate, LocationResponse, LocationUpdate
from .models import Location
from .service import LocationService
router = TenantCRUDRoutes(
service=LocationService,
create_schema=LocationCreate,
update_schema=LocationUpdate,
response_schema=LocationResponse,
prefix="/locations",
tags=["a76.general_catalogs.locations"],
resource_name="Location",
enable_list=True,
enable_filters=True,
max_page_size=1000,
).router

View File

@@ -0,0 +1,154 @@
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from .models import Location, FaLocationExt
from .dto import LocationCreate, LocationUpdate
class LocationService:
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: Optional[int],
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[Location], int]:
query = db.query(Location).filter(Location.tenant_id == tenant_id)
if company_id is not None:
query = query.filter(Location.company_id == company_id)
if filters:
if filters.get("clave_localizacion"):
query = query.filter(
Location.clave_localizacion.ilike(
f"%{filters['clave_localizacion']}%"
)
)
if filters.get("localizacion"):
query = query.filter(
Location.localizacion.ilike(f"%{filters['localizacion']}%")
)
if filters.get("system"):
query = query.filter(Location.system == filters["system"])
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(
db: Session,
id: int,
tenant_id: int,
company_id: int,
) -> Optional[Location]:
return (
db.query(Location)
.filter(
Location.id == id,
Location.tenant_id == tenant_id,
Location.company_id == company_id,
)
.first()
)
@staticmethod
def get_by_clave(
db: Session,
clave_localizacion: str,
tenant_id: int,
company_id: int,
system: Optional[str] = None,
) -> Optional[Location]:
query = db.query(Location).filter(
Location.clave_localizacion == clave_localizacion,
Location.tenant_id == tenant_id,
Location.company_id == company_id,
)
if system is not None:
query = query.filter(Location.system == system)
return query.first()
@staticmethod
def create(
db: Session,
data: LocationCreate,
tenant_id: int,
company_id: int,
) -> Location:
db_obj = Location(
clave_localizacion=data.clave_localizacion,
localizacion=data.localizacion,
system=data.system.value,
tenant_id=tenant_id,
company_id=company_id,
)
db.add(db_obj)
try:
db.flush() # get db_obj.id without committing
if data.system.value == "fixed_asset" and (
data.department is not None
or data.responsible is not None
or data.observations is not None
):
ext = FaLocationExt(
location_id=db_obj.id,
department=data.department,
responsible=data.responsible,
observations=data.observations,
tenant_id=tenant_id,
company_id=company_id,
)
db.add(ext)
db.commit()
db.refresh(db_obj)
return db_obj
except IntegrityError:
db.rollback()
raise ValueError("Ya existe una localización con esa clave y system")
@staticmethod
def update(
db: Session,
id: int,
tenant_id: int,
data: LocationUpdate,
company_id: int,
) -> Optional[Location]:
db_obj = LocationService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return None
update_dict = data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
if key == "system" and value is not None:
setattr(db_obj, key, value.value)
else:
setattr(db_obj, key, value)
try:
db.commit()
db.refresh(db_obj)
return db_obj
except IntegrityError:
db.rollback()
raise ValueError("Ya existe una localización con esa clave y system")
@staticmethod
def delete(
db: Session,
id: int,
tenant_id: int,
company_id: int,
) -> bool:
db_obj = LocationService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return False
db.delete(db_obj)
db.commit()
return True

View File

@@ -25,12 +25,14 @@ from .error_catalogs.routes import router as error_catalogs_router
from .doda.routes import router as doda_router
from .prevalidators.routes import router as prevalidators_router
from .electronic_notices.routes import router as electronic_notices_router
from .location.routes import router as location_router
router = APIRouter()
router.include_router(company_router, tags=["a76 / company"])
router.include_router(package_router)
router.include_router(ports_router)
router.include_router(location_router)
router.include_router(tariff_fractions_router)
router.include_router(us_tariff_fractions_router)
router.include_router(historical_tariff_fractions_router, prefix="/fractions/historical-tariff-fractions", tags=["a76 / historical_tariff_fractions"])

View File

@@ -47,6 +47,7 @@ from api.v1.modules.a76.general_catalogs.legends.models import Legend
from api.v1.modules.a76.general_catalogs.multi_currency_types.models import MultiCurrencyType
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.a76.general_catalogs.ports.models import Port
from api.v1.modules.a76.general_catalogs.location.models import Location, FaLocationExt
from api.v1.modules.a76.general_catalogs.prevalidators.models import Prevalidator
from api.v1.modules.a76.general_catalogs.seal.models import Seal
from api.v1.modules.a76.general_catalogs.signatures.models import Signature
@@ -255,6 +256,7 @@ from api.v1.modules.a76.general_catalogs.multi_currency_types.models import (
)
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.a76.general_catalogs.ports.models import Port
from api.v1.modules.a76.general_catalogs.location.models import Location, FaLocationExt
from api.v1.modules.a76.general_catalogs.prevalidators.models import Prevalidator
from api.v1.modules.a76.general_catalogs.seal.models import Seal
from api.v1.modules.a76.general_catalogs.signatures.models import Signature

View File

@@ -1,64 +1,84 @@
import type { PaginatedResponse } from '$lib/types';
import { api } from '$lib/api';
/** Contexto de uso: Fixed Asset (activo fijo) o inventario */
export type LocationSystem = 'fixed_asset' | 'inventory';
export interface Location {
id: number;
location_code: string;
location_description: string | null;
company_id: number;
tenant_id: number;
clave_localizacion: string;
localizacion: string | null;
system: LocationSystem;
}
export interface LocationCreate {
location_code: string;
location_description?: string | null;
clave_localizacion: string;
localizacion?: string | null;
system: LocationSystem;
/** Used when system === 'fixed_asset' */
department?: string | null;
responsible?: string | null;
observations?: string | null;
}
export interface LocationUpdate {
location_description?: string | null;
clave_localizacion?: string;
localizacion?: string | null;
system?: LocationSystem;
}
export interface LocationListResponse extends PaginatedResponse {
export interface LocationListResponse {
items: Location[];
total: number;
page: number;
page_size: number;
}
export interface LocationFilters {
location_code?: string;
location_description?: string;
clave_localizacion?: string;
localizacion?: string;
system?: LocationSystem;
page?: number;
page_size?: number;
}
import { portsApi, PortType } from './ports';
const BASE_URL = '/v1/a76/locations';
function buildQuery(companyId: number, params?: LocationFilters): string {
const search = new URLSearchParams();
search.set('company_id', String(companyId));
if (params?.page != null) search.set('page', String(params.page));
if (params?.page_size != null) search.set('page_size', String(params.page_size));
if (params?.clave_localizacion) search.set('clave_localizacion', params.clave_localizacion);
if (params?.localizacion) search.set('localizacion', params.localizacion);
if (params?.system) search.set('system', params.system);
return search.toString();
}
export async function getLocations(
companyId: number,
filters?: LocationFilters
): Promise<LocationListResponse> {
const res = await portsApi.list(companyId, filters || {});
return (res.data || res) as unknown as LocationListResponse;
const q = buildQuery(companyId, filters);
const res = await api.get<LocationListResponse>(`${BASE_URL}/?${q}`);
return (res.data ?? res) as LocationListResponse;
}
export async function getLocation(
locationId: number,
companyId: number
): Promise<Location> {
const res = await portsApi.get(locationId, companyId);
return (res.data || res) as unknown as Location;
const q = new URLSearchParams({ company_id: String(companyId) });
const res = await api.get<Location>(`${BASE_URL}/${locationId}?${q}`);
return (res.data ?? res) as Location;
}
export async function createLocation(
data: LocationCreate,
companyId: number
): Promise<Location> {
const res = await portsApi.create({
port_code: data.location_code,
location_code: data.location_code,
description: null,
location_description: data.location_description || null,
port_type: PortType.ENTRY
}, companyId);
return (res.data || res) as unknown as Location;
const q = new URLSearchParams({ company_id: String(companyId) });
const res = await api.post<Location>(`${BASE_URL}/?${q}`, data);
return (res.data ?? res) as Location;
}
export async function updateLocation(
@@ -66,15 +86,15 @@ export async function updateLocation(
data: LocationUpdate,
companyId: number
): Promise<Location> {
const res = await portsApi.update(locationId, {
location_description: data.location_description
}, companyId);
return (res.data || res) as unknown as Location;
const q = new URLSearchParams({ company_id: String(companyId) });
const res = await api.put<Location>(`${BASE_URL}/${locationId}/?${q}`, data);
return (res.data ?? res) as Location;
}
export async function deleteLocation(
locationId: number,
companyId: number
): Promise<void> {
await portsApi.delete(locationId, companyId);
}
const q = new URLSearchParams({ company_id: String(companyId) });
await api.delete(`${BASE_URL}/${locationId}?${q}`);
}

View File

@@ -6,7 +6,8 @@
import {
createLocation,
updateLocation,
type Location
type Location,
type LocationSystem
} from '$lib/api/dashboard/a76/general_catalogs/locations';
import { companyStore } from '$lib/stores/company.svelte';
import { obtenerAtajosFormularioLocalidades } from '$lib/config/shortcuts/dashboard/general_catalogs/locations/edit';
@@ -21,14 +22,18 @@
onSuccess?: () => void;
} = $props();
// Atajos
const isEdit = $derived(!!item);
const title = $derived(isEdit ? 'Editar Ubicación' : 'Nueva Ubicación');
const systemOptions: { value: LocationSystem; label: string }[] = [
{ value: 'fixed_asset', label: 'Activo fijo (FA)' },
{ value: 'inventory', label: 'Inventario' }
];
let formData = $state({
location_code: '',
location_description: ''
clave_localizacion: '',
localizacion: '',
system: 'fixed_asset' as LocationSystem
});
let loading = $state(false);
@@ -38,11 +43,16 @@
if (open) {
if (item) {
formData = {
location_code: item.location_code || '',
location_description: item.location_description || ''
clave_localizacion: item.clave_localizacion ?? '',
localizacion: item.localizacion ?? '',
system: item.system ?? 'fixed_asset'
};
} else {
formData = { location_code: '', location_description: '' };
formData = {
clave_localizacion: '',
localizacion: '',
system: 'fixed_asset'
};
}
error = null;
}
@@ -55,20 +65,31 @@
const companyId = companyStore.activeCompany?.id;
if (!companyId) throw new Error('No hay una compañía seleccionada');
if (!formData.location_code.trim()) throw new Error('El código es requerido');
if (!formData.clave_localizacion.trim()) throw new Error('La clave es requerida');
const basePayload = {
location_description: formData.location_description?.trim() || null
localizacion: formData.localizacion?.trim() || null
};
if (isEdit && item) {
await updateLocation(item.id, basePayload, companyId);
await updateLocation(
item.id,
{
...basePayload,
clave_localizacion: formData.clave_localizacion.trim(),
system: formData.system
},
companyId
);
} else {
const createPayload = {
location_code: formData.location_code.trim(),
...basePayload
};
await createLocation(createPayload, companyId);
await createLocation(
{
clave_localizacion: formData.clave_localizacion.trim(),
...basePayload,
system: formData.system
},
companyId
);
}
open = false;
@@ -107,12 +128,12 @@
<div class="grid gap-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="location_code" class="text-right">Código *</Label>
<Label for="clave_localizacion" class="text-right">Clave *</Label>
<div class="col-span-3">
<Input
id="location_code"
bind:value={formData.location_code}
maxlength={4}
id="clave_localizacion"
bind:value={formData.clave_localizacion}
maxlength={20}
disabled={loading || isEdit}
required
/>
@@ -120,16 +141,32 @@
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="location_description" class="text-right">Descripción</Label>
<Label for="localizacion" class="text-right">Localización</Label>
<div class="col-span-3">
<Input
id="location_description"
bind:value={formData.location_description}
maxlength={20}
id="localizacion"
bind:value={formData.localizacion}
maxlength={200}
disabled={loading}
/>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="system" class="text-right">Sistema</Label>
<div class="col-span-3">
<select
id="system"
bind:value={formData.system}
disabled={loading}
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{#each systemOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
</div>
</div>
<Dialog.Footer>

View File

@@ -10,10 +10,12 @@
let {
open = $bindable(false),
regimen = 'Temporal',
operationType = 'imp' as 'imp' | 'exp',
onSelect
}: {
open: boolean;
regimen: string;
regimen?: string;
operationType?: 'imp' | 'exp';
onSelect: (invoice: Invoice) => void;
} = $props();
@@ -25,15 +27,16 @@
if (!companyStore.activeCompany) return;
loading = true;
try {
let filters: any = {
operation_type: 'imp',
invoice_number: searchTerm
const filters: any = {
operation_type: operationType,
invoice_number: searchTerm || undefined
};
if (regimen === 'Temporal' || regimen === 'TEMPORAL SCAF') {
filters.invoice_type = 'TEM';
} else if (regimen === 'Definitiva' || regimen === 'DEFINITIVO SCAF') {
filters.invoice_type = 'DEF';
if (operationType === 'imp' && regimen) {
if (regimen === 'Temporal' || regimen === 'TEMPORAL SCAF') {
filters.invoice_type = 'TEM';
} else if (regimen === 'Definitiva' || regimen === 'DEFINITIVO SCAF') {
filters.invoice_type = 'DEF';
}
}
const res = await invoicesApi.list(companyStore.activeCompany.id, 1, 50, filters);
@@ -60,12 +63,18 @@
});
</script>
<Dialog.Root bind:open>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title>Seleccionar Factura ({regimen})</Dialog.Title>
<Dialog.Title>
{operationType === 'exp' ? 'Seleccionar Factura de Exportación' : `Seleccionar Factura (${regimen})`}
</Dialog.Title>
<Dialog.Description>
Busca y selecciona una factura del catálogo de importación para el régimen {regimen}.
{#if operationType === 'exp'}
Busca y selecciona una factura del catálogo de exportación.
{:else}
Busca y selecciona una factura del catálogo de importación para el régimen {regimen}.
{/if}
</Dialog.Description>
</Dialog.Header>

View File

@@ -1,13 +1,21 @@
<script lang="ts">
import * as Sheet from '$lib/components/ui/sheet';
import * as Tabs from '$lib/components/ui/tabs';
import * as Select from '$lib/components/ui/select';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { RadioGroup, RadioGroupItem } from '$lib/components/ui/radio-group';
import { Separator } from '$lib/components/ui/separator';
import { Loader2, Package, Save, X, FileText } from 'lucide-svelte';
import { Loader2, Package, Save, X, FileText, Folder } from 'lucide-svelte';
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
import type { Item } from '$lib/api/dashboard/a76/items';
import { invoicesApi } from '$lib/api/dashboard/a76/invoices';
import { itemsApi, type Item } from '$lib/api/dashboard/a76/items';
import { companyStore } from '$lib/stores/company.svelte';
// Child components
import InvoiceSelectorModal from '../../InvoiceSelectorModal.svelte';
import MainData from './main-data.svelte';
import ItemConfiguration from './item-configuration.svelte';
import PackagesSection from './packages-section.svelte';
@@ -63,15 +71,64 @@
}
});
const visibility = $derived.by(() => getVisibility(invoiceType ?? invoice?.invoice_type, operationType ?? invoice?.operation_type));
const showCrTrackingBlock = $derived.by(() => {
const normalizedOperationType = operationType ?? invoice?.operation_type;
if (normalizedOperationType === 1 || normalizedOperationType === 'exp') {
return false;
}
// Selectores de factura (FK): estado y líneas
let showImportInvoiceModal = $state(false);
let showExportInvoiceModal = $state(false);
let showExportLinePicker = $state(false);
let showImportLinePicker = $state(false);
let selectedImportInvoiceId = $state<number | null>(null);
let selectedExportInvoiceId = $state<number | null>(null);
let importInvoiceLines = $state<Item[]>([]);
let exportInvoiceLines = $state<Item[]>([]);
let loadingImportLines = $state(false);
let loadingExportLines = $state(false);
return visibility.showCrTrackingHeader;
});
async function loadImportLines(invoiceId: number) {
const companyId = companyStore?.activeCompany?.id;
if (!companyId) return;
loadingImportLines = true;
try {
const res = await itemsApi.listByInvoice(invoiceId, companyId);
importInvoiceLines = res.data?.items ?? [];
} catch {
importInvoiceLines = [];
} finally {
loadingImportLines = false;
}
}
async function loadExportLines(invoiceId: number) {
const companyId = companyStore?.activeCompany?.id;
if (!companyId) return;
loadingExportLines = true;
try {
const res = await itemsApi.listByInvoice(invoiceId, companyId);
exportInvoiceLines = res.data?.items ?? [];
} catch {
exportInvoiceLines = [];
} finally {
loadingExportLines = false;
}
}
function handleSelectImportInvoice(inv: Invoice) {
editingItem.fa_data = editingItem.fa_data || {};
editingItem.fa_data.search_invoice = inv.invoice_number ?? '';
editingItem.fa_data.movement_type_import = (inv.invoice_type as string) || 'TEM';
editingItem.fa_data.search_line = undefined;
selectedImportInvoiceId = inv.id ?? null;
if (selectedImportInvoiceId) loadImportLines(selectedImportInvoiceId);
}
function handleSelectExportInvoice(inv: Invoice) {
editingItem.fa_data = editingItem.fa_data || {};
editingItem.fa_data.search_invoice = inv.invoice_number ?? '';
editingItem.fa_data.search_line = undefined;
selectedExportInvoiceId = inv.id ?? null;
if (selectedExportInvoiceId) loadExportLines(selectedExportInvoiceId);
}
const visibility = $derived.by(() => getVisibility(invoiceType ?? invoice?.invoice_type, operationType ?? invoice?.operation_type));
/** Show link-to-import block for import (CR tracking) or for export when showExportLinkToImportBlock. */
const showLinkToImportBlock = $derived.by(() => {
const normalizedOperationType = operationType ?? invoice?.operation_type;
@@ -80,16 +137,81 @@
}
return visibility.showCrTrackingHeader;
});
const isExport = $derived.by(() => {
const op = operationType ?? invoice?.operation_type;
return op === 1 || op === 'exp';
});
/** Show repair-import block (Factura de Expo / Línea de Expo) for REP/REPAR. */
const showRepairBlock = $derived.by(() => {
const op = operationType ?? invoice?.operation_type;
if (op === 1 || op === 'exp') return false;
return visibility.showRepairLinkToExportBlock;
});
// Resolver invoice id desde search_invoice al abrir el sheet (para cargar líneas)
$effect(() => {
if (!open || !companyStore?.activeCompany?.id || !editingItem?.fa_data) return;
const num = editingItem.fa_data.search_invoice;
const movementType = editingItem.fa_data.movement_type_import;
if (showLinkToImportBlock && num && !selectedImportInvoiceId && !loadingImportLines) {
(async () => {
try {
const res = await invoicesApi.list(companyStore.activeCompany!.id, 1, 5, {
operation_type: 'imp',
invoice_number: num,
invoice_type: movementType === 'DEF' ? 'DEF' : 'TEM'
});
const items = res.data?.items ?? [];
const inv = items.find((i: Invoice) => i.invoice_number === num);
if (inv?.id) {
selectedImportInvoiceId = inv.id;
await loadImportLines(inv.id);
}
} catch {
// ignore
}
})();
}
if (showRepairBlock && num && !selectedExportInvoiceId && !loadingExportLines) {
(async () => {
try {
const res = await invoicesApi.list(companyStore.activeCompany!.id, 1, 5, {
operation_type: 'exp',
invoice_number: num
});
const items = res.data?.items ?? [];
const inv = items.find((i: Invoice) => i.invoice_number === num);
if (inv?.id) {
selectedExportInvoiceId = inv.id;
await loadExportLines(inv.id);
}
} catch {
// ignore
}
})();
}
});
$effect(() => {
if (!open) {
selectedImportInvoiceId = null;
selectedExportInvoiceId = null;
importInvoiceLines = [];
exportInvoiceLines = [];
}
});
const importRegimenLabel = $derived(
(editingItem.fa_data?.movement_type_import?.toUpperCase() === 'DEF' ? 'Definitiva' : 'Temporal')
);
const showCrTrackingBlock = $derived.by(() => {
const normalizedOperationType = operationType ?? invoice?.operation_type;
if (normalizedOperationType === 1 || normalizedOperationType === 'exp') {
return false;
}
return visibility.showCrTrackingHeader;
});
const isExport = $derived.by(() => {
const op = operationType ?? invoice?.operation_type;
return op === 1 || op === 'exp';
});
const visibleTabs = $derived.by(() => [
{ value: 'generales', label: 'General', visible: true },
{ value: 'continuacion', label: 'Continuación', visible: true },
@@ -137,114 +259,207 @@
{#if line}
{#if showRepairBlock}
<!-- Importación de Reparación: Genera Descarga? + Factura de Expo + Línea de Expo -->
<div class="space-y-2 rounded border border-zinc-200 bg-white p-2 dark:border-zinc-800 dark:bg-zinc-900">
<div class="flex items-center gap-4">
<span class="text-[10px] font-semibold uppercase tracking-wide text-zinc-500">Genera Descarga?</span>
<label class="flex items-center gap-1.5">
<input type="radio" name="fa_rep_download" value="si" checked={editingItem.fa_data?.download === true} onchange={() => (editingItem.fa_data = editingItem.fa_data || {}, editingItem.fa_data.download = true)} class="rounded border-input" />
<span class="text-xs"></span>
</label>
<label class="flex items-center gap-1.5">
<input type="radio" name="fa_rep_download" value="no" checked={editingItem.fa_data?.download === false || editingItem.fa_data?.download === undefined} onchange={() => (editingItem.fa_data = editingItem.fa_data || {}, editingItem.fa_data.download = false)} class="rounded border-input" />
<span class="text-xs">No</span>
</label>
<div class="space-y-3 rounded-md border border-zinc-200 bg-white p-3 dark:border-zinc-800 dark:bg-zinc-900">
<div class="space-y-1.5">
<Label class="text-xs font-medium text-muted-foreground">Genera Descarga?</Label>
<RadioGroup
value={editingItem.fa_data?.download === true ? 'si' : 'no'}
onValueChange={(v) => {
editingItem.fa_data = editingItem.fa_data || {};
editingItem.fa_data.download = v === 'si';
}}
class="flex gap-4"
>
<div class="flex items-center gap-2">
<RadioGroupItem value="si" id="fa_rep_download_si" />
<Label for="fa_rep_download_si" class="text-xs cursor-pointer"></Label>
</div>
<div class="flex items-center gap-2">
<RadioGroupItem value="no" id="fa_rep_download_no" />
<Label for="fa_rep_download_no" class="text-xs cursor-pointer">No</Label>
</div>
</RadioGroup>
</div>
<div class="grid grid-cols-1 gap-2 lg:grid-cols-4">
<div class="rounded border border-zinc-200 bg-white p-2 dark:border-zinc-800 dark:bg-zinc-900">
<label for="fa_rep_search_invoice" class="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-zinc-500">
Factura de Expo
</label>
<input
id="fa_rep_search_invoice"
type="text"
bind:value={editingItem.fa_data.search_invoice}
class="flex h-8 w-full rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
<!-- Fila ligada: selector de factura (FK) + línea (FK), estilo Input + Folder -->
<div class="flex flex-wrap items-end gap-3">
<div class="min-w-[120px] flex-1 space-y-1">
<Label class="text-xs">Factura de Expo</Label>
<div class="flex gap-1">
<Input
readonly
value={editingItem.fa_data?.search_invoice || ''}
class="h-8 flex-1 bg-muted cursor-pointer text-sm"
placeholder="Seleccionar factura..."
onclick={() => (showExportInvoiceModal = true)}
/>
<Button
type="button"
variant="outline"
size="icon"
class="h-8 w-8 shrink-0"
onclick={() => (showExportInvoiceModal = true)}
>
<Folder class="h-4 w-4" />
</Button>
</div>
</div>
<div class="rounded border border-zinc-200 bg-white p-2 dark:border-zinc-800 dark:bg-zinc-900">
<label for="fa_rep_search_line" class="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-zinc-500">
Línea de Expo
</label>
<input
id="fa_rep_search_line"
type="number"
min="0"
bind:value={editingItem.fa_data.search_line}
class="flex h-8 w-full rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
<div class="min-w-[90px] flex-1 space-y-1">
<Label for="fa_rep_search_line" class="text-xs">Línea de Expo</Label>
<div class="flex gap-1">
<Input
id="fa_rep_search_line"
readonly
value={editingItem.fa_data?.search_line != null ? String(editingItem.fa_data.search_line) : ''}
class="h-8 flex-1 bg-muted cursor-pointer text-sm"
placeholder={loadingExportLines ? 'Cargando...' : 'Línea'}
disabled={!selectedExportInvoiceId || loadingExportLines}
onclick={() => selectedExportInvoiceId && !loadingExportLines && (showExportLinePicker = true)}
/>
<Button
type="button"
variant="outline"
size="icon"
class="h-8 w-8 shrink-0"
disabled={!selectedExportInvoiceId || loadingExportLines}
onclick={() => (showExportLinePicker = true)}
>
<Folder class="h-4 w-4" />
</Button>
</div>
</div>
<div class="rounded border border-zinc-200 bg-white p-2 dark:border-zinc-800 dark:bg-zinc-900">
<label for="fa_rep_search_type" class="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-zinc-500">
Tipo Búsqueda
</label>
<input
id="fa_rep_search_type"
type="text"
bind:value={editingItem.fa_data.search_type}
class="flex h-8 w-full rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
<div class="min-w-[100px] flex-1 space-y-1">
<Label for="fa_rep_search_type" class="text-xs">Tipo Búsqueda</Label>
<Select.Root
type="single"
value={editingItem.fa_data?.search_type || ''}
onValueChange={(v) => {
editingItem.fa_data = editingItem.fa_data || {};
editingItem.fa_data.search_type = v ?? undefined;
}}
>
<Select.Trigger id="fa_rep_search_type" class="h-8 text-sm">
<span class="truncate">
{editingItem.fa_data?.search_type || 'Seleccionar...'}
</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="Factura">Factura</Select.Item>
<Select.Item value="NumParte">NumParte</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
</div>
{:else if showLinkToImportBlock}
<div class="space-y-2 rounded border border-zinc-200 bg-white p-2 dark:border-zinc-800 dark:bg-zinc-900">
<!-- Genera Descarga? visible when block is shown (expo or import CR per legacy). -->
<div class="flex items-center gap-4">
<span class="text-[10px] font-semibold uppercase tracking-wide text-zinc-500">Genera Descarga?</span>
<label class="flex items-center gap-1.5">
<input type="radio" name="fa_download" value="si" checked={editingItem.fa_data?.download === true} onchange={() => (editingItem.fa_data = editingItem.fa_data || {}, editingItem.fa_data.download = true)} class="rounded border-input" />
<span class="text-xs"></span>
</label>
<label class="flex items-center gap-1.5">
<input type="radio" name="fa_download" value="no" checked={editingItem.fa_data?.download === false || editingItem.fa_data?.download === undefined} onchange={() => (editingItem.fa_data = editingItem.fa_data || {}, editingItem.fa_data.download = false)} class="rounded border-input" />
<span class="text-xs">No</span>
</label>
<div class="space-y-3 rounded-md border border-zinc-200 bg-white p-3 dark:border-zinc-800 dark:bg-zinc-900">
<div class="space-y-1.5">
<Label class="text-xs font-medium text-muted-foreground">Genera Descarga?</Label>
<RadioGroup
value={editingItem.fa_data?.download === true ? 'si' : 'no'}
onValueChange={(v) => {
editingItem.fa_data = editingItem.fa_data || {};
editingItem.fa_data.download = v === 'si';
}}
class="flex gap-4"
>
<div class="flex items-center gap-2">
<RadioGroupItem value="si" id="fa_download_si" />
<Label for="fa_download_si" class="text-xs cursor-pointer"></Label>
</div>
<div class="flex items-center gap-2">
<RadioGroupItem value="no" id="fa_download_no" />
<Label for="fa_download_no" class="text-xs cursor-pointer">No</Label>
</div>
</RadioGroup>
</div>
<div class="grid grid-cols-1 gap-2 lg:grid-cols-4">
<div class="rounded border border-zinc-200 bg-white p-2 dark:border-zinc-800 dark:bg-zinc-900">
<label for="fa_search_invoice" class="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-zinc-500">
Factura Impo
</label>
<input
id="fa_search_invoice"
type="text"
bind:value={editingItem.fa_data.search_invoice}
class="flex h-8 w-full rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
<!-- Fila ligada: selector de factura (FK) + línea (FK) -->
<div class="flex flex-wrap items-end gap-3">
<div class="min-w-[100px] flex-1 space-y-1">
<Label class="text-xs">Tipo Importación</Label>
<Select.Root
type="single"
value={editingItem.fa_data?.movement_type_import || 'TEM'}
onValueChange={(v) => {
editingItem.fa_data = editingItem.fa_data || {};
editingItem.fa_data.movement_type_import = v || 'TEM';
selectedImportInvoiceId = null;
importInvoiceLines = [];
}}
>
<Select.Trigger class="h-8 text-sm">
<span>{editingItem.fa_data?.movement_type_import === 'DEF' ? 'DEF' : 'TEM'}</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="TEM">TEM (Temporal)</Select.Item>
<Select.Item value="DEF">DEF (Definitiva)</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="rounded border border-zinc-200 bg-white p-2 dark:border-zinc-800 dark:bg-zinc-900">
<label for="fa_search_line" class="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-zinc-500">
Línea
</label>
<input
id="fa_search_line"
type="number"
min="0"
bind:value={editingItem.fa_data.search_line}
class="flex h-8 w-full rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
<div class="min-w-[120px] flex-1 space-y-1">
<Label class="text-xs">Factura Impo</Label>
<div class="flex gap-1">
<Input
readonly
value={editingItem.fa_data?.search_invoice || ''}
class="h-8 flex-1 bg-muted cursor-pointer text-sm"
placeholder="Seleccionar factura..."
onclick={() => (showImportInvoiceModal = true)}
/>
<Button
type="button"
variant="outline"
size="icon"
class="h-8 w-8 shrink-0"
onclick={() => (showImportInvoiceModal = true)}
>
<Folder class="h-4 w-4" />
</Button>
</div>
</div>
<div class="rounded border border-zinc-200 bg-white p-2 dark:border-zinc-800 dark:bg-zinc-900">
<label for="fa_search_type" class="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-zinc-500">
Tipo Búsqueda
</label>
<input
id="fa_search_type"
type="text"
bind:value={editingItem.fa_data.search_type}
class="flex h-8 w-full rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
<div class="min-w-[90px] flex-1 space-y-1">
<Label for="fa_search_line" class="text-xs">Línea</Label>
<div class="flex gap-1">
<Input
id="fa_search_line"
readonly
value={editingItem.fa_data?.search_line != null ? String(editingItem.fa_data.search_line) : ''}
class="h-8 flex-1 bg-muted cursor-pointer text-sm"
placeholder={loadingImportLines ? 'Cargando...' : 'Línea'}
disabled={!selectedImportInvoiceId || loadingImportLines}
onclick={() => selectedImportInvoiceId && !loadingImportLines && (showImportLinePicker = true)}
/>
<Button
type="button"
variant="outline"
size="icon"
class="h-8 w-8 shrink-0"
disabled={!selectedImportInvoiceId || loadingImportLines}
onclick={() => (showImportLinePicker = true)}
>
<Folder class="h-4 w-4" />
</Button>
</div>
</div>
<div class="rounded border border-zinc-200 bg-white p-2 dark:border-zinc-800 dark:bg-zinc-900">
<label for="fa_movement_type_import" class="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-zinc-500">
Tipo Importación / Tipo Movimiento
</label>
<input
id="fa_movement_type_import"
type="text"
bind:value={editingItem.fa_data.movement_type_import}
class="flex h-8 w-full rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
<div class="min-w-[100px] flex-1 space-y-1">
<Label for="fa_search_type" class="text-xs">Tipo Búsqueda</Label>
<Select.Root
type="single"
value={editingItem.fa_data?.search_type || ''}
onValueChange={(v) => {
editingItem.fa_data = editingItem.fa_data || {};
editingItem.fa_data.search_type = v ?? undefined;
}}
>
<Select.Trigger id="fa_search_type" class="h-8 text-sm">
<span class="truncate">
{editingItem.fa_data?.search_type || 'Seleccionar...'}
</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="Factura">Factura</Select.Item>
<Select.Item value="NumParte">NumParte</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
</div>
@@ -367,4 +582,66 @@
</footer>
</Sheet.Content>
</Sheet.Root>
</Sheet.Root>
<InvoiceSelectorModal
bind:open={showImportInvoiceModal}
regimen={importRegimenLabel}
operationType="imp"
onSelect={handleSelectImportInvoice}
/>
<InvoiceSelectorModal
bind:open={showExportInvoiceModal}
operationType="exp"
onSelect={handleSelectExportInvoice}
/>
<!-- Diálogo para elegir línea (Expo) -->
<Dialog.Root bind:open={showExportLinePicker}>
<Dialog.Content class="max-w-sm">
<Dialog.Header>
<Dialog.Title class="text-sm">Seleccionar línea</Dialog.Title>
</Dialog.Header>
<div class="max-h-[280px] overflow-y-auto py-2">
{#each exportInvoiceLines as lineItem}
{@const num = lineItem.line_number ?? lineItem.id}
<button
type="button"
class="w-full px-3 py-2 text-left text-sm hover:bg-muted rounded-md"
onclick={() => {
editingItem.fa_data = editingItem.fa_data || {};
editingItem.fa_data.search_line = typeof num === 'number' ? num : undefined;
showExportLinePicker = false;
}}
>
Línea {num}
</button>
{/each}
</div>
</Dialog.Content>
</Dialog.Root>
<!-- Diálogo para elegir línea (Impo) -->
<Dialog.Root bind:open={showImportLinePicker}>
<Dialog.Content class="max-w-sm">
<Dialog.Header>
<Dialog.Title class="text-sm">Seleccionar línea</Dialog.Title>
</Dialog.Header>
<div class="max-h-[280px] overflow-y-auto py-2">
{#each importInvoiceLines as lineItem}
{@const num = lineItem.line_number ?? lineItem.id}
<button
type="button"
class="w-full px-3 py-2 text-left text-sm hover:bg-muted rounded-md"
onclick={() => {
editingItem.fa_data = editingItem.fa_data || {};
editingItem.fa_data.search_line = typeof num === 'number' ? num : undefined;
showImportLinePicker = false;
}}
>
Línea {num}
</button>
{/each}
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,309 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Loader2, Search, Plus, ArrowLeft } from 'lucide-svelte';
import { companyStore } from '$lib/stores/company.svelte';
import {
getLocations,
createLocation,
type Location,
type LocationSystem
} from '$lib/api/dashboard/a76/general_catalogs/locations';
let {
open = $bindable(false),
system = 'fixed_asset' as LocationSystem,
onSelect
}: {
open: boolean;
system?: LocationSystem;
onSelect: (location: Location) => void;
} = $props();
let locations: Location[] = $state([]);
let filtered: Location[] = $state([]);
let loading = $state(false);
let searchTerm = $state('');
let error = $state('');
let showRegisterForm = $state(false);
// Register form state
let formData = $state({
clave_localizacion: '',
localizacion: '',
department: '',
responsible: '',
observations: ''
});
let formLoading = $state(false);
let formError = $state('');
async function loadLocations() {
const companyId = companyStore?.activeCompany?.id;
if (!companyId) {
error = 'No hay compañía seleccionada';
return;
}
loading = true;
error = '';
try {
const res = await getLocations(companyId, {
system,
page_size: 500
});
locations = res.items ?? [];
filtered = locations;
} catch (e) {
error = 'Error al cargar ubicaciones';
console.error(e);
locations = [];
filtered = [];
} finally {
loading = false;
}
}
function applyFilter() {
if (!searchTerm.trim()) {
filtered = locations;
} else {
const t = searchTerm.toLowerCase();
filtered = locations.filter(
(loc) =>
(loc.clave_localizacion ?? '').toLowerCase().includes(t) ||
(loc.localizacion ?? '').toLowerCase().includes(t)
);
}
}
function handleSelect(loc: Location) {
onSelect(loc);
open = false;
}
function openRegisterForm() {
showRegisterForm = true;
formData = {
clave_localizacion: '',
localizacion: '',
department: '',
responsible: '',
observations: ''
};
formError = '';
}
function closeRegisterForm() {
showRegisterForm = false;
formError = '';
}
async function handleRegisterSubmit() {
const companyId = companyStore?.activeCompany?.id;
if (!companyId) {
formError = 'No hay compañía seleccionada';
return;
}
if (!formData.clave_localizacion.trim()) {
formError = 'La clave es requerida';
return;
}
formLoading = true;
formError = '';
try {
const created = await createLocation(
{
clave_localizacion: formData.clave_localizacion.trim(),
localizacion: formData.localizacion?.trim() || null,
system,
department: formData.department?.trim() || null,
responsible: formData.responsible?.trim() || null,
observations: formData.observations?.trim() || null
},
companyId
);
onSelect(created);
open = false;
} catch (e) {
formError = e instanceof Error ? e.message : 'Error al guardar';
} finally {
formLoading = false;
}
}
$effect(() => {
if (open && !showRegisterForm) loadLocations();
});
$effect(() => {
searchTerm;
applyFilter();
});
$effect(() => {
if (!open) showRegisterForm = false;
});
</script>
<Dialog.Root bind:open>
<Dialog.Content class="!max-w-[60vw] w-[60vw] max-h-[90vh] p-0 flex flex-col">
<Dialog.Header class="px-6 py-4 border-b">
<Dialog.Title class="text-lg font-semibold">Catálogo de ubicaciones (maquinaria y equipo)</Dialog.Title>
</Dialog.Header>
{#if showRegisterForm}
<!-- Registrar nueva ubicación (formulario) -->
<form
class="flex flex-col flex-1 overflow-hidden"
onsubmit={(e) => {
e.preventDefault();
handleRegisterSubmit();
}}
>
<div class="flex-1 overflow-auto px-6 py-4 space-y-4">
{#if formError}
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{formError}
</div>
{/if}
<div class="grid gap-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="reg-clave" class="text-right">Clave *</Label>
<div class="col-span-3">
<Input
id="reg-clave"
bind:value={formData.clave_localizacion}
maxlength={20}
placeholder="Clave de localización"
class="h-9"
/>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="reg-localizacion" class="text-right">Localización</Label>
<div class="col-span-3">
<Input
id="reg-localizacion"
bind:value={formData.localizacion}
maxlength={200}
placeholder="Nombre o descripción"
class="h-9"
/>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="reg-department" class="text-right">Departamento</Label>
<div class="col-span-3">
<Input
id="reg-department"
bind:value={formData.department}
maxlength={100}
placeholder="Opcional"
class="h-9"
/>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="reg-responsible" class="text-right">Responsable</Label>
<div class="col-span-3">
<Input
id="reg-responsible"
bind:value={formData.responsible}
maxlength={200}
placeholder="Opcional"
class="h-9"
/>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="reg-observations" class="text-right">Observaciones</Label>
<div class="col-span-3">
<Input
id="reg-observations"
bind:value={formData.observations}
placeholder="Opcional"
class="h-9"
/>
</div>
</div>
</div>
</div>
<div class="px-6 py-4 border-t flex justify-end gap-2">
<Button type="button" variant="outline" onclick={closeRegisterForm} disabled={formLoading}>
<ArrowLeft class="mr-2 h-4 w-4" />
Volver al listado
</Button>
<Button type="submit" disabled={formLoading}>
{#if formLoading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{/if}
Guardar
</Button>
</div>
</form>
{:else}
<!-- Listado + búsqueda -->
<div class="px-6 py-3 border-b bg-zinc-50 dark:bg-zinc-900 flex items-center gap-2">
<div class="flex items-center gap-2 flex-1">
<Search class="w-4 h-4 text-zinc-400 shrink-0" />
<Input
bind:value={searchTerm}
placeholder="Buscar por clave o localización..."
class="flex-1 h-9"
/>
</div>
<Button type="button" variant="default" size="sm" onclick={openRegisterForm}>
<Plus class="mr-2 h-4 w-4" />
Registrar nueva ubicación
</Button>
</div>
<div class="flex-1 overflow-auto px-6 py-4">
{#if loading}
<div class="flex items-center justify-center py-20">
<Loader2 class="w-8 h-8 animate-spin text-zinc-900 dark:text-zinc-100" />
</div>
{:else if error}
<div class="flex items-center justify-center py-20 text-red-600">
<p>{error}</p>
</div>
{:else}
<div class="border rounded-md overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-zinc-900 dark:bg-zinc-800 text-white">
<tr>
<th class="px-3 py-2 text-left font-semibold border-r border-zinc-700">Clave</th>
<th class="px-3 py-2 text-left font-semibold">Localización</th>
</tr>
</thead>
<tbody>
{#each filtered as loc}
<tr
class="border-b hover:bg-zinc-100 dark:hover:bg-zinc-800 cursor-pointer transition-colors"
onclick={() => handleSelect(loc)}
>
<td class="px-3 py-2 border-r">{loc.clave_localizacion ?? '—'}</td>
<td class="px-3 py-2">{loc.localizacion ?? '—'}</td>
</tr>
{/each}
{#if filtered.length === 0}
<tr>
<td colspan="2" class="px-3 py-8 text-center text-zinc-500">
No se encontraron resultados
</td>
</tr>
{/if}
</tbody>
</table>
</div>
{/if}
</div>
<div class="px-6 py-4 border-t flex justify-end gap-2">
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>

View File

@@ -8,6 +8,7 @@
import type { Item, LineDescriptions } from '$lib/api/dashboard/a76/items';
import type { InvoiceItemVisibility } from '$lib/config/invoice-item-visibility';
import PaymentMethodDialog from './payment-method-dialog.svelte';
import LocationSelectorDialog from './location-selector-dialog.svelte';
let {
lineItem = $bindable(),
@@ -51,6 +52,7 @@
let paymentMethodDialogOpen = $state(false);
let payment_method_description = $state('');
let locationSelectorOpen = $state(false);
// Load payment method description when payment_method exists
$effect(() => {
@@ -89,6 +91,10 @@
lineItem.payment_method = method.key;
payment_method_description = method.description;
}
function handleLocationSelect(loc: { clave_localizacion: string; localizacion?: string | null }) {
descriptions.machinery_location = loc.localizacion ?? loc.clave_localizacion ?? '';
}
</script>
<div class="grid grid-cols-1 items-start gap-2 lg:grid-cols-2">
@@ -224,15 +230,27 @@
{/if}
{#if visibility.showContinuationLocation}
<!-- Location -->
<div class="space-y-1">
<!-- Location (catálogo a76, sistema fixed_asset) -->
<div class="space-y-1">
<div class="space-y-0.5">
<Label for="localizacion_maquinaria" class="text-xs">Machinery and equipment location:</Label>
<div class="flex gap-1">
<Input id="localizacion_maquinaria" bind:value={descriptions.machinery_location} class="h-6 text-xs" />
<button
id="localizacion_maquinaria"
type="button"
onclick={() => (locationSelectorOpen = true)}
class="flex h-6 min-w-[120px] flex-1 items-center rounded-md border border-input bg-transparent px-2 text-left text-xs ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{descriptions.machinery_location || 'Seleccionar ubicación...'}
</button>
</div>
<Label for="localizacion_maquinaria" class="text-xs">Location variable</Label>
<Label for="localizacion_maquinaria" class="text-xs text-muted-foreground">Location variable</Label>
</div>
<LocationSelectorDialog
bind:open={locationSelectorOpen}
system="fixed_asset"
onSelect={handleLocationSelect}
/>
</div>
{/if}

View File

@@ -3,24 +3,35 @@ import type { Location } from '$lib/api/dashboard/a76/general_catalogs/locations
import { renderComponent } from '$lib/components/ui/data-table';
import DataTableActions from './data-table-actions.svelte';
const SYSTEM_LABELS: Record<string, string> = {
fixed_asset: 'Activo fijo (FA)',
inventory: 'Inventario'
};
export function createColumns(onSuccess?: () => void): ColumnDef<Location>[] {
return [
{
accessorKey: 'location_code',
header: 'Código',
},
{
accessorKey: 'location_description',
header: 'Descripción',
cell: ({ row }) => row.original.location_description || '—'
},
{
id: 'actions',
header: 'Acciones',
cell: ({ row }) => renderComponent(DataTableActions, {
item: row.original,
onSuccess
})
}
];
return [
{
accessorKey: 'clave_localizacion',
header: 'Clave'
},
{
accessorKey: 'localizacion',
header: 'Localización',
cell: ({ row }) => row.original.localizacion ?? '—'
},
{
accessorKey: 'system',
header: 'Sistema',
cell: ({ row }) => SYSTEM_LABELS[row.original.system] ?? row.original.system
},
{
id: 'actions',
header: 'Acciones',
cell: ({ row }) =>
renderComponent(DataTableActions, {
item: row.original,
onSuccess
})
}
];
}

View File

@@ -0,0 +1,156 @@
<script lang="ts">
import { createColumns } from '$lib/components/dashboard/locations/columns';
import CreateDialog from '$lib/components/dashboard/general_catalogs/locations/create-edit-dialog.svelte';
import DataTable from '$lib/components/dashboard/locations/data-table.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus } from 'lucide-svelte';
import {
getLocations,
type Location,
type LocationSystem
} from '$lib/api/dashboard/a76/general_catalogs/locations';
let {
companyId,
system: initialSystem = undefined,
showSystemFilter = true,
compact = false
}: {
companyId: number;
/** Filtro por sistema cuando está embebido (ej. solo fixed_asset o solo inventory). */
system?: LocationSystem;
/** Mostrar selector de sistema (FA / Inventario). Por defecto true. */
showSystemFilter?: boolean;
/** Vista compacta sin título ni descripción. */
compact?: boolean;
} = $props();
let items = $state<Location[]>([]);
let total = $state(0);
let page = $state(1);
let pageSize = $state(50);
let pages = $state(0);
let loading = $state(true);
let dialogOpen = $state(false);
let searchClave = $state('');
let searchLocalizacion = $state('');
let filterSystem = $state<LocationSystem | ''>(initialSystem ?? '');
let timeout: ReturnType<typeof setTimeout>;
async function load() {
if (!companyId) return;
loading = true;
try {
const filters: Record<string, string | number> = {
page,
page_size: pageSize
};
if (searchClave) filters.clave_localizacion = searchClave;
if (searchLocalizacion) filters.localizacion = searchLocalizacion;
if (filterSystem) filters.system = filterSystem;
const res = await getLocations(companyId, filters);
items = res.items ?? [];
total = res.total ?? 0;
page = res.page ?? 1;
pageSize = res.page_size ?? 50;
pages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
} catch (e) {
console.error('Error loading locations:', e);
items = [];
total = 0;
pages = 0;
} finally {
loading = false;
}
}
function handleSuccess() {
load();
}
function handleSearch() {
clearTimeout(timeout);
timeout = setTimeout(() => {
page = 1;
load();
}, 400);
}
$effect(() => {
void companyId;
void page;
load();
});
</script>
<div class="flex flex-col gap-4">
{#if !compact}
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold tracking-tight">Ubicaciones</h2>
<p class="text-sm text-muted-foreground">
Clave y localización por sistema (FA / Inventario)
</p>
</div>
<Button onclick={() => (dialogOpen = true)} size="sm">
<Plus class="mr-2 h-4 w-4" />
Nueva
</Button>
</div>
{:else}
<div class="flex items-center justify-end">
<Button onclick={() => (dialogOpen = true)} size="sm" variant="outline">
<Plus class="mr-2 h-4 w-4" />
Nueva ubicación
</Button>
</div>
{/if}
<div class="flex gap-4 items-end flex-wrap">
<div class="grid w-full max-w-[200px] items-center gap-1.5">
<Input
placeholder="Clave..."
bind:value={searchClave}
oninput={handleSearch}
/>
</div>
<div class="grid w-full max-w-[200px] items-center gap-1.5">
<Input
placeholder="Localización..."
bind:value={searchLocalizacion}
oninput={handleSearch}
/>
</div>
{#if showSystemFilter}
<div class="grid w-full max-w-[180px] items-center gap-1.5">
<select
bind:value={filterSystem}
onchange={handleSearch}
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
>
<option value="">Todos</option>
<option value="fixed_asset">Activo fijo (FA)</option>
<option value="inventory">Inventario</option>
</select>
</div>
{/if}
</div>
<div class="rounded-md border">
{#if loading}
<div class="flex items-center justify-center py-12 text-muted-foreground">
Cargando...
</div>
{:else}
<DataTable
data={items}
columns={createColumns(handleSuccess)}
pageCount={pages}
totalItems={total}
/>
{/if}
</div>
<CreateDialog bind:open={dialogOpen} onSuccess={handleSuccess} />
</div>

View File

@@ -278,10 +278,6 @@ export function getSidebarData(): SidebarData {
title: m["sidebar.general_catalogs.customs_warehouses"](),
url: "/dashboard/reference_data/customs_warehouses",
},
{
title: m["sidebar.general_catalogs.locations"](),
url: "/dashboard/general_catalogs/locations",
},
{
title: m["sidebar.general_catalogs.doda"](),
url: "/dashboard/general_catalogs/doda",

View File

@@ -1,69 +0,0 @@
import type { PageServerLoad } from './$types';
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
const parentData = await parent();
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) {
return {
error: 'No authenticated',
locations: { items: [], total: 0, page: 1, page_size: 10, pages: 0 }
};
}
const page = Number(url.searchParams.get('page')) || 1;
const pageSize = Number(url.searchParams.get('page_size')) || 50;
const cookieCompanyId = cookies.get('active_company_id');
const companyId = cookieCompanyId
? parseInt(cookieCompanyId)
: parentData.companies?.[0]?.id;
if (!companyId) {
return {
error: 'No company selected',
locations: { items: [], total: 0, page, page_size: pageSize, pages: 0 }
};
}
const filters: Record<string, string> = {};
const location_code = url.searchParams.get('location_code');
const location_description = url.searchParams.get('location_description');
if (location_code) filters.location_code = location_code;
if (location_description) filters.location_description = location_description;
try {
const queryParams = new URLSearchParams({
page: page.toString(),
page_size: pageSize.toString(),
company_id: companyId.toString(),
...filters
});
const response = await authenticatedFetch(
`v1/a76/ports/?${queryParams.toString()}`,
{ method: 'GET', cache: 'no-store' },
cookies,
fetch
);
if (!response.ok) {
return {
error: 'Failed to load',
locations: { items: [], total: 0, page, page_size: pageSize, pages: 0 }
};
}
const data = await response.json();
return { locations: data };
} catch (error) {
console.error('Error loading locations:', error);
return {
error: 'Error loading',
locations: { items: [], total: 0, page: 1, page_size: pageSize, pages: 0 }
};
}
};

View File

@@ -1,89 +0,0 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { createColumns } from '$lib/components/dashboard/locations/columns';
import CreateDialog from '$lib/components/dashboard/general_catalogs/locations/create-edit-dialog.svelte';
import DataTable from '$lib/components/dashboard/locations/data-table.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus } from 'lucide-svelte';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosListaLocalidades } from '$lib/config/shortcuts/dashboard/general_catalogs/locations/list';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let dialogOpen = $state(false);
// Atajos
useShortcuts(
'Lista Localidades',
obtenerAtajosListaLocalidades({
manejarNuevo: () => (dialogOpen = true),
manejarActualizar: handleSuccess
})
);
let searchCode = $state($page.url.searchParams.get('location_code') || '');
let searchDesc = $state($page.url.searchParams.get('location_description') || '');
let timeout: ReturnType<typeof setTimeout>;
function handleSuccess() {
const url = new URL($page.url);
goto(url, { invalidateAll: true });
}
function handleSearch() {
if (!browser) return;
clearTimeout(timeout);
timeout = setTimeout(() => {
const url = new URL($page.url);
if (searchCode) url.searchParams.set('location_code', searchCode);
else url.searchParams.delete('location_code');
if (searchDesc) url.searchParams.set('location_description', searchDesc);
else url.searchParams.delete('location_description');
url.searchParams.set('page', '1');
goto(url, { keepFocus: true, noScroll: true });
}, 500);
}
</script>
<div class="flex flex-col gap-4 p-4">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">Ubicaciones</h1>
<p class="text-muted-foreground">Catálogo de ubicaciones de puertos</p>
</div>
<Button onclick={() => (dialogOpen = true)}>
<Plus class="mr-2 h-4 w-4" />
Nueva Ubicación
</Button>
</div>
<div class="flex gap-4 items-end">
<div class="grid w-full max-w-sm items-center gap-1.5">
<Input placeholder="Buscar por código..." bind:value={searchCode} oninput={handleSearch} />
</div>
<div class="grid w-full max-w-sm items-center gap-1.5">
<Input
placeholder="Buscar por descripción..."
bind:value={searchDesc}
oninput={handleSearch}
/>
</div>
</div>
<div class="rounded-md border">
<DataTable
data={data.locations?.items || []}
columns={createColumns(handleSuccess)}
pageCount={data.locations?.pages || 0}
totalItems={data.locations?.total || 0}
/>
</div>
<CreateDialog bind:open={dialogOpen} onSuccess={handleSuccess} />
</div>