feature/presets-invoices-and-teplates-partidas
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
"""create invoice_settings table
|
||||
|
||||
Revision ID: 703f96cf6025
|
||||
Revises: 7937209f9718
|
||||
Create Date: 2026-02-09 23:02:43.174682
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '703f96cf6025'
|
||||
down_revision: Union[str, Sequence[str], None] = '7937209f9718'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
pass
|
||||
@@ -0,0 +1,28 @@
|
||||
"""create item_presets table
|
||||
|
||||
Revision ID: 8a607a5e88bf
|
||||
Revises: 703f96cf6025
|
||||
Create Date: 2026-02-10 15:49:59.617922
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '8a607a5e88bf'
|
||||
down_revision: Union[str, Sequence[str], None] = '703f96cf6025'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
pass
|
||||
28
backend/api/v1/modules/a76/invoice_settings/dto.py
Normal file
28
backend/api/v1/modules/a76/invoice_settings/dto.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from typing import Any, Dict, Optional
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from enum import Enum
|
||||
|
||||
class OperationType(str, Enum):
|
||||
IMP = "imp" # Importación
|
||||
EXP = "exp" # Exportación
|
||||
SM_IN = "sm_in" # Entrada SM
|
||||
SM_OUT = "sm_out" # Salida SM
|
||||
CTM_SEND = "ctm_send" # Envío CTM
|
||||
CTM_RECEIVE = "ctm_receive" # Recibo CTM
|
||||
|
||||
class InvoiceSettingsBase(BaseModel):
|
||||
invoice_type: str
|
||||
operation_type: OperationType
|
||||
settings: Dict[str, Any]
|
||||
|
||||
class InvoiceSettingsRequest(InvoiceSettingsBase):
|
||||
"""Schema for creating or updating invoice settings"""
|
||||
pass
|
||||
|
||||
class InvoiceSettingsResponse(InvoiceSettingsBase):
|
||||
"""Schema for returning invoice settings"""
|
||||
id: int
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
47
backend/api/v1/modules/a76/invoice_settings/models.py
Normal file
47
backend/api/v1/modules/a76/invoice_settings/models.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from typing import Optional
|
||||
from sqlalchemy import BigInteger, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from core.database import Base
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
import enum
|
||||
|
||||
class OperationType(str, enum.Enum):
|
||||
IMP = "imp" # Importación
|
||||
EXP = "exp" # Exportación
|
||||
SM_IN = "sm_in" # Entrada SM
|
||||
SM_OUT = "sm_out" # Salida SM
|
||||
CTM_SEND = "ctm_send" # Envío CTM
|
||||
CTM_RECEIVE = "ctm_receive" # Recibo CTM
|
||||
|
||||
class InvoiceSettings(Base, TenantScopedMixin, TimestampMixin):
|
||||
__tablename__ = "invoice_settings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"company_id",
|
||||
"invoice_type",
|
||||
"operation_type",
|
||||
name="uq_invoice_settings_tenant_company_type_op"
|
||||
),
|
||||
{"schema": "a76"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
|
||||
# Configuration Scope
|
||||
invoice_type: Mapped[str] = mapped_column(
|
||||
ForeignKey("public.invoice_types.key"),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
operation_type: Mapped[OperationType] = mapped_column(
|
||||
String(11),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# The actual settings payload
|
||||
settings: Mapped[dict] = mapped_column(JSONB, nullable=False, default={})
|
||||
|
||||
def __repr__(self):
|
||||
return f"<InvoiceSettings(id={self.id}, type={self.invoice_type}, op={self.operation_type})>"
|
||||
76
backend/api/v1/modules/a76/invoice_settings/routes.py
Normal file
76
backend/api/v1/modules/a76/invoice_settings/routes.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from typing import List, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
from api.v1.modules.a76.invoice_settings import services
|
||||
from api.v1.modules.a76.invoice_settings.dto import InvoiceSettingsRequest, InvoiceSettingsResponse, OperationType
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/a76/invoice-settings",
|
||||
tags=["a76/invoice-settings"]
|
||||
)
|
||||
|
||||
@router.get("/{invoice_type}", response_model=InvoiceSettingsResponse)
|
||||
def get_invoice_settings(
|
||||
invoice_type: str,
|
||||
operation_type: OperationType = Query(...),
|
||||
company_id: int = Query(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Get settings for a specific invoice type and operation"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
settings = services.get_settings(
|
||||
db,
|
||||
tenant_id,
|
||||
company_id,
|
||||
invoice_type,
|
||||
operation_type
|
||||
)
|
||||
|
||||
if not settings:
|
||||
# Return empty default if not found, to simplify frontend logic
|
||||
return InvoiceSettingsResponse(
|
||||
invoice_type=invoice_type,
|
||||
operation_type=operation_type,
|
||||
settings={},
|
||||
id=0,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id
|
||||
)
|
||||
|
||||
return settings
|
||||
|
||||
@router.get("/", response_model=List[InvoiceSettingsResponse])
|
||||
def list_invoice_settings(
|
||||
company_id: int = Query(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""List all configured settings for validation or overview"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
return services.list_settings(
|
||||
db,
|
||||
tenant_id,
|
||||
company_id
|
||||
)
|
||||
|
||||
@router.put("/", response_model=InvoiceSettingsResponse)
|
||||
def save_invoice_settings(
|
||||
settings_data: InvoiceSettingsRequest,
|
||||
company_id: int = Query(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Create or update invoice settings"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
return services.upsert_settings(
|
||||
db,
|
||||
tenant_id,
|
||||
company_id,
|
||||
settings_data
|
||||
)
|
||||
70
backend/api/v1/modules/a76/invoice_settings/services.py
Normal file
70
backend/api/v1/modules/a76/invoice_settings/services.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
from fastapi import HTTPException
|
||||
from api.v1.modules.a76.invoice_settings.models import InvoiceSettings
|
||||
from api.v1.modules.a76.invoice_settings.dto import InvoiceSettingsRequest, OperationType
|
||||
|
||||
def get_settings(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
invoice_type: str,
|
||||
operation_type: OperationType
|
||||
) -> Optional[InvoiceSettings]:
|
||||
"""Retrieve settings for a specific context"""
|
||||
stmt = select(InvoiceSettings).where(
|
||||
InvoiceSettings.tenant_id == tenant_id,
|
||||
InvoiceSettings.company_id == company_id,
|
||||
InvoiceSettings.invoice_type == invoice_type,
|
||||
InvoiceSettings.operation_type == operation_type
|
||||
)
|
||||
return db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def list_settings(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int
|
||||
) -> List[InvoiceSettings]:
|
||||
"""List all settings for a company"""
|
||||
stmt = select(InvoiceSettings).where(
|
||||
InvoiceSettings.tenant_id == tenant_id,
|
||||
InvoiceSettings.company_id == company_id
|
||||
)
|
||||
return db.execute(stmt).scalars().all()
|
||||
|
||||
def upsert_settings(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
settings_data: InvoiceSettingsRequest
|
||||
) -> InvoiceSettings:
|
||||
"""Create or update settings"""
|
||||
# Check if exists
|
||||
existing = get_settings(
|
||||
db,
|
||||
tenant_id,
|
||||
company_id,
|
||||
settings_data.invoice_type,
|
||||
settings_data.operation_type
|
||||
)
|
||||
|
||||
if existing:
|
||||
existing.settings = settings_data.settings
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return existing
|
||||
|
||||
# Create new
|
||||
new_settings = InvoiceSettings(
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
invoice_type=settings_data.invoice_type,
|
||||
operation_type=settings_data.operation_type,
|
||||
settings=settings_data.settings
|
||||
)
|
||||
|
||||
db.add(new_settings)
|
||||
db.commit()
|
||||
db.refresh(new_settings)
|
||||
return new_settings
|
||||
25
backend/api/v1/modules/a76/item_presets/dto.py
Normal file
25
backend/api/v1/modules/a76/item_presets/dto.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
class ItemPresetBase(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
items: List[Dict[str, Any]]
|
||||
|
||||
class ItemPresetCreate(ItemPresetBase):
|
||||
"""Schema for creating a new item preset"""
|
||||
pass
|
||||
|
||||
class ItemPresetUpdate(BaseModel):
|
||||
"""Schema for updating an existing item preset"""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
items: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
class ItemPresetResponse(ItemPresetBase):
|
||||
"""Schema for returning an item preset"""
|
||||
id: int
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
18
backend/api/v1/modules/a76/item_presets/models.py
Normal file
18
backend/api/v1/modules/a76/item_presets/models.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from typing import Any, Dict, List
|
||||
from sqlalchemy import String, Integer, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from core.database import Base
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
|
||||
|
||||
class ItemPreset(Base, TenantScopedMixin, TimestampMixin):
|
||||
__tablename__ = "item_presets"
|
||||
__table_args__ = {"schema": "a76"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
items: Mapped[List[Dict[str, Any]]] = mapped_column(JSON, nullable=False, default=[])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ItemPreset(id={self.id}, name='{self.name}')>"
|
||||
72
backend/api/v1/modules/a76/item_presets/routes.py
Normal file
72
backend/api/v1/modules/a76/item_presets/routes.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from typing import List, Any, Dict
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
from . import services, dto
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=List[dto.ItemPresetResponse])
|
||||
def list_item_presets(
|
||||
company_id: int = Query(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""List all item presets for a company"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
return services.get_presets(db, tenant_id, company_id)
|
||||
|
||||
@router.post("/", response_model=dto.ItemPresetResponse)
|
||||
def create_item_preset(
|
||||
data: dto.ItemPresetCreate,
|
||||
company_id: int = Query(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new item preset"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
return services.create_preset(db, tenant_id, company_id, data)
|
||||
|
||||
@router.get("/{preset_id}", response_model=dto.ItemPresetResponse)
|
||||
def get_item_preset(
|
||||
preset_id: int,
|
||||
company_id: int = Query(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Get a specific item preset"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
preset = services.get_preset_by_id(db, preset_id, tenant_id, company_id)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=404, detail="Item preset not found")
|
||||
return preset
|
||||
|
||||
@router.put("/{preset_id}", response_model=dto.ItemPresetResponse)
|
||||
def update_item_preset(
|
||||
preset_id: int,
|
||||
data: dto.ItemPresetUpdate,
|
||||
company_id: int = Query(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Update an item preset"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
preset = services.update_preset(db, preset_id, tenant_id, company_id, data)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=404, detail="Item preset not found")
|
||||
return preset
|
||||
|
||||
@router.delete("/{preset_id}")
|
||||
def delete_item_preset(
|
||||
preset_id: int,
|
||||
company_id: int = Query(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Delete an item preset"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
success = services.delete_preset(db, preset_id, tenant_id, company_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Item preset not found")
|
||||
return {"status": "success", "message": "Preset deleted"}
|
||||
63
backend/api/v1/modules/a76/item_presets/services.py
Normal file
63
backend/api/v1/modules/a76/item_presets/services.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from .models import ItemPreset
|
||||
from .dto import ItemPresetCreate, ItemPresetUpdate
|
||||
|
||||
def get_presets(db: Session, tenant_id: int, company_id: int) -> List[ItemPreset]:
|
||||
"""Get all presets for a specific company and tenant"""
|
||||
return db.query(ItemPreset).filter(
|
||||
ItemPreset.tenant_id == tenant_id,
|
||||
ItemPreset.company_id == company_id
|
||||
).all()
|
||||
|
||||
def get_preset_by_id(db: Session, preset_id: int, tenant_id: int, company_id: int) -> Optional[ItemPreset]:
|
||||
"""Get a specific preset by ID"""
|
||||
return db.query(ItemPreset).filter(
|
||||
ItemPreset.id == preset_id,
|
||||
ItemPreset.tenant_id == tenant_id,
|
||||
ItemPreset.company_id == company_id
|
||||
).first()
|
||||
|
||||
def create_preset(db: Session, tenant_id: int, company_id: int, data: ItemPresetCreate) -> ItemPreset:
|
||||
"""Create a new item preset"""
|
||||
new_preset = ItemPreset(
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
items=data.items
|
||||
)
|
||||
db.add(new_preset)
|
||||
db.commit()
|
||||
db.refresh(new_preset)
|
||||
return new_preset
|
||||
|
||||
def update_preset(
|
||||
db: Session,
|
||||
preset_id: int,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
data: ItemPresetUpdate
|
||||
) -> Optional[ItemPreset]:
|
||||
"""Update an existing item preset"""
|
||||
preset = get_preset_by_id(db, preset_id, tenant_id, company_id)
|
||||
if not preset:
|
||||
return None
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(preset, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(preset)
|
||||
return preset
|
||||
|
||||
def delete_preset(db: Session, preset_id: int, tenant_id: int, company_id: int) -> bool:
|
||||
"""Delete an item preset"""
|
||||
preset = get_preset_by_id(db, preset_id, tenant_id, company_id)
|
||||
if not preset:
|
||||
return False
|
||||
|
||||
db.delete(preset)
|
||||
db.commit()
|
||||
return True
|
||||
@@ -12,6 +12,8 @@ from .invoices.routes import router as invoices_router
|
||||
from .items.routes import router as items_router
|
||||
from .classes import router as classes_router
|
||||
from .clients_and_providers import router as client_and_provider_router
|
||||
from .invoice_settings.routes import router as invoice_settings_router
|
||||
from .item_presets.routes import router as item_presets_router
|
||||
from .general_catalogs.company import router as company_router
|
||||
from .country_rule_oct.routes import router as country_rule_oct_router
|
||||
from .transportation.drivers.routes import router as drivers_router
|
||||
@@ -66,6 +68,8 @@ router = APIRouter()
|
||||
# Registrar módulos
|
||||
router.include_router(invoices_router, prefix="/a76", tags=["a76 / invoices"])
|
||||
router.include_router(items_router, prefix="/a76", tags=["a76 / items"])
|
||||
router.include_router(invoice_settings_router)
|
||||
router.include_router(item_presets_router, prefix="/a76/item-presets", tags=["a76 / item_presets"])
|
||||
router.include_router(pedimentos_router, prefix="/a76")
|
||||
router.include_router(
|
||||
client_and_provider_router, prefix="/a76", tags=["a76 / clients_and_providers"]
|
||||
|
||||
77
frontend/src/lib/api/dashboard/a76/item-presets.ts
Normal file
77
frontend/src/lib/api/dashboard/a76/item-presets.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { api } from '$lib/api';
|
||||
import type { Item } from './items';
|
||||
|
||||
export interface ItemPreset {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
company_id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
items: Item[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface ItemPresetCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
items: Item[];
|
||||
}
|
||||
|
||||
export interface ItemPresetUpdate {
|
||||
name?: string;
|
||||
description?: string;
|
||||
items?: Item[];
|
||||
}
|
||||
|
||||
export const itemPresetsApi = {
|
||||
/**
|
||||
* List all item presets for a company
|
||||
*/
|
||||
list: (companyId: number) => {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
return api.get<ItemPreset[]>(`/v1/a76/item-presets/?${params.toString()}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a specific item preset
|
||||
*/
|
||||
get: (presetId: number, companyId: number) => {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
return api.get<ItemPreset>(`/v1/a76/item-presets/${presetId}/?${params.toString()}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new item preset
|
||||
*/
|
||||
create: (companyId: number, data: ItemPresetCreate) => {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
return api.post<ItemPreset>(`/v1/a76/item-presets/?${params.toString()}`, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing item preset
|
||||
*/
|
||||
update: (presetId: number, companyId: number, data: ItemPresetUpdate) => {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
return api.put<ItemPreset>(`/v1/a76/item-presets/${presetId}/?${params.toString()}`, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an item preset
|
||||
*/
|
||||
delete: (presetId: number, companyId: number) => {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
return api.delete(`/v1/a76/item-presets/${presetId}/?${params.toString()}`);
|
||||
}
|
||||
};
|
||||
@@ -24,10 +24,11 @@
|
||||
|
||||
const filteredClasses = $derived(
|
||||
searchQuery
|
||||
? classes.filter(c =>
|
||||
c.class_code?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.description_es?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
? classes.filter(
|
||||
(c) =>
|
||||
c.class_code?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.description_es?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: classes
|
||||
);
|
||||
|
||||
@@ -86,8 +87,9 @@
|
||||
function handleScroll(e: Event) {
|
||||
const target = e.target as HTMLDivElement;
|
||||
const threshold = 100;
|
||||
const scrolledToBottom = target.scrollHeight - target.scrollTop - target.clientHeight < threshold;
|
||||
|
||||
const scrolledToBottom =
|
||||
target.scrollHeight - target.scrollTop - target.clientHeight < threshold;
|
||||
|
||||
if (scrolledToBottom && displayedClasses.length < filteredClasses.length) {
|
||||
currentPage++;
|
||||
loadMoreClasses();
|
||||
@@ -106,9 +108,7 @@
|
||||
<Dialog.Content class="max-w-4xl max-h-[80vh] flex flex-col">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Seleccionar Clase</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Busca y selecciona una clase para la partida
|
||||
</Dialog.Description>
|
||||
<Dialog.Description>Busca y selecciona una clase para la partida</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex gap-2 mb-4">
|
||||
@@ -146,9 +146,14 @@
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each displayedClasses as classItem}
|
||||
<Table.Row class="cursor-pointer hover:bg-muted/50" onclick={() => handleSelect(classItem)}>
|
||||
<Table.Row
|
||||
class="cursor-pointer hover:bg-muted/50"
|
||||
onclick={() => handleSelect(classItem)}
|
||||
>
|
||||
<Table.Cell class="font-medium">{classItem.class_code}</Table.Cell>
|
||||
<Table.Cell>{classItem.description_es || classItem.description_en || '-'}</Table.Cell>
|
||||
<Table.Cell
|
||||
>{classItem.description_es || classItem.description_en || '-'}</Table.Cell
|
||||
>
|
||||
<Table.Cell>{classItem.unit_of_measure || '-'}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Button size="sm" variant="ghost" onclick={() => handleSelect(classItem)}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
@@ -26,6 +26,8 @@
|
||||
invoice,
|
||||
onSave,
|
||||
onCancel,
|
||||
|
||||
isTargetingPreset = false,
|
||||
isSaving = false
|
||||
}: {
|
||||
open: boolean;
|
||||
@@ -34,6 +36,8 @@
|
||||
invoice: Invoice | null;
|
||||
onSave: () => void;
|
||||
onCancel?: () => void;
|
||||
|
||||
isTargetingPreset?: boolean;
|
||||
isSaving?: boolean;
|
||||
} = $props();
|
||||
|
||||
@@ -63,31 +67,56 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open={open}>
|
||||
<Sheet.Content side="right" class="w-full sm:max-w-[95vw] lg:max-w-[85vw] xl:max-w-[75vw] p-0 flex flex-col h-full bg-slate-50 dark:bg-black">
|
||||
|
||||
<header class="bg-white dark:bg-zinc-950 border-b dark:border-zinc-800 px-3 py-1.5 shadow-sm shrink-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="bg-zinc-900 p-1 rounded">
|
||||
<Package class="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<Sheet.Title class="text-sm font-semibold text-zinc-900 dark:text-zinc-100 leading-tight">
|
||||
<Dialog.Root bind:open={open}>
|
||||
<Dialog.Content class="sm:max-w-6xl max-h-[90vh] p-0 overflow-hidden z-[100] [&>button]:hidden">
|
||||
<div class="bg-white dark:bg-zinc-950 border-b dark:border-zinc-800 px-4 py-3 shadow-sm flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="bg-zinc-900 p-1 rounded">
|
||||
<Package class="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<Dialog.Title class="text-sm font-semibold text-zinc-900 dark:text-zinc-100 leading-tight flex items-center gap-2">
|
||||
{#if isTargetingPreset}
|
||||
{isEditMode ? 'Editar Item de Plantilla' : 'Nuevo Item para Plantilla'}
|
||||
{:else}
|
||||
{isEditMode ? 'Editar Partida' : 'Nueva Partida - Activo Fijo'}
|
||||
</Sheet.Title>
|
||||
{/if}
|
||||
{#if editingItem.lines && editingItem.lines.length > 1}
|
||||
<span class="px-1.5 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-[10px] text-blue-700 dark:text-blue-300 font-bold border border-blue-200 dark:border-blue-800">
|
||||
{editingItem.lines.length} lines
|
||||
</span>
|
||||
{/if}
|
||||
</Dialog.Title>
|
||||
{#if !isTargetingPreset}
|
||||
<p class="text-[10px] text-muted-foreground flex items-center gap-1.5">
|
||||
Factura: <span class="font-medium text-zinc-700 dark:text-zinc-300">{invoice?.invoice_number || 'N/A'}</span>
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-[10px] text-muted-foreground flex items-center gap-1.5">
|
||||
<span class="font-medium text-blue-600 dark:text-blue-400 uppercase tracking-wider">Modo Plantilla</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onclick={() => onCancel?.()} class="h-7 w-7 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<X class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-2 py-1.5">
|
||||
<Button variant="outline" size="sm" onclick={() => onCancel?.()} disabled={isSaving} class="h-7 text-xs px-2">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button size="sm" onclick={onSave} disabled={isSaving} class="h-7 text-xs px-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white">
|
||||
{#if isSaving}
|
||||
<Loader2 class="w-3 h-3 mr-1 animate-spin" />
|
||||
Guardando...
|
||||
{:else}
|
||||
<Save class="w-3 h-3 mr-1" />
|
||||
{isEditMode ? 'Actualizar' : 'Crear'}
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3 overflow-y-auto max-h-[calc(90vh-88px)] bg-slate-50/70 dark:bg-black">
|
||||
<div class="space-y-2">
|
||||
|
||||
{#if line}
|
||||
@@ -187,23 +216,5 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="bg-white dark:bg-zinc-950 border-t border-zinc-200 dark:border-zinc-800 px-3 py-1.5 shadow-sm shrink-0">
|
||||
<div class="flex items-center justify-end gap-1.5">
|
||||
<Button variant="outline" size="sm" onclick={() => onCancel?.()} disabled={isSaving} class="h-7 text-xs px-2">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button size="sm" onclick={onSave} disabled={isSaving} class="h-7 text-xs px-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white">
|
||||
{#if isSaving}
|
||||
<Loader2 class="w-3 h-3 mr-1 animate-spin" />
|
||||
Guardando...
|
||||
{:else}
|
||||
<Save class="w-3 h-3 mr-1" />
|
||||
{isEditMode ? 'Actualizar' : 'Crear'}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Loader2 } from 'lucide-svelte';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Loader2, FileText } from 'lucide-svelte';
|
||||
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import type { Item } from '$lib/api/dashboard/a76/items';
|
||||
import { useShortcuts } from '$lib/hooks/use-shortcuts';
|
||||
@@ -17,6 +18,8 @@
|
||||
invoice,
|
||||
onSave,
|
||||
onCancel,
|
||||
|
||||
isTargetingPreset = false,
|
||||
isSaving = false
|
||||
}: {
|
||||
open: boolean;
|
||||
@@ -25,6 +28,8 @@
|
||||
invoice: Invoice | null;
|
||||
onSave: () => void;
|
||||
onCancel?: () => void;
|
||||
|
||||
isTargetingPreset?: boolean;
|
||||
isSaving?: boolean;
|
||||
} = $props();
|
||||
|
||||
@@ -48,257 +53,397 @@
|
||||
manejarCancelar: () => onCancel?.()
|
||||
})
|
||||
);
|
||||
|
||||
// Derived state for easier binding and safety
|
||||
let line = $derived(editingItem.lines?.[0]);
|
||||
|
||||
// Initialize missing nested objects if they don't exist
|
||||
$effect(() => {
|
||||
if (open && editingItem) {
|
||||
if (!editingItem.lines) editingItem.lines = [{}];
|
||||
if (!editingItem.lines[0].quantity) editingItem.lines[0].quantity = {};
|
||||
if (!editingItem.lines[0].financial) editingItem.lines[0].financial = {};
|
||||
if (!editingItem.lines[0].customs) editingItem.lines[0].customs = {};
|
||||
if (!editingItem.lines[0].description) editingItem.lines[0].description = {};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open>
|
||||
<Sheet.Content side="right" class="w-full sm:max-w-2xl overflow-y-auto">
|
||||
<Sheet.Header>
|
||||
<Sheet.Title
|
||||
>{isEditMode ? 'Editar Item' : 'Agregar Nuevo Item'} - SCAII (Inventario)</Sheet.Title
|
||||
>
|
||||
<Sheet.Description>
|
||||
{isEditMode
|
||||
? 'Modifica los campos del inventario y guarda los cambios.'
|
||||
: 'Completa la información del nuevo item de inventario.'}
|
||||
</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
|
||||
<Tabs.Root bind:value={activeTab} class="mt-6">
|
||||
<Tabs.List class="grid w-full grid-cols-4">
|
||||
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
||||
<Tabs.Trigger value="clasificacion">Clasificación</Tabs.Trigger>
|
||||
<Tabs.Trigger value="cantidades">Cantidades</Tabs.Trigger>
|
||||
<Tabs.Trigger value="otros">Otros</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<!-- Tab: General -->
|
||||
<Tabs.Content value="general" class="space-y-4 mt-4">
|
||||
<!-- Información de la Factura (Solo lectura) -->
|
||||
<div class="rounded-lg border bg-muted/50 p-4 space-y-3">
|
||||
<h4 class="text-sm font-medium">Información de la Factura (SCAII - Inventario)</h4>
|
||||
{#if !invoice?.id}
|
||||
<div class="text-sm text-amber-600 bg-amber-50 dark:bg-amber-950/20 p-3 rounded">
|
||||
⚠️ Esta factura aún no se ha guardado. Los items se asociarán cuando guardes la
|
||||
factura.
|
||||
</div>
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-5xl max-h-[90vh] p-0 overflow-hidden z-[100] [&>button]:hidden">
|
||||
<div
|
||||
class="px-6 py-4 border-b bg-white dark:bg-zinc-950 flex items-start justify-between gap-3"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<Dialog.Title class="text-lg font-semibold">
|
||||
{isEditMode ? 'Editar Item' : 'Agregar Nuevo Item'} - SCAII (Inventario)
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-muted-foreground">
|
||||
{isEditMode
|
||||
? 'Modifica los campos del inventario y guarda los cambios.'
|
||||
: 'Completa la información del nuevo item de inventario.'}
|
||||
{#if editingItem.lines && editingItem.lines.length > 1}
|
||||
<Badge variant="secondary" class="ml-2">
|
||||
{editingItem.lines.length} items en esta partida
|
||||
</Badge>
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" onclick={() => onCancel?.()} disabled={isSaving}>Cancelar</Button>
|
||||
<Button onclick={onSave} disabled={isSaving}>
|
||||
{#if isSaving}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Guardando...
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-muted-foreground">ID Factura:</span>
|
||||
<span class="ml-2 font-medium">{invoice.id}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted-foreground">Tipo Operación:</span>
|
||||
<span class="ml-2 font-medium uppercase">{invoice.operation_type || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<span class="text-muted-foreground">Número de Factura:</span>
|
||||
<span class="ml-2 font-medium">{invoice.invoice_number || 'Pendiente'}</span>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<span class="text-muted-foreground">Sistema:</span>
|
||||
<span class="ml-2 font-medium bg-blue-100 dark:bg-blue-900/30 px-2 py-1 rounded"
|
||||
>SCAII (Inventory)</span
|
||||
>
|
||||
</div>
|
||||
{isEditMode ? 'Guardar Cambios' : 'Agregar Item'}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 overflow-auto max-h-[calc(90vh-96px)] bg-slate-50/60 dark:bg-black">
|
||||
<Tabs.Root bind:value={activeTab} class="mt-0">
|
||||
<Tabs.List class="grid w-full grid-cols-4">
|
||||
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
||||
<Tabs.Trigger value="clasificacion">Clasificación</Tabs.Trigger>
|
||||
<Tabs.Trigger value="cantidades">Cantidades</Tabs.Trigger>
|
||||
<Tabs.Trigger value="otros">Otros</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<!-- Tab: General -->
|
||||
<Tabs.Content value="general" class="space-y-4 mt-4">
|
||||
<!-- Información de la Factura (Solo lectura) -->
|
||||
{#if !isTargetingPreset}
|
||||
<div class="rounded-lg border bg-muted/50 p-4 space-y-3">
|
||||
<h4 class="text-sm font-medium">Información de la Factura (SCAII - Inventario)</h4>
|
||||
{#if !invoice?.id}
|
||||
<div class="text-sm text-amber-600 bg-amber-50 dark:bg-amber-950/20 p-3 rounded">
|
||||
⚠️ Esta factura aún no se ha guardado. Los items se asociarán cuando guardes la
|
||||
factura.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-muted-foreground">ID Factura:</span>
|
||||
<span class="ml-2 font-medium">{invoice.id}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted-foreground">Tipo Operación:</span>
|
||||
<span class="ml-2 font-medium uppercase">{invoice.operation_type || 'N/A'}</span
|
||||
>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<span class="text-muted-foreground">Número de Factura:</span>
|
||||
<span class="ml-2 font-medium">{invoice.invoice_number || 'Pendiente'}</span>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<span class="text-muted-foreground">Sistema:</span>
|
||||
<span class="ml-2 font-medium bg-blue-100 dark:bg-blue-900/30 px-2 py-1 rounded"
|
||||
>SCAII (Inventory)</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="reference_number">Número de Referencia</Label>
|
||||
<Input id="reference_number" bind:value={editingItem.reference_number} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="order">Orden de Compra/Venta</Label>
|
||||
<Input
|
||||
id="order"
|
||||
bind:value={editingItem.order}
|
||||
placeholder={invoice?.purchase_order || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="warehouse">Almacén</Label>
|
||||
<Input id="warehouse" bind:value={editingItem.warehouse} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="location">Ubicación</Label>
|
||||
<Input id="location" bind:value={editingItem.location} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Campos específicos de SCAII -->
|
||||
<div class="space-y-2">
|
||||
<Label for="product_description">Descripción del Producto</Label>
|
||||
<Input id="product_description" placeholder="Descripción detallada del producto" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="sku">SKU</Label>
|
||||
<Input id="sku" placeholder="Código SKU del producto" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="batch">Lote</Label>
|
||||
<Input id="batch" placeholder="Número de lote" />
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Tab: Clasificación -->
|
||||
<Tabs.Content value="clasificacion" class="space-y-4 mt-4">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="tariff_fraction">Fracción Arancelaria</Label>
|
||||
<Input id="tariff_fraction" placeholder="8 dígitos" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="product_type">Tipo de Producto</Label>
|
||||
<Input id="product_type" placeholder="Materia prima, producto terminado, etc." />
|
||||
<Label for="reference_number">Número de Referencia</Label>
|
||||
<Input id="reference_number" bind:value={editingItem.reference_number} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="material_type">Tipo de Material</Label>
|
||||
<Input id="material_type" placeholder="Metal, plástico, etc." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="product_code">Código de Producto</Label>
|
||||
<Input id="product_code" placeholder="Código interno" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="country_origin">País de Origen</Label>
|
||||
<Input id="country_origin" placeholder="Código del país" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="merchandise_category">Categoría de Mercancía</Label>
|
||||
<Input id="merchandise_category" placeholder="Categoría" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Tab: Cantidades -->
|
||||
<Tabs.Content value="cantidades" class="space-y-4 mt-4">
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="quantity">Cantidad</Label>
|
||||
<Input id="quantity" type="number" placeholder="0" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="unit">Unidad de Medida</Label>
|
||||
<Input id="unit" placeholder="PZA, KG, M, etc." />
|
||||
<Label for="order">Orden de Compra/Venta</Label>
|
||||
<Input
|
||||
id="order"
|
||||
bind:value={editingItem.order}
|
||||
placeholder={invoice?.purchase_order || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="net_weight">Peso Neto (KG)</Label>
|
||||
<Input id="net_weight" type="number" step="0.01" placeholder="0.00" />
|
||||
<Label for="warehouse">Almacén</Label>
|
||||
<Input id="warehouse" bind:value={editingItem.warehouse} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="gross_weight">Peso Bruto (KG)</Label>
|
||||
<Input id="gross_weight" type="number" step="0.01" placeholder="0.00" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="unit_value">Valor Unitario (USD)</Label>
|
||||
<Input id="unit_value" type="number" step="0.01" placeholder="0.00" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="total_value">Valor Total (USD)</Label>
|
||||
<Input id="total_value" type="number" step="0.01" placeholder="0.00" disabled />
|
||||
<Label for="location">Ubicación</Label>
|
||||
<Input id="location" bind:value={editingItem.location} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Campos específicos de SCAII -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="packages">Número de Bultos</Label>
|
||||
<Input id="packages" type="number" placeholder="0" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="package_type">Tipo de Empaque</Label>
|
||||
<Input id="package_type" placeholder="Caja, pallet, etc." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="imported_quantity">Cantidad Importada</Label>
|
||||
<Input id="imported_quantity" type="number" placeholder="0" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="remaining_quantity">Cantidad Remanente</Label>
|
||||
<Input id="remaining_quantity" type="number" placeholder="0" disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Tab: Otros -->
|
||||
<Tabs.Content value="otros" class="space-y-4 mt-4">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="brand">Marca</Label>
|
||||
<Input id="brand" placeholder="Marca del producto" />
|
||||
<Label for="product_description">Descripción del Producto</Label>
|
||||
{#if line?.description}
|
||||
<Input
|
||||
id="product_description"
|
||||
placeholder="Descripción detallada del producto"
|
||||
bind:value={line.description.description_spanish}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="expiration_date">Fecha de Caducidad</Label>
|
||||
<Input id="expiration_date" type="date" />
|
||||
<Label for="sku">SKU</Label>
|
||||
{#if line}
|
||||
<Input
|
||||
id="sku"
|
||||
placeholder="Código SKU del producto"
|
||||
bind:value={line.part_number}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="production_date">Fecha de Producción</Label>
|
||||
<Input id="production_date" type="date" />
|
||||
<Label for="batch">Lote</Label>
|
||||
{#if line?.description}
|
||||
<Input id="batch" placeholder="Número de lote" bind:value={line.description.lot} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Tab: Clasificación -->
|
||||
<Tabs.Content value="clasificacion" class="space-y-4 mt-4">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="min_stock">Stock Mínimo</Label>
|
||||
<Input id="min_stock" type="number" placeholder="0" />
|
||||
<Label for="tariff_fraction">Fracción Arancelaria</Label>
|
||||
{#if line?.customs}
|
||||
<Input
|
||||
id="tariff_fraction"
|
||||
placeholder="8 dígitos"
|
||||
bind:value={line.customs.fraction}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="product_type">Tipo de Producto</Label>
|
||||
{#if line}
|
||||
<Input
|
||||
id="product_type"
|
||||
placeholder="Materia prima, producto terminado, etc."
|
||||
bind:value={line.description.extra_description_2}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="material_type">Tipo de Material</Label>
|
||||
{#if line}
|
||||
<Input
|
||||
id="material_type"
|
||||
placeholder="Metal, plástico, etc."
|
||||
bind:value={line.description.extra_description_3}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="max_stock">Stock Máximo</Label>
|
||||
<Input id="max_stock" type="number" placeholder="0" />
|
||||
<Label for="product_code">Código de Producto</Label>
|
||||
<Input id="product_code" placeholder="Código interno" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="country_origin">País de Origen</Label>
|
||||
<Input id="country_origin" placeholder="Código del país" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="merchandise_category">Categoría de Mercancía</Label>
|
||||
<Input id="merchandise_category" placeholder="Categoría" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="observations">Observaciones</Label>
|
||||
<textarea
|
||||
id="observations"
|
||||
class="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Notas adicionales sobre el inventario..."
|
||||
></textarea>
|
||||
<!-- Tab: Cantidades -->
|
||||
<Tabs.Content value="cantidades" class="space-y-4 mt-4">
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="quantity">Cantidad</Label>
|
||||
{#if line?.quantity}
|
||||
<Input
|
||||
id="quantity"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
bind:value={line.quantity.quantity}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="unit">Unidad de Medida</Label>
|
||||
{#if line?.quantity}
|
||||
<Input
|
||||
id="unit"
|
||||
placeholder="PZA, KG, M, etc."
|
||||
bind:value={line.quantity.unit_of_measure}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="net_weight">Peso Neto (KG)</Label>
|
||||
{#if line?.quantity}
|
||||
<Input
|
||||
id="net_weight"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
bind:value={line.quantity.net_weight}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="gross_weight">Peso Bruto (KG)</Label>
|
||||
{#if line?.quantity}
|
||||
<Input
|
||||
id="gross_weight"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
bind:value={line.quantity.gross_weight}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="unit_cost_usd">Costo Unitario (USD)</Label>
|
||||
{#if line?.financial}
|
||||
<Input
|
||||
id="unit_cost_usd"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
placeholder="0.00"
|
||||
bind:value={line.financial.unit_cost_usd}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="total_value">Valor Total (USD)</Label>
|
||||
<Input
|
||||
id="total_value"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={(line?.quantity?.quantity || 0) * (line?.financial?.unit_cost_usd || 0)}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Campos específicos de SCAII -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="packages">Número de Bultos</Label>
|
||||
{#if line?.quantity}
|
||||
<Input
|
||||
id="packages"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
bind:value={line.quantity.packages}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="package_type">Tipo de Empaque</Label>
|
||||
{#if line?.quantity}
|
||||
<Input
|
||||
id="package_type"
|
||||
placeholder="Caja, pallet, etc."
|
||||
bind:value={line.quantity.package_type}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="imported_quantity">Cantidad Importada</Label>
|
||||
{#if line?.quantity}
|
||||
<Input
|
||||
id="imported_quantity"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
bind:value={line.quantity.quantity_imported}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="remaining_quantity">Cantidad Remanente</Label>
|
||||
{#if line?.quantity}
|
||||
<Input
|
||||
id="remaining_quantity"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={(line.quantity.quantity || 0) - (line.quantity.quantity_imported || 0)}
|
||||
disabled
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</Tabs.Content>
|
||||
|
||||
<Sheet.Footer class="mt-6 gap-2">
|
||||
<Button variant="outline" onclick={() => onCancel?.()} disabled={isSaving}>Cancelar</Button>
|
||||
<Button onclick={onSave} disabled={isSaving}>
|
||||
{#if isSaving}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Guardando...
|
||||
{:else}
|
||||
{isEditMode ? 'Guardar Cambios' : 'Agregar Item'}
|
||||
{/if}
|
||||
</Button>
|
||||
</Sheet.Footer>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
<!-- Tab: Otros -->
|
||||
<Tabs.Content value="otros" class="space-y-4 mt-4">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="brand">Marca</Label>
|
||||
{#if line?.description}
|
||||
<Input
|
||||
id="brand"
|
||||
placeholder="Marca del producto"
|
||||
bind:value={line.description.brand}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="expiration_date">Fecha de Caducidad</Label>
|
||||
<Input id="expiration_date" type="date" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="production_date">Fecha de Producción</Label>
|
||||
<Input id="production_date" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="min_stock">Stock Mínimo</Label>
|
||||
<Input id="min_stock" type="number" placeholder="0" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="max_stock">Stock Máximo</Label>
|
||||
<Input id="max_stock" type="number" placeholder="0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="observations">Observaciones</Label>
|
||||
{#if line?.description}
|
||||
<textarea
|
||||
id="observations"
|
||||
class="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Notas adicionales sobre el inventario..."
|
||||
bind:value={line.description.extra_description}
|
||||
></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,49 +22,54 @@
|
||||
transportModes?: any[];
|
||||
} = $props();
|
||||
|
||||
if (!formData && invoice) {
|
||||
formData = {
|
||||
// Campo de comentario estatus
|
||||
comments_status: invoice.comments_status || '',
|
||||
// Campos que van en diferentes recursos pero se editan aquí
|
||||
transport_mode: invoice.logistics?.transport_mode || null,
|
||||
is_mixed: invoice.compliance_mx?.is_mixed || null,
|
||||
print_stamp: invoice.financials?.seal_value_2500 || false,
|
||||
rule_3121_parties_ii: false,
|
||||
related_doc_id: invoice.related_doc_id || null,
|
||||
code_signature: invoice.compliance_mx?.code_signature || '',
|
||||
electronic_signature: invoice.compliance_mx?.electronic_signature || '',
|
||||
mandatory_person: '',
|
||||
contingency_mode: invoice.compliance_mx?.contingency_mode || false,
|
||||
cove: invoice.compliance_mx?.origin_destination_cove || '',
|
||||
operation_num: invoice.compliance_mx?.vucem_operation_num || '',
|
||||
adendas: invoice.compliance_mx?.addendum_vu || '',
|
||||
observations_vu: invoice.vu_observations || '',
|
||||
certified_number: invoice.compliance_mx?.certificate_number || '',
|
||||
};
|
||||
exists = true;
|
||||
} else if (!formData) {
|
||||
formData = {
|
||||
// Campo de comentario estatus
|
||||
comments_status: '',
|
||||
// Campos que van en diferentes recursos pero se editan aquí
|
||||
transport_mode: 'TRUCK',
|
||||
is_mixed: null,
|
||||
print_stamp: false,
|
||||
rule_3121_parties_ii: false,
|
||||
related_doc_id: null,
|
||||
code_signature: '',
|
||||
electronic_signature: '',
|
||||
mandatory_person: '',
|
||||
contingency_mode: false,
|
||||
cove: '',
|
||||
operation_num: '',
|
||||
adendas: '',
|
||||
observations_vu: '',
|
||||
certified_number: '',
|
||||
};
|
||||
exists = false;
|
||||
}
|
||||
// Inicializar formData si está vacío o null
|
||||
$effect(() => {
|
||||
if ((!formData || Object.keys(formData).length === 0)) {
|
||||
if (invoice) {
|
||||
formData = {
|
||||
// Campo de comentario estatus
|
||||
comments_status: invoice.comments_status || '',
|
||||
// Campos que van en diferentes recursos pero se editan aquí
|
||||
transport_mode: invoice.logistics?.transport_mode || 'TRUCK', // Default to TRUCK if null
|
||||
is_mixed: invoice.compliance_mx?.is_mixed || null,
|
||||
print_stamp: invoice.financials?.seal_value_2500 || false,
|
||||
rule_3121_parties_ii: false, // Default false or logic?
|
||||
related_doc_id: invoice.related_doc_id || null,
|
||||
code_signature: invoice.compliance_mx?.code_signature || '',
|
||||
electronic_signature: invoice.compliance_mx?.electronic_signature || '',
|
||||
mandatory_person: '',
|
||||
contingency_mode: invoice.compliance_mx?.contingency_mode || false,
|
||||
cove: invoice.compliance_mx?.origin_destination_cove || '',
|
||||
operation_num: invoice.compliance_mx?.vucem_operation_num || '',
|
||||
adendas: invoice.compliance_mx?.addendum_vu || '',
|
||||
observations_vu: invoice.vu_observations || '',
|
||||
certified_number: invoice.compliance_mx?.certificate_number || '',
|
||||
};
|
||||
exists = true;
|
||||
} else {
|
||||
formData = {
|
||||
// Campo de comentario estatus
|
||||
comments_status: '',
|
||||
// Campos que van en diferentes recursos pero se editan aquí
|
||||
transport_mode: 'TRUCK',
|
||||
is_mixed: null,
|
||||
print_stamp: false,
|
||||
rule_3121_parties_ii: false,
|
||||
related_doc_id: null,
|
||||
code_signature: '',
|
||||
electronic_signature: '',
|
||||
mandatory_person: '',
|
||||
contingency_mode: false,
|
||||
cove: '',
|
||||
operation_num: '',
|
||||
adendas: '',
|
||||
observations_vu: '',
|
||||
certified_number: '',
|
||||
};
|
||||
exists = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Campos que no están en el backend
|
||||
let rfc = $state('');
|
||||
|
||||
107
frontend/src/lib/utils/items-logic.ts
Normal file
107
frontend/src/lib/utils/items-logic.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
|
||||
export interface Item {
|
||||
id?: number;
|
||||
lines?: any[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Helper function to check if an object has any meaningful values
|
||||
export function hasValues(obj: any): boolean {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
return Object.values(obj).some(
|
||||
(val) =>
|
||||
val !== undefined &&
|
||||
val !== null &&
|
||||
val !== '' &&
|
||||
!(typeof val === 'object' && !hasValues(val))
|
||||
);
|
||||
}
|
||||
|
||||
// Clean nested data before sending to API
|
||||
export function cleanLineData(line: any) {
|
||||
const cleaned: any = { ...line };
|
||||
|
||||
// Helper function to convert to number or undefined
|
||||
const toNumberOrUndefined = (value: any): number | undefined => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const numValue = Number(value);
|
||||
return !isNaN(numValue) && isFinite(numValue) ? numValue : undefined;
|
||||
};
|
||||
|
||||
// Convert integer fields
|
||||
cleaned.part_number = toNumberOrUndefined(cleaned.part_number);
|
||||
cleaned.component_part_number = toNumberOrUndefined(cleaned.component_part_number);
|
||||
cleaned.class_id = toNumberOrUndefined(cleaned.class_id);
|
||||
cleaned.unit_of_measure = toNumberOrUndefined(cleaned.unit_of_measure);
|
||||
cleaned.alternate_unit = toNumberOrUndefined(cleaned.alternate_unit);
|
||||
|
||||
// Remove display-only fields
|
||||
delete cleaned.class_code;
|
||||
delete cleaned.class_unit_of_measure;
|
||||
delete cleaned.class_description;
|
||||
|
||||
// UI specific fields that shouldn't be in the payload
|
||||
delete cleaned.part_description_es;
|
||||
delete cleaned.part_description_en;
|
||||
delete cleaned.unit_code;
|
||||
delete cleaned.unit_description;
|
||||
|
||||
// Only delete part_number if it's the string code from UI, but schema expects int ID.
|
||||
// In this codebase, if part_number is populated from existing data, it's an ID.
|
||||
// If it's a new item, it might be cleaned.
|
||||
|
||||
// Remove display-only fields from nested objects
|
||||
if (cleaned.customs) {
|
||||
delete cleaned.customs.origin_country_name;
|
||||
delete cleaned.customs.fraction_description;
|
||||
if (!hasValues(cleaned.customs)) delete cleaned.customs;
|
||||
}
|
||||
|
||||
// Remove empty nested objects
|
||||
if (cleaned.financial && !hasValues(cleaned.financial)) delete cleaned.financial;
|
||||
if (cleaned.quantity && !hasValues(cleaned.quantity)) delete cleaned.quantity;
|
||||
if (cleaned.description && !hasValues(cleaned.description)) delete cleaned.description;
|
||||
if (cleaned.reference && !hasValues(cleaned.reference)) delete cleaned.reference;
|
||||
if (cleaned.fa_data && !hasValues(cleaned.fa_data)) delete cleaned.fa_data;
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
// Normalize numeric values from strings to numbers (for editing)
|
||||
export function normalizeItemData(item: Partial<Item>): Partial<Item> {
|
||||
if (item.lines && item.lines.length > 0) {
|
||||
item.lines = item.lines.map((line) => {
|
||||
const normalizedLine = { ...line };
|
||||
|
||||
// Normalize financials
|
||||
if (normalizedLine.financial) {
|
||||
const f = normalizedLine.financial;
|
||||
normalizedLine.financial = {
|
||||
...f,
|
||||
unit_cost_usd: f.unit_cost_usd != null ? Number(f.unit_cost_usd) : undefined,
|
||||
unit_cost_mxn: f.unit_cost_mxn != null ? Number(f.unit_cost_mxn) : undefined,
|
||||
value_usd: f.value_usd != null ? Number(f.value_usd) : undefined,
|
||||
value_mxn: f.value_mxn != null ? Number(f.value_mxn) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
// Normalize quantities
|
||||
if (normalizedLine.quantity) {
|
||||
const q = normalizedLine.quantity;
|
||||
normalizedLine.quantity = {
|
||||
...q,
|
||||
quantity: q.quantity != null ? Number(q.quantity) : undefined,
|
||||
net_weight: q.net_weight != null ? Number(q.net_weight) : undefined,
|
||||
gross_weight: q.gross_weight != null ? Number(q.gross_weight) : undefined,
|
||||
package_quantity: q.package_quantity != null ? Number(q.package_quantity) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
return normalizedLine;
|
||||
});
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import {
|
||||
validateAuth,
|
||||
getUserCompanies,
|
||||
import {
|
||||
validateAuth,
|
||||
getUserCompanies,
|
||||
getAuthTokens,
|
||||
clearAuthTokens
|
||||
clearAuthTokens
|
||||
} from '$lib/server/api';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
// Verificar si existe el token en las cookies
|
||||
const { accessToken } = getAuthTokens(cookies);
|
||||
|
||||
|
||||
// Si no hay token, redirigir al login
|
||||
if (!accessToken) {
|
||||
const redirectUrl = `/login?redirect=${encodeURIComponent(url.pathname)}`;
|
||||
@@ -20,16 +20,16 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
// Validar el token con el backend y obtener datos del usuario
|
||||
// La función validateAuth maneja automáticamente el refresh de tokens
|
||||
const redirectOnFail = `/login?redirect=${encodeURIComponent(url.pathname)}`;
|
||||
|
||||
|
||||
try {
|
||||
const userData = await validateAuth(cookies, fetch, redirectOnFail);
|
||||
|
||||
|
||||
// Cargar las compañías del usuario en el servidor (SSR)
|
||||
const companies = await getUserCompanies(cookies, fetch);
|
||||
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
user: userData,
|
||||
user: { ...userData, token: accessToken },
|
||||
companies, // Pasar las compañías al cliente
|
||||
error: undefined // Agregar error opcional para compatibilidad con error-handler
|
||||
};
|
||||
@@ -38,7 +38,7 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
// Para cualquier otro error (conexión, etc), limpiar token y redirigir
|
||||
console.error('🔐 [Dashboard] Error validando token:', error);
|
||||
clearAuthTokens(cookies);
|
||||
|
||||
@@ -17,7 +17,16 @@
|
||||
import type { PageData } from './$types';
|
||||
import { browser } from '$app/environment';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { Plus, RefreshCw, FileText, RotateCcw, Boxes, Package, ClipboardList } from 'lucide-svelte';
|
||||
import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
FileText,
|
||||
RotateCcw,
|
||||
Boxes,
|
||||
Package,
|
||||
ClipboardList,
|
||||
Settings
|
||||
} from 'lucide-svelte';
|
||||
|
||||
// IMPORTANTE: Asegúrate de tener instalada svelte-sonner para las notificaciones
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -39,7 +48,7 @@
|
||||
year: data.filters?.year || ''
|
||||
});
|
||||
|
||||
let isDownloadModalOpen = $state(false);
|
||||
let isDownloadModalOpen = $state(false);
|
||||
|
||||
// Efecto reactivo para actualizar filtros cuando cambian los query parameters en la URL
|
||||
$effect(() => {
|
||||
@@ -611,32 +620,36 @@
|
||||
currentStatusFunction = null;
|
||||
}
|
||||
|
||||
// --- AQUÍ PASAMOS LA FUNCIÓN DE DESCARGA A LAS COLUMNAS ---
|
||||
const columns = createColumns(handleSuccess);
|
||||
async function handleModalConfirm(type: string, format: string, currency: string, uomSource: string, weightUnit: string) {
|
||||
if (!selectedInvoice || !companyStore.activeCompany) return;
|
||||
// --- AQUÍ PASAMOS LA FUNCIÓN DE DESCARGA A LAS COLUMNAS ---
|
||||
const columns = createColumns(handleSuccess);
|
||||
async function handleModalConfirm(
|
||||
type: string,
|
||||
format: string,
|
||||
currency: string,
|
||||
uomSource: string,
|
||||
weightUnit: string
|
||||
) {
|
||||
if (!selectedInvoice || !companyStore.activeCompany) return;
|
||||
|
||||
try {
|
||||
// 1. Trigger: Start celery task with selected options
|
||||
// Note: uomSource and weightUnit are UI-only for now
|
||||
const { task_id } = await invoicesReportsApi.triggerPdfGeneration(
|
||||
selectedInvoice.id,
|
||||
companyStore.activeCompany.id,
|
||||
type,
|
||||
currency
|
||||
);
|
||||
|
||||
// 2. Open Progress Dialog
|
||||
currentTaskId = task_id;
|
||||
currentStatusFunction = invoicesReportsApi.getTaskStatus;
|
||||
showProgressDialog = true;
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("No se pudo iniciar la descarga");
|
||||
}
|
||||
}
|
||||
try {
|
||||
// 1. Trigger: Start celery task with selected options
|
||||
// Note: uomSource and weightUnit are UI-only for now
|
||||
const { task_id } = await invoicesReportsApi.triggerPdfGeneration(
|
||||
selectedInvoice.id,
|
||||
companyStore.activeCompany.id,
|
||||
type,
|
||||
currency
|
||||
);
|
||||
|
||||
// 2. Open Progress Dialog
|
||||
currentTaskId = task_id;
|
||||
currentStatusFunction = invoicesReportsApi.getTaskStatus;
|
||||
showProgressDialog = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('No se pudo iniciar la descarga');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
@@ -645,10 +658,16 @@
|
||||
<h1 class="text-3xl font-bold tracking-tight">Facturas</h1>
|
||||
<p class="text-muted-foreground">Gestiona las facturas del sistema</p>
|
||||
</div>
|
||||
<Button onclick={handleCreateClick}>
|
||||
<Plus class="mr-2" size={16} />
|
||||
Nueva Factura
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" onclick={() => goto('/dashboard/invoices/settings')}>
|
||||
<Settings class="mr-2" size={16} />
|
||||
Parámetros
|
||||
</Button>
|
||||
<Button onclick={handleCreateClick}>
|
||||
<Plus class="mr-2" size={16} />
|
||||
Nueva Factura
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
@@ -775,13 +794,13 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => isDownloadModalOpen = true}
|
||||
onclick={() => (isDownloadModalOpen = true)}
|
||||
disabled={!selectedInvoice}
|
||||
>
|
||||
<FileText class="h-4 w-4 mr-2" />
|
||||
Factura
|
||||
</Button>
|
||||
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -792,49 +811,44 @@
|
||||
Consolidado
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => selectedInvoice && handleDownloadAvisoConsolidado(selectedInvoice)}
|
||||
disabled={!selectedInvoice}
|
||||
>
|
||||
<Boxes class="mr-2 h-4 w-4" />
|
||||
Aviso Consolidado
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => selectedInvoice && handleDownloadPackingList(selectedInvoice)}
|
||||
disabled={!selectedInvoice}
|
||||
>
|
||||
<Package class="mr-2 h-4 w-4" />
|
||||
Packing List
|
||||
</Button>
|
||||
|
||||
{#if selectedInvoice?.operation_type === 'exp'}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => selectedInvoice && handleDownloadDescargo(selectedInvoice)}
|
||||
onclick={() => selectedInvoice && handleDownloadAvisoConsolidado(selectedInvoice)}
|
||||
disabled={!selectedInvoice}
|
||||
>
|
||||
<ClipboardList class="mr-2 h-4 w-4" />
|
||||
Descargo PEPS
|
||||
<Boxes class="mr-2 h-4 w-4" />
|
||||
Aviso Consolidado
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => selectedInvoice && handleDownloadPackingList(selectedInvoice)}
|
||||
disabled={!selectedInvoice}
|
||||
>
|
||||
<Package class="mr-2 h-4 w-4" />
|
||||
Packing List
|
||||
</Button>
|
||||
|
||||
{#if selectedInvoice?.operation_type === 'exp'}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => selectedInvoice && handleDownloadDescargo(selectedInvoice)}
|
||||
disabled={!selectedInvoice}
|
||||
>
|
||||
<ClipboardList class="mr-2 h-4 w-4" />
|
||||
Descargo PEPS
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ... -->
|
||||
|
||||
|
||||
<!-- ... -->
|
||||
|
||||
{#if selectedInvoice && companyStore.activeCompany}
|
||||
<InvoiceDownloadModal
|
||||
bind:open={isDownloadModalOpen}
|
||||
onConfirm={handleModalConfirm}
|
||||
/>
|
||||
{/if}
|
||||
{#if selectedInvoice && companyStore.activeCompany}
|
||||
<InvoiceDownloadModal bind:open={isDownloadModalOpen} onConfirm={handleModalConfirm} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -144,10 +144,23 @@ export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => {
|
||||
// Si el ID es "new", es una creación
|
||||
if (params.id === 'new') {
|
||||
try {
|
||||
// Fetch default settings if type, operation and company are present
|
||||
const settingsPromise = (parsedOperationType && invoiceTypeParam && companyId)
|
||||
? authenticatedFetch(
|
||||
`v1/a76/invoice-settings/${invoiceTypeParam}?operation_type=${parsedOperationType}&company_id=${companyId}`,
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
).catch((err) => {
|
||||
console.error('Error fetching defaults in server load:', err);
|
||||
return null;
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
|
||||
const [
|
||||
invoiceTypesResponse,
|
||||
customsBrokersResponse,
|
||||
clientsResponse,
|
||||
invoiceTypesResponse,
|
||||
customsBrokersResponse,
|
||||
clientsResponse,
|
||||
providersResponse,
|
||||
currencyTypesResponse,
|
||||
transportTypesResponse,
|
||||
@@ -160,7 +173,8 @@ export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => {
|
||||
sealsResponse,
|
||||
incotermsResponse,
|
||||
pedimentosResponse,
|
||||
transportModesResponse
|
||||
transportModesResponse,
|
||||
settingsResponse
|
||||
] = await Promise.all([
|
||||
invoiceTypesPromise,
|
||||
customsBrokersPromise,
|
||||
@@ -177,7 +191,8 @@ export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => {
|
||||
sealsPromise,
|
||||
incotermsPromise,
|
||||
pedimentosPromise,
|
||||
transportModesPromise
|
||||
transportModesPromise,
|
||||
settingsPromise
|
||||
]);
|
||||
|
||||
const invoiceTypes = invoiceTypesResponse.ok ? await invoiceTypesResponse.json() : { items: [] };
|
||||
@@ -191,12 +206,18 @@ export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => {
|
||||
const drivers = driversResponse.ok ? await driversResponse.json() : { items: [] };
|
||||
const trailers = trailersResponse.ok ? await trailersResponse.json() : { items: [] };
|
||||
const customsSections = customsSectionsResponse.ok ? await customsSectionsResponse.json() : { items: [] };
|
||||
const codePedimentoRegimens = codePedimentoRegimensResponse.ok ? await codePedimentoRegimensResponse.json() : { items: [] };
|
||||
const codePedimentoRegimens = codePedimentoRegimensResponse.ok ? await codePedimentoRegimensResponse.json() : { items: [] };
|
||||
const seals = sealsResponse.ok ? await sealsResponse.json() : { items: [] };
|
||||
const incoterms = incotermsResponse.ok ? await incotermsResponse.json() : { items: [] };
|
||||
const pedimentos = pedimentosResponse.ok ? await pedimentosResponse.json() : { items: [] };
|
||||
const transportModes = transportModesResponse.ok ? await transportModesResponse.json() : { items: [] };
|
||||
|
||||
let defaultSettings = null;
|
||||
if (settingsResponse && settingsResponse.ok) {
|
||||
const settingsData = await settingsResponse.json();
|
||||
defaultSettings = settingsData.settings || null;
|
||||
}
|
||||
|
||||
return {
|
||||
invoice: null,
|
||||
invoiceId: null,
|
||||
@@ -217,6 +238,7 @@ export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => {
|
||||
incoterms: incoterms.items || [],
|
||||
pedimentos: pedimentos.items || [],
|
||||
transportModes: transportModes.items || [],
|
||||
defaultSettings,
|
||||
// Filtros desde query parameters para preselección
|
||||
filters: {
|
||||
operation_type: parsedOperationType,
|
||||
@@ -276,9 +298,9 @@ export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => {
|
||||
|
||||
// Cargar también los datos de referencia para edición
|
||||
const [
|
||||
invoiceTypesResponse,
|
||||
customsBrokersResponse,
|
||||
clientsResponse,
|
||||
invoiceTypesResponse,
|
||||
customsBrokersResponse,
|
||||
clientsResponse,
|
||||
providersResponse,
|
||||
currencyTypesResponse,
|
||||
transportTypesResponse,
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
invoice_type?: string | null;
|
||||
};
|
||||
pedimentos?: any[];
|
||||
defaultSettings?: any;
|
||||
}
|
||||
|
||||
let { data }: { data: ExtendedPageData } = $props();
|
||||
@@ -91,19 +92,135 @@
|
||||
// ID de la factura
|
||||
let invoiceId = $state<number | null>(data.invoiceId ?? null);
|
||||
|
||||
// Form Skeletons for robust initialization
|
||||
const topFieldsSkeleton = {
|
||||
is_pedimento_pending: false,
|
||||
pedimento_id: '',
|
||||
remesa: '',
|
||||
invoice_number: '',
|
||||
invoice_date: new Date().toISOString().split('T')[0],
|
||||
emission_date: new Date().toISOString().split('T')[0],
|
||||
operation_type: data.filters?.operation_type || '',
|
||||
invoice_type: data.filters?.invoice_type || '',
|
||||
fecha_pedimento_del: '',
|
||||
fecha_pedimento_al: '',
|
||||
clave_pedimento: '',
|
||||
regimen_pedimento: ''
|
||||
};
|
||||
|
||||
const generalSkeleton = {
|
||||
provider_header: 'proveedor',
|
||||
provider_id: null,
|
||||
sold_to_header: 'consignado_a',
|
||||
sold_to_id: null,
|
||||
shipped_to_header: 'enviado_a',
|
||||
shipped_to_id: null,
|
||||
customs_broker_id: null,
|
||||
customs_broker_us_id: null,
|
||||
currency_type: '',
|
||||
currency: 'foreign',
|
||||
exchange_rate: null,
|
||||
weight_type: 'kgs',
|
||||
iva_factor: null,
|
||||
carrier_id: null,
|
||||
transport_id: '',
|
||||
driver_name: '',
|
||||
transport_type: '',
|
||||
transport_num: '',
|
||||
aduana: '',
|
||||
document_type: ''
|
||||
};
|
||||
|
||||
const observationSkeleton = {
|
||||
observation_es: '',
|
||||
observation_en: '',
|
||||
freight: null,
|
||||
insurance_value: null,
|
||||
insurance: null,
|
||||
packaging: null,
|
||||
other_increments: null,
|
||||
total_increments_mn: null,
|
||||
total_increments_me: null,
|
||||
incoterm: null,
|
||||
enclosure: null,
|
||||
num_seals: null,
|
||||
movement_type: '',
|
||||
alternate_invoice: '',
|
||||
valuation_method: null
|
||||
};
|
||||
|
||||
const othersSkeleton = {
|
||||
comments_status: '',
|
||||
transport_mode: 'TRUCK',
|
||||
is_mixed: null,
|
||||
print_stamp: false,
|
||||
rule_3121_parties_ii: false,
|
||||
related_doc_id: null,
|
||||
code_signature: '',
|
||||
electronic_signature: '',
|
||||
mandatory_person: '',
|
||||
contingency_mode: false,
|
||||
cove: '',
|
||||
operation_num: '',
|
||||
adendas: '',
|
||||
observations_vu: '',
|
||||
certified_number: ''
|
||||
};
|
||||
|
||||
const continuationSkeleton = {
|
||||
numero_tipo_transporte: '',
|
||||
es_ferrocarril: 'no',
|
||||
numero_bl: '',
|
||||
cantidad_guias_embarque: null,
|
||||
destino_origen: '',
|
||||
puerto_entrada: '',
|
||||
fue_revisado_equipo: false,
|
||||
sub_division: false,
|
||||
funge_como_cd: false,
|
||||
llego_pedimento: false,
|
||||
errores_facturacion: [],
|
||||
semaforo_verde_aduana_mexicana: false,
|
||||
semaforo_verde_aduana_americana: false,
|
||||
semaforo_rojo_aduana_mexicana: false,
|
||||
semaforo_rojo_aduana_americana: false
|
||||
};
|
||||
|
||||
function ensureItemsFormData(initial?: any) {
|
||||
const base = initial ? { ...initial } : {};
|
||||
if (!Array.isArray(base.items)) {
|
||||
base.items = [];
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function mergeDefaults(skeleton: any, partial: any) {
|
||||
if (!partial) return skeleton;
|
||||
return { ...skeleton, ...partial };
|
||||
}
|
||||
|
||||
// Referencias a los componentes de formulario para obtener sus datos
|
||||
let InvoiceTopFieldsFormData = $state<any>(null);
|
||||
let generalFormData = $state<any>(null);
|
||||
let observationFormData = $state<any>(null);
|
||||
let itemsFormData = $state<any>(null);
|
||||
let othersFormData = $state<any>(null);
|
||||
let continuationFormData = $state<any>(null);
|
||||
let InvoiceTopFieldsFormData = $state<any>(
|
||||
mergeDefaults(topFieldsSkeleton, data.defaultSettings?.InvoiceTopFieldsFormData)
|
||||
);
|
||||
let generalFormData = $state<any>(
|
||||
mergeDefaults(generalSkeleton, data.defaultSettings?.generalFormData)
|
||||
);
|
||||
let observationFormData = $state<any>(
|
||||
mergeDefaults(observationSkeleton, data.defaultSettings?.observationFormData)
|
||||
);
|
||||
let itemsFormData = $state<any>(ensureItemsFormData(data.defaultSettings?.itemsFormData));
|
||||
let othersFormData = $state<any>(
|
||||
mergeDefaults(othersSkeleton, data.defaultSettings?.othersFormData)
|
||||
);
|
||||
let continuationFormData = $state<any>(
|
||||
mergeDefaults(continuationSkeleton, data.defaultSettings?.continuationFormData)
|
||||
);
|
||||
|
||||
// Estados para saber si existen datos previos
|
||||
let observationExists = $state(false);
|
||||
let itemsExists = $state(false);
|
||||
let othersExists = $state(false);
|
||||
let continuationExists = $state(false);
|
||||
let observationExists = $state(!!data.defaultSettings?.observationFormData);
|
||||
let itemsExists = $state(!!(data.defaultSettings?.itemsFormData?.items?.length));
|
||||
let othersExists = $state(!!data.defaultSettings?.othersFormData);
|
||||
let continuationExists = $state(!!data.defaultSettings?.continuationFormData);
|
||||
|
||||
let calculatedExchangeRate = $state<number | null>(
|
||||
data.invoice?.financials?.exchange_rate ?? null
|
||||
@@ -196,12 +313,12 @@
|
||||
isCreate: data.isCreate || false,
|
||||
companyId: companyStore?.activeCompany?.id || 0,
|
||||
formData: {
|
||||
InvoiceTopFieldsFormData,
|
||||
generalFormData,
|
||||
observationFormData,
|
||||
itemsFormData,
|
||||
othersFormData,
|
||||
continuationFormData
|
||||
InvoiceTopFieldsFormData: $state.snapshot(InvoiceTopFieldsFormData),
|
||||
generalFormData: $state.snapshot(generalFormData),
|
||||
observationFormData: $state.snapshot(observationFormData),
|
||||
itemsFormData: $state.snapshot(itemsFormData),
|
||||
othersFormData: $state.snapshot(othersFormData),
|
||||
continuationFormData: $state.snapshot(continuationFormData)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -267,6 +384,97 @@
|
||||
}
|
||||
}
|
||||
|
||||
let isLoadingDefaults = $state(false);
|
||||
let lastLoadedKey = $state(
|
||||
data.defaultSettings
|
||||
? `${InvoiceTopFieldsFormData.invoice_type}-${InvoiceTopFieldsFormData.operation_type}`
|
||||
: ''
|
||||
);
|
||||
|
||||
async function loadDefaults() {
|
||||
if (
|
||||
!data.isCreate ||
|
||||
!InvoiceTopFieldsFormData.invoice_type ||
|
||||
!InvoiceTopFieldsFormData.operation_type ||
|
||||
!companyStore?.activeCompany?.id
|
||||
)
|
||||
return;
|
||||
|
||||
const currentKey = `${InvoiceTopFieldsFormData.invoice_type}-${InvoiceTopFieldsFormData.operation_type}`;
|
||||
if (currentKey === lastLoadedKey) return;
|
||||
|
||||
isLoadingDefaults = true;
|
||||
try {
|
||||
const token = data.user?.token;
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`/api/v1/a76/invoice-settings/${InvoiceTopFieldsFormData.invoice_type}?operation_type=${InvoiceTopFieldsFormData.operation_type}&company_id=${companyStore.activeCompany.id}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
const settingsData = await res.json();
|
||||
const settings = settingsData.settings || {};
|
||||
|
||||
// Mark as loaded even if empty to prevent retries for the same combination
|
||||
lastLoadedKey = currentKey;
|
||||
|
||||
// Only notify and apply if there are actually settings
|
||||
if (Object.keys(settings).length === 0) return;
|
||||
|
||||
// Re-apply merges with new defaults
|
||||
InvoiceTopFieldsFormData = mergeDefaults(
|
||||
topFieldsSkeleton,
|
||||
settings.InvoiceTopFieldsFormData
|
||||
);
|
||||
generalFormData = mergeDefaults(generalSkeleton, settings.generalFormData);
|
||||
observationFormData = mergeDefaults(observationSkeleton, settings.observationFormData);
|
||||
itemsFormData = ensureItemsFormData(settings.itemsFormData);
|
||||
othersFormData = mergeDefaults(othersSkeleton, settings.othersFormData);
|
||||
continuationFormData = mergeDefaults(continuationSkeleton, settings.continuationFormData);
|
||||
|
||||
// Ensure types remain as selected
|
||||
InvoiceTopFieldsFormData.invoice_type =
|
||||
InvoiceTopFieldsFormData.invoice_type || data.filters?.invoice_type;
|
||||
InvoiceTopFieldsFormData.operation_type =
|
||||
InvoiceTopFieldsFormData.operation_type || data.filters?.operation_type;
|
||||
|
||||
observationExists = !!settings.observationFormData;
|
||||
itemsExists = !!(itemsFormData.items?.length);
|
||||
othersExists = !!settings.othersFormData;
|
||||
continuationExists = !!settings.continuationFormData;
|
||||
|
||||
toast.info(
|
||||
'Valores predeterminados cargados para ' + InvoiceTopFieldsFormData.invoice_type
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading defaults:', error);
|
||||
} finally {
|
||||
isLoadingDefaults = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic defaults loading when types change in "New" mode
|
||||
$effect(() => {
|
||||
if (
|
||||
data.isCreate &&
|
||||
mounted &&
|
||||
companyStore?.activeCompany?.id &&
|
||||
InvoiceTopFieldsFormData.invoice_type &&
|
||||
InvoiceTopFieldsFormData.operation_type
|
||||
) {
|
||||
// Trigger only if types actually changed from initial load or previous selection
|
||||
loadDefaults();
|
||||
}
|
||||
});
|
||||
|
||||
useShortcuts(
|
||||
'Invoice Edit',
|
||||
obtenerAtajosEdicionFactura({
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Save,
|
||||
ArrowLeft,
|
||||
Package,
|
||||
FileText,
|
||||
Search,
|
||||
Sparkles
|
||||
} from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { goto } from '$app/navigation';
|
||||
import { itemPresetsApi, type ItemPreset } from '$lib/api/dashboard/a76/item-presets';
|
||||
import { itemsApi, type Item } from '$lib/api/dashboard/a76/items';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import ItemSheetFa from '$lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte';
|
||||
import ItemSheetInv from '$lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte';
|
||||
|
||||
let presets = $state<ItemPreset[]>([]);
|
||||
let searchTerm = $state('');
|
||||
let isLoading = $state(true);
|
||||
let isSaving = $state(false);
|
||||
let showEditor = $state(false);
|
||||
let isEditMode = $state(false);
|
||||
let selectedPreset = $state<Partial<ItemPreset>>({
|
||||
name: '',
|
||||
description: '',
|
||||
items: []
|
||||
});
|
||||
|
||||
// Item Editor State (mimicking ItemsTabForm)
|
||||
let showItemSheet = $state(false);
|
||||
let isItemEditMode = $state(false);
|
||||
let editingItemIndex = $state<number | null>(null);
|
||||
let editingItem = $state<Partial<Item>>({});
|
||||
let itemSystem = $state<'fixed_asset' | 'scaii'>('scaii');
|
||||
|
||||
const activeCompanyId = $derived(companyStore.activeCompany?.id);
|
||||
|
||||
const filteredPresets = $derived(
|
||||
presets.filter((preset) => {
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
if (!term) return true;
|
||||
return (
|
||||
preset.name.toLowerCase().includes(term) ||
|
||||
preset.description?.toLowerCase().includes(term)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const totalPresetItems = $derived(
|
||||
presets.reduce((acc, preset) => acc + (preset.items?.length || 0), 0)
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (activeCompanyId) {
|
||||
loadPresets();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadPresets() {
|
||||
if (!activeCompanyId) return;
|
||||
isLoading = true;
|
||||
try {
|
||||
const response = await itemPresetsApi.list(activeCompanyId);
|
||||
presets = response.data || [];
|
||||
} catch (error) {
|
||||
console.error('Error loading presets:', error);
|
||||
toast.error('Error al cargar presets');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEditMode = false;
|
||||
selectedPreset = {
|
||||
name: '',
|
||||
description: '',
|
||||
items: []
|
||||
};
|
||||
showEditor = true;
|
||||
}
|
||||
|
||||
function handleEditPreset(preset: ItemPreset) {
|
||||
isEditMode = true;
|
||||
selectedPreset = JSON.parse(JSON.stringify(preset));
|
||||
// Basic inference of system from first item
|
||||
if (selectedPreset.items && selectedPreset.items.length > 0) {
|
||||
// This is a bit arbitrary, but scaii is default
|
||||
itemSystem = 'scaii';
|
||||
}
|
||||
showEditor = true;
|
||||
}
|
||||
|
||||
async function handleDeletePreset(id: number) {
|
||||
if (!activeCompanyId || !confirm('¿Estás seguro de eliminar esta plantilla?')) return;
|
||||
try {
|
||||
await itemPresetsApi.delete(id, activeCompanyId);
|
||||
toast.success('Plantilla eliminada');
|
||||
loadPresets();
|
||||
} catch (error) {
|
||||
console.error('Error deleting preset:', error);
|
||||
toast.error('Error al eliminar plantilla');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSavePreset() {
|
||||
if (!activeCompanyId || !selectedPreset.name) {
|
||||
toast.warning('El nombre es obligatorio');
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
if (isEditMode && selectedPreset.id) {
|
||||
await itemPresetsApi.update(selectedPreset.id, activeCompanyId, {
|
||||
name: selectedPreset.name,
|
||||
description: selectedPreset.description,
|
||||
items: selectedPreset.items
|
||||
});
|
||||
toast.success('Plantilla actualizada');
|
||||
} else {
|
||||
await itemPresetsApi.create(activeCompanyId, {
|
||||
name: selectedPreset.name!,
|
||||
description: selectedPreset.description,
|
||||
items: selectedPreset.items || []
|
||||
});
|
||||
toast.success('Plantilla creada');
|
||||
}
|
||||
showEditor = false;
|
||||
loadPresets();
|
||||
} catch (error) {
|
||||
console.error('Error saving preset:', error);
|
||||
toast.error('Error al guardar plantilla');
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Item Management Logic (within Preset) ---
|
||||
|
||||
function handleAddItem() {
|
||||
isItemEditMode = false;
|
||||
editingItemIndex = null;
|
||||
editingItem = {
|
||||
lines: [
|
||||
{
|
||||
line_number: (selectedPreset.items?.length || 0) + 1,
|
||||
quantity: { quantity: 1 },
|
||||
financial: { unit_cost_usd: 0 },
|
||||
customs: {},
|
||||
description: {},
|
||||
reference: {}
|
||||
}
|
||||
]
|
||||
};
|
||||
showItemSheet = true;
|
||||
}
|
||||
|
||||
function handleEditItem(index: number) {
|
||||
isItemEditMode = true;
|
||||
editingItemIndex = index;
|
||||
editingItem = JSON.parse(JSON.stringify(selectedPreset.items![index]));
|
||||
showItemSheet = true;
|
||||
}
|
||||
|
||||
function handleDeleteItem(index: number) {
|
||||
selectedPreset.items!.splice(index, 1);
|
||||
selectedPreset.items = [...selectedPreset.items!]; // Trigger reactivity
|
||||
}
|
||||
|
||||
function saveItemToPreset() {
|
||||
if (isItemEditMode && editingItemIndex !== null) {
|
||||
selectedPreset.items![editingItemIndex] = editingItem as any;
|
||||
} else {
|
||||
selectedPreset.items = [...(selectedPreset.items || []), editingItem as any];
|
||||
}
|
||||
showItemSheet = false;
|
||||
toast.success(
|
||||
isItemEditMode ? 'Partida actualizada en plantilla' : 'Partida añadida a plantilla'
|
||||
);
|
||||
}
|
||||
|
||||
// Mock invoice to satisfy ItemSheets
|
||||
const mockInvoice = $derived({
|
||||
system: itemSystem,
|
||||
invoice_number: 'PLANTILLA',
|
||||
operation_type: 'exp',
|
||||
purchase_order: ''
|
||||
} as any);
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto py-6">
|
||||
<div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onclick={() => goto('/dashboard/invoices')}>
|
||||
<ArrowLeft class="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">Plantillas de Partidas</h1>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Gestiona, edita y prueba plantillas reutilizables sin perderte en la UI.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<Button variant="outline" size="sm" onclick={loadPresets} disabled={isLoading}>
|
||||
<Loader2 class={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Refrescar
|
||||
</Button>
|
||||
<Button onclick={handleAdd}>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
Nueva Plantilla
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-3 mb-6">
|
||||
<div class="bg-white dark:bg-zinc-950 border rounded-lg p-4 shadow-sm">
|
||||
<p class="text-xs uppercase text-muted-foreground font-semibold">Total plantillas</p>
|
||||
<p class="text-2xl font-bold mt-1">{presets.length}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-zinc-950 border rounded-lg p-4 shadow-sm">
|
||||
<p class="text-xs uppercase text-muted-foreground font-semibold">Partidas totales</p>
|
||||
<p class="text-2xl font-bold mt-1">{totalPresetItems}</p>
|
||||
</div>
|
||||
<div class="bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg p-4 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs uppercase font-semibold opacity-80">Tip</p>
|
||||
<p class="text-sm mt-1 opacity-90">Usa plantillas para acelerar la captura.</p>
|
||||
</div>
|
||||
<Sparkles class="h-6 w-6 opacity-80" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header class="pb-3">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="space-y-1">
|
||||
<Card.Title class="text-base">Listado de plantillas</Card.Title>
|
||||
<Card.Description>Mira, filtra y edita tus plantillas guardadas.</Card.Description>
|
||||
</div>
|
||||
<div class="relative w-full lg:w-64">
|
||||
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar por nombre o descripción"
|
||||
class="pl-9"
|
||||
bind:value={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="p-0">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Nombre</Table.Head>
|
||||
<Table.Head>Descripción</Table.Head>
|
||||
<Table.Head class="text-center">Items</Table.Head>
|
||||
<Table.Head>Fecha</Table.Head>
|
||||
<Table.Head class="text-right">Acciones</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if isLoading}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={5} class="text-center py-10">
|
||||
<Loader2 class="h-8 w-8 animate-spin mx-auto text-primary" />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each filteredPresets as preset}
|
||||
<Table.Row class="hover:bg-muted/40 transition-colors">
|
||||
<Table.Cell class="font-medium">{preset.name}</Table.Cell>
|
||||
<Table.Cell class="max-w-[280px] text-sm text-muted-foreground">
|
||||
<span class="line-clamp-2">{preset.description || 'Sin descripción'}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-center text-sm font-semibold">
|
||||
{preset.items?.length || 0} partidas
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-sm text-muted-foreground">
|
||||
{preset.created_at
|
||||
? new Date(preset.created_at).toLocaleDateString()
|
||||
: '-'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button size="icon" variant="ghost" onclick={() => handleEditPreset(preset)}>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onclick={() => handleDeletePreset(preset.id)}
|
||||
>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{#if filteredPresets.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={5} class="text-center py-12 text-muted-foreground">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<Package class="h-8 w-8 opacity-30" />
|
||||
<p class="text-sm">No hay plantillas que coincidan con tu búsqueda.</p>
|
||||
<Button variant="outline" size="sm" onclick={handleAdd}>
|
||||
<Plus class="w-4 h-4 mr-2" /> Crear la primera
|
||||
</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<!-- Editor como popup modal -->
|
||||
<Dialog.Root bind:open={showEditor}>
|
||||
<Dialog.Content class="sm:max-w-5xl max-h-[90vh] p-0 overflow-hidden">
|
||||
<div class="bg-white dark:bg-zinc-950 border-b dark:border-zinc-800 px-6 py-4 shadow-sm flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 p-2 rounded-lg text-primary">
|
||||
<Package class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Dialog.Title class="text-lg font-bold">
|
||||
{isEditMode ? 'Editar Plantilla' : 'Nueva Plantilla'}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-muted-foreground">
|
||||
Configura los items que compondrán esta plantilla.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" onclick={() => (showEditor = false)} disabled={isSaving}
|
||||
>Cancelar</Button
|
||||
>
|
||||
<Button
|
||||
onclick={handleSavePreset}
|
||||
disabled={isSaving}
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{#if isSaving}
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
Guardando...
|
||||
{:else}
|
||||
<Save class="mr-2 h-4 w-4" />
|
||||
{isEditMode ? 'Actualizar Plantilla' : 'Crear Plantilla'}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 overflow-auto max-h-[calc(90vh-88px)] bg-slate-50/70 dark:bg-black">
|
||||
<div class="max-w-6xl mx-auto space-y-6">
|
||||
<!-- Metadata Card -->
|
||||
<div class="bg-white dark:bg-zinc-950 p-6 rounded-lg border shadow-sm space-y-4">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Información General
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<Label for="name">Nombre de la Plantilla</Label>
|
||||
<Input
|
||||
id="name"
|
||||
bind:value={selectedPreset.name}
|
||||
placeholder="Ej. Aceros Estándar"
|
||||
class="bg-slate-50/50"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="description">Descripción (Opcional)</Label>
|
||||
<Input
|
||||
id="description"
|
||||
bind:value={selectedPreset.description}
|
||||
placeholder="Breve descripción..."
|
||||
class="bg-slate-50/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items Management Card -->
|
||||
<div class="bg-white dark:bg-zinc-950 p-6 rounded-lg border shadow-sm space-y-4">
|
||||
<div class="flex justify-between items-start gap-3 flex-wrap">
|
||||
<div class="flex flex-col">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Partidas de la Plantilla
|
||||
</h3>
|
||||
<p class="text-[11px] text-muted-foreground mt-1">
|
||||
Estas partidas se inyectarán en la factura al cargar la plantilla.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 mr-2">
|
||||
<Label class="text-[11px] text-zinc-400">Sistema:</Label>
|
||||
<select
|
||||
bind:value={itemSystem}
|
||||
class="text-[11px] border rounded-md px-2 py-1 bg-slate-50 dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800"
|
||||
>
|
||||
<option value="scaii">SCAII (Inventario)</option>
|
||||
<option value="fixed_asset">Fixed Asset</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button size="sm" onclick={handleAddItem} class="h-8">
|
||||
<Plus class="w-3.5 h-3.5 mr-2" />
|
||||
Agregar Partida
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-md overflow-hidden bg-white dark:bg-zinc-950">
|
||||
<Table.Root>
|
||||
<Table.Header class="bg-zinc-50 dark:bg-zinc-900/50">
|
||||
<Table.Row>
|
||||
<Table.Head class="w-12 text-center">#</Table.Head>
|
||||
<Table.Head>Descripción (ES)</Table.Head>
|
||||
<Table.Head class="text-center">Cantidad</Table.Head>
|
||||
<Table.Head class="text-center">Precio (USD)</Table.Head>
|
||||
<Table.Head class="text-right pr-6">Acciones</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if !selectedPreset.items || selectedPreset.items.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={5} class="text-center py-16 text-muted-foreground">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<Package class="h-8 w-8 opacity-20" />
|
||||
<p class="text-sm italic">No hay partidas agregadas aún.</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={handleAddItem}
|
||||
class="mt-2 text-primary"
|
||||
>
|
||||
Haz clic aquí para agregar la primera
|
||||
</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each selectedPreset.items as item, i}
|
||||
<Table.Row class="hover:bg-slate-50/50 dark:hover:bg-zinc-900/30">
|
||||
<Table.Cell class="text-center font-mono text-[11px] text-zinc-400"
|
||||
>{i + 1}</Table.Cell
|
||||
>
|
||||
<Table.Cell>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-sm">
|
||||
{item.lines?.[0]?.description?.description_spanish || 'Sin descripción'}
|
||||
</span>
|
||||
{#if item.reference_number}
|
||||
<span class="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
<FileText class="w-3 h-3" /> Ref: {item.reference_number}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-center tabular-nums font-medium"
|
||||
>{item.lines?.[0]?.quantity?.quantity || 0}</Table.Cell
|
||||
>
|
||||
<Table.Cell
|
||||
class="text-center tabular-nums font-medium text-emerald-600 dark:text-emerald-400"
|
||||
>
|
||||
${(item.lines?.[0]?.financial?.unit_cost_usd || 0).toLocaleString()}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right pr-6">
|
||||
<div class="flex justify-end gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onclick={() => handleEditItem(i)}
|
||||
class="h-8 w-8 text-zinc-500 hover:text-primary"
|
||||
>
|
||||
<Pencil class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onclick={() => handleDeleteItem(i)}
|
||||
class="h-8 w-8 text-zinc-500 hover:text-destructive"
|
||||
>
|
||||
<Trash2 class="h-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
{#if selectedPreset.items && selectedPreset.items.length > 0}
|
||||
<div class="flex justify-between items-center text-[11px] text-muted-foreground px-1">
|
||||
<p>
|
||||
Total de partidas: <span class="font-bold text-zinc-900 dark:text-zinc-100"
|
||||
>{selectedPreset.items.length}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Reuse ItemSheets -->
|
||||
{#if itemSystem === 'fixed_asset'}
|
||||
<ItemSheetFa
|
||||
bind:open={showItemSheet}
|
||||
isEditMode={isItemEditMode}
|
||||
bind:editingItem
|
||||
invoice={mockInvoice}
|
||||
onSave={saveItemToPreset}
|
||||
onCancel={() => (showItemSheet = false)}
|
||||
isSaving={false}
|
||||
/>
|
||||
{:else}
|
||||
<ItemSheetInv
|
||||
bind:open={showItemSheet}
|
||||
isEditMode={isItemEditMode}
|
||||
bind:editingItem
|
||||
invoice={mockInvoice}
|
||||
onSave={saveItemToPreset}
|
||||
onCancel={() => (showItemSheet = false)}
|
||||
isSaving={false}
|
||||
/>
|
||||
{/if}
|
||||
208
frontend/src/routes/dashboard/invoices/settings/+page.server.ts
Normal file
208
frontend/src/routes/dashboard/invoices/settings/+page.server.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { getAuthTokens, getActiveCompanyId, authenticatedFetch } from '$lib/server/api';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, fetch, url }) => {
|
||||
const { accessToken } = getAuthTokens(cookies);
|
||||
|
||||
if (!accessToken) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
|
||||
const companyId = await getActiveCompanyId(cookies, fetch);
|
||||
if (!companyId) {
|
||||
throw error(400, 'No se encontró una compañía seleccionada');
|
||||
}
|
||||
|
||||
// Load necessary reference data for the settings form
|
||||
// We need: InvoiceTypes, CustomsBrokers, Incoterms, etc. to populate the reusable forms
|
||||
|
||||
// We load minimal data initially, but to support all tabs we need more.
|
||||
// Reuse the same calls as in edit/[id] to ensure we have data for the dropdowns
|
||||
|
||||
const invoiceTypesPromise = authenticatedFetch(
|
||||
'v1/public/reference_data/invoice-types/?page=1&page_size=100',
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
|
||||
const customsBrokersPromise = authenticatedFetch(
|
||||
`v1/a76/customs-brokers/?company_id=${companyId}&page=1&page_size=100`,
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
|
||||
const clientsPromise = authenticatedFetch(
|
||||
`v1/a76/clients-providers/?company_id=${companyId}&type=client&page=1&page_size=1000`,
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
|
||||
const providersPromise = authenticatedFetch(
|
||||
`v1/a76/clients-providers/?company_id=${companyId}&type=provider&page=1&page_size=1000`,
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
|
||||
const currencyTypesPromise = authenticatedFetch(
|
||||
'v1/public/reference_data/currency-types/?page=1&page_size=100',
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
|
||||
const transportTypesPromise = authenticatedFetch(
|
||||
'v1/public/reference_data/transport-types/?page=1&page_size=100',
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
|
||||
const transportersPromise = authenticatedFetch(
|
||||
`v1/a76/transportation/transporters/?company_id=${companyId}&page=1&page_size=100`,
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
|
||||
const vehiclesPromise = authenticatedFetch(
|
||||
`v1/a76/transportation/vehicles/?company_id=${companyId}&page=1&page_size=100`,
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
|
||||
const driversPromise = authenticatedFetch(
|
||||
`v1/a76/transportation/drivers/?company_id=${companyId}`,
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
|
||||
const trailersPromise = authenticatedFetch(
|
||||
`v1/a76/transportation/trailers/?company_id=${companyId}&page=1&page_size=100`,
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
|
||||
const incotermsPromise = authenticatedFetch(
|
||||
'v1/public/reference_data/incoterms/?page=1&page_size=100',
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
|
||||
const customsSectionsPromise = authenticatedFetch(
|
||||
'v1/public/reference_data/customs-sections/?page=1&page_size=100',
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
|
||||
const codePedimentoRegimensPromise = authenticatedFetch(
|
||||
'v1/public/reference_data/code-pedimento-regimens/?page=1&page_size=1000',
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
|
||||
const transportModesPromise = authenticatedFetch(
|
||||
'v1/public/reference_data/transport-modes/?page=1&page_size=100',
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
|
||||
const pedimentosPromise = authenticatedFetch(
|
||||
`v1/a76/pedimentos/?company_id=${companyId}&page=1&page_size=100`,
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
|
||||
try {
|
||||
const [
|
||||
invoiceTypesResponse,
|
||||
customsBrokersResponse,
|
||||
clientsResponse,
|
||||
providersResponse,
|
||||
currencyTypesResponse,
|
||||
transportTypesResponse,
|
||||
transportersResponse,
|
||||
vehiclesResponse,
|
||||
driversResponse,
|
||||
trailersResponse,
|
||||
incotermsResponse,
|
||||
customsSectionsResponse,
|
||||
codePedimentoRegimensResponse,
|
||||
transportModesResponse,
|
||||
pedimentosResponse
|
||||
] = await Promise.all([
|
||||
invoiceTypesPromise,
|
||||
customsBrokersPromise,
|
||||
clientsPromise,
|
||||
providersPromise,
|
||||
currencyTypesPromise,
|
||||
transportTypesPromise,
|
||||
transportersPromise,
|
||||
vehiclesPromise,
|
||||
driversPromise,
|
||||
trailersPromise,
|
||||
incotermsPromise,
|
||||
customsSectionsPromise,
|
||||
codePedimentoRegimensPromise,
|
||||
transportModesPromise,
|
||||
pedimentosPromise
|
||||
]);
|
||||
|
||||
const invoiceTypes = invoiceTypesResponse.ok ? await invoiceTypesResponse.json() : { items: [] };
|
||||
const customsBrokers = customsBrokersResponse.ok ? await customsBrokersResponse.json() : { items: [] };
|
||||
const clients = clientsResponse.ok ? await clientsResponse.json() : { items: [] };
|
||||
const providers = providersResponse.ok ? await providersResponse.json() : { items: [] };
|
||||
const currencyTypes = currencyTypesResponse.ok ? await currencyTypesResponse.json() : { items: [] };
|
||||
const transportTypes = transportTypesResponse.ok ? await transportTypesResponse.json() : { items: [] };
|
||||
const transporters = transportersResponse.ok ? await transportersResponse.json() : { items: [] };
|
||||
const vehicles = vehiclesResponse.ok ? await vehiclesResponse.json() : { items: [] };
|
||||
const drivers = driversResponse.ok ? await driversResponse.json() : { items: [] };
|
||||
const trailers = trailersResponse.ok ? await trailersResponse.json() : { items: [] };
|
||||
const incoterms = incotermsResponse.ok ? await incotermsResponse.json() : { items: [] };
|
||||
const customsSections = customsSectionsResponse.ok ? await customsSectionsResponse.json() : { items: [] };
|
||||
const codePedimentoRegimens = codePedimentoRegimensResponse.ok ? await codePedimentoRegimensResponse.json() : { items: [] };
|
||||
const transportModes = transportModesResponse.ok ? await transportModesResponse.json() : { items: [] };
|
||||
const pedimentos = pedimentosResponse.ok ? await pedimentosResponse.json() : { items: [] };
|
||||
|
||||
return {
|
||||
invoiceTypes: invoiceTypes.items || [],
|
||||
customsBrokers: customsBrokers.items || [],
|
||||
clients: clients.items || [],
|
||||
providers: providers.items || [],
|
||||
currencyTypes: currencyTypes.items || [],
|
||||
transportTypes: transportTypes.items || [],
|
||||
transporters: transporters.items || [],
|
||||
vehicles: vehicles.items || [],
|
||||
drivers: drivers.items || [],
|
||||
trailers: trailers.items || [],
|
||||
incoterms: incoterms.items || [],
|
||||
customsSections: customsSections.items || [],
|
||||
codePedimentoRegimens: codePedimentoRegimens.items || [],
|
||||
transportModes: transportModes.items || [],
|
||||
pedimentos: pedimentos.items || [],
|
||||
companyId
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading settings data:', err);
|
||||
return {
|
||||
invoiceTypes: [],
|
||||
customsBrokers: [],
|
||||
incoterms: [],
|
||||
customsSections: [],
|
||||
companyId
|
||||
};
|
||||
}
|
||||
};
|
||||
585
frontend/src/routes/dashboard/invoices/settings/+page.svelte
Normal file
585
frontend/src/routes/dashboard/invoices/settings/+page.svelte
Normal file
@@ -0,0 +1,585 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { LoaderCircle, Save, FileText, Eye, DollarSign, Truck, Package } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||
// Import Form Components
|
||||
import GeneralTabForm from '$lib/components/dashboard/invoices/edit/general-tab-form.svelte';
|
||||
import ObservationsTabForm from '$lib/components/dashboard/invoices/edit/observations-tab-form.svelte';
|
||||
import ItemsTabForm from '$lib/components/dashboard/invoices/edit/items/items-tab-form.svelte';
|
||||
import OthersTabForm from '$lib/components/dashboard/invoices/edit/others-tab-form.svelte';
|
||||
import InvoiceTopFields from '$lib/components/dashboard/invoices/edit/invoice-top-fields.svelte';
|
||||
import ContinuationTabForm from '$lib/components/dashboard/invoices/edit/continuation-tab-form.svelte';
|
||||
|
||||
// Props
|
||||
let { data } = $props();
|
||||
|
||||
// State
|
||||
let selectedInvoiceType = $state<string>('');
|
||||
let selectedOperationType = $state<string>('');
|
||||
let isLoading = $state(false);
|
||||
let isSaving = $state(false);
|
||||
let activeTab = $state('general');
|
||||
let companyStore: any = $state(undefined);
|
||||
|
||||
// Form Data State
|
||||
let InvoiceTopFieldsFormData = $state<any>({});
|
||||
let generalFormData = $state<any>({});
|
||||
let observationFormData = $state<any>({});
|
||||
let itemsFormData = $state<any>({});
|
||||
let othersFormData = $state<any>({
|
||||
comments_status: '',
|
||||
transport_mode: 'TRUCK',
|
||||
is_mixed: null,
|
||||
print_stamp: false,
|
||||
rule_3121_parties_ii: false,
|
||||
related_doc_id: null,
|
||||
code_signature: '',
|
||||
electronic_signature: '',
|
||||
mandatory_person: '',
|
||||
contingency_mode: false,
|
||||
cove: '',
|
||||
operation_num: '',
|
||||
adendas: '',
|
||||
observations_vu: '',
|
||||
certified_number: ''
|
||||
});
|
||||
let continuationFormData = $state<any>({
|
||||
numero_tipo_transporte: '',
|
||||
es_ferrocarril: 'no',
|
||||
numero_bl: '',
|
||||
cantidad_guias_embarque: null,
|
||||
destino_origen: '',
|
||||
puerto_entrada: '',
|
||||
fue_revisado_equipo: false,
|
||||
sub_division: false,
|
||||
funge_como_cd: false,
|
||||
llego_pedimento: false,
|
||||
errores_facturacion: [],
|
||||
semaforo_verde_aduana_mexicana: false,
|
||||
semaforo_verde_aduana_americana: false,
|
||||
semaforo_rojo_aduana_mexicana: false,
|
||||
semaforo_rojo_aduana_americana: false
|
||||
});
|
||||
|
||||
// Dummy objects for components
|
||||
let invoice = $state<any>({
|
||||
financials: { exchange_rate: 0 }
|
||||
});
|
||||
|
||||
// Existence flags (not strictly needed for settings but required by components)
|
||||
let observationExists = $state(false);
|
||||
let itemsExists = $state(false);
|
||||
let othersExists = $state(false);
|
||||
let continuationExists = $state(false);
|
||||
|
||||
const operationTypes = [
|
||||
{ value: 'imp', label: 'Importación' },
|
||||
{ value: 'exp', label: 'Exportación' }
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const companyStoreModule = await import('$lib/stores/company.svelte');
|
||||
companyStore = companyStoreModule.companyStore;
|
||||
} catch (err) {
|
||||
console.error('Error loading company store:', err);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
if (!selectedInvoiceType || !selectedOperationType || !companyStore?.activeCompany?.id) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const token = $page.data.user?.token;
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`/api/v1/a76/invoice-settings/${selectedInvoiceType}?operation_type=${selectedOperationType}&company_id=${companyStore.activeCompany.id}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
const settingsData = await res.json();
|
||||
if (settingsData && settingsData.settings) {
|
||||
applySettings(settingsData.settings);
|
||||
} else {
|
||||
resetForms();
|
||||
}
|
||||
} else {
|
||||
resetForms();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading settings', error);
|
||||
resetForms();
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applySettings(settings: any) {
|
||||
InvoiceTopFieldsFormData = {
|
||||
is_pedimento_pending: false,
|
||||
pedimento_id: '',
|
||||
remesa: '',
|
||||
invoice_number: '',
|
||||
invoice_date: new Date().toISOString().split('T')[0],
|
||||
emission_date: new Date().toISOString().split('T')[0],
|
||||
operation_type: selectedOperationType,
|
||||
invoice_type: selectedInvoiceType,
|
||||
fecha_pedimento_del: '',
|
||||
fecha_pedimento_al: '',
|
||||
clave_pedimento: '',
|
||||
regimen_pedimento: '',
|
||||
...(settings.InvoiceTopFieldsFormData || {})
|
||||
};
|
||||
generalFormData = {
|
||||
provider_header: 'proveedor',
|
||||
provider_id: null,
|
||||
sold_to_header: 'consignado_a',
|
||||
sold_to_id: null,
|
||||
shipped_to_header: 'enviado_a',
|
||||
shipped_to_id: null,
|
||||
customs_broker_id: null,
|
||||
customs_broker_us_id: null,
|
||||
currency_type: '',
|
||||
currency: 'foreign',
|
||||
exchange_rate: null,
|
||||
weight_type: 'kgs',
|
||||
iva_factor: null,
|
||||
carrier_id: null,
|
||||
transport_id: '',
|
||||
driver_name: '',
|
||||
transport_type: '',
|
||||
transport_num: '',
|
||||
aduana: '',
|
||||
document_type: '',
|
||||
...(settings.generalFormData || {})
|
||||
};
|
||||
observationFormData = {
|
||||
observation_es: '',
|
||||
observation_en: '',
|
||||
freight: null,
|
||||
insurance_value: null,
|
||||
insurance: null,
|
||||
packaging: null,
|
||||
other_increments: null,
|
||||
total_increments_mn: null,
|
||||
total_increments_me: null,
|
||||
incoterm: null,
|
||||
enclosure: null,
|
||||
num_seals: null,
|
||||
movement_type: '',
|
||||
alternate_invoice: '',
|
||||
valuation_method: null,
|
||||
...(settings.observationFormData || {})
|
||||
};
|
||||
itemsFormData = settings.itemsFormData || {};
|
||||
othersFormData = {
|
||||
comments_status: '',
|
||||
transport_mode: 'TRUCK',
|
||||
is_mixed: null,
|
||||
print_stamp: false,
|
||||
rule_3121_parties_ii: false,
|
||||
related_doc_id: null,
|
||||
code_signature: '',
|
||||
electronic_signature: '',
|
||||
mandatory_person: '',
|
||||
contingency_mode: false,
|
||||
cove: '',
|
||||
operation_num: '',
|
||||
adendas: '',
|
||||
observations_vu: '',
|
||||
certified_number: '',
|
||||
...(settings.othersFormData || {})
|
||||
};
|
||||
continuationFormData = {
|
||||
numero_tipo_transporte: '',
|
||||
es_ferrocarril: 'no',
|
||||
numero_bl: '',
|
||||
cantidad_guias_embarque: null,
|
||||
destino_origen: '',
|
||||
puerto_entrada: '',
|
||||
fue_revisado_equipo: false,
|
||||
sub_division: false,
|
||||
funge_como_cd: false,
|
||||
llego_pedimento: false,
|
||||
errores_facturacion: [],
|
||||
semaforo_verde_aduana_mexicana: false,
|
||||
semaforo_verde_aduana_americana: false,
|
||||
semaforo_rojo_aduana_mexicana: false,
|
||||
semaforo_rojo_aduana_americana: false,
|
||||
...(settings.continuationFormData || {})
|
||||
};
|
||||
|
||||
// Update the 'invoice' dummy object to reflect some top fields if needed for display
|
||||
invoice = {
|
||||
...invoice,
|
||||
...InvoiceTopFieldsFormData,
|
||||
operation_type: selectedOperationType,
|
||||
invoice_type: selectedInvoiceType
|
||||
};
|
||||
}
|
||||
|
||||
function resetForms() {
|
||||
InvoiceTopFieldsFormData = {
|
||||
is_pedimento_pending: false,
|
||||
pedimento_id: '',
|
||||
remesa: '',
|
||||
invoice_number: '',
|
||||
invoice_date: new Date().toISOString().split('T')[0],
|
||||
emission_date: new Date().toISOString().split('T')[0],
|
||||
operation_type: selectedOperationType,
|
||||
invoice_type: selectedInvoiceType,
|
||||
fecha_pedimento_del: '',
|
||||
fecha_pedimento_al: '',
|
||||
clave_pedimento: '',
|
||||
regimen_pedimento: ''
|
||||
};
|
||||
generalFormData = {
|
||||
provider_header: 'proveedor',
|
||||
provider_id: null,
|
||||
sold_to_header: 'consignado_a',
|
||||
sold_to_id: null,
|
||||
shipped_to_header: 'enviado_a',
|
||||
shipped_to_id: null,
|
||||
customs_broker_id: null,
|
||||
customs_broker_us_id: null,
|
||||
currency_type: '',
|
||||
currency: 'foreign',
|
||||
exchange_rate: null,
|
||||
weight_type: 'kgs',
|
||||
iva_factor: null,
|
||||
carrier_id: null,
|
||||
transport_id: '',
|
||||
driver_name: '',
|
||||
transport_type: '',
|
||||
transport_num: '',
|
||||
aduana: '',
|
||||
document_type: ''
|
||||
};
|
||||
observationFormData = {
|
||||
observation_es: '',
|
||||
observation_en: '',
|
||||
freight: null,
|
||||
insurance_value: null,
|
||||
insurance: null,
|
||||
packaging: null,
|
||||
other_increments: null,
|
||||
total_increments_mn: null,
|
||||
total_increments_me: null,
|
||||
incoterm: null,
|
||||
enclosure: null,
|
||||
num_seals: null,
|
||||
movement_type: '',
|
||||
alternate_invoice: '',
|
||||
valuation_method: null
|
||||
};
|
||||
itemsFormData = {};
|
||||
othersFormData = {
|
||||
comments_status: '',
|
||||
transport_mode: 'TRUCK',
|
||||
is_mixed: null,
|
||||
print_stamp: false,
|
||||
rule_3121_parties_ii: false,
|
||||
related_doc_id: null,
|
||||
code_signature: '',
|
||||
electronic_signature: '',
|
||||
mandatory_person: '',
|
||||
contingency_mode: false,
|
||||
cove: '',
|
||||
operation_num: '',
|
||||
adendas: '',
|
||||
observations_vu: '',
|
||||
certified_number: ''
|
||||
};
|
||||
continuationFormData = {
|
||||
numero_tipo_transporte: '',
|
||||
es_ferrocarril: 'no',
|
||||
numero_bl: '',
|
||||
cantidad_guias_embarque: null,
|
||||
destino_origen: '',
|
||||
puerto_entrada: '',
|
||||
fue_revisado_equipo: false,
|
||||
sub_division: false,
|
||||
funge_como_cd: false,
|
||||
llego_pedimento: false,
|
||||
errores_facturacion: [],
|
||||
semaforo_verde_aduana_mexicana: false,
|
||||
semaforo_verde_aduana_americana: false,
|
||||
semaforo_rojo_aduana_mexicana: false,
|
||||
semaforo_rojo_aduana_americana: false
|
||||
};
|
||||
invoice = { financials: { exchange_rate: 0 } };
|
||||
}
|
||||
|
||||
function cleanObject(obj: any): any {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(cleanObject).filter((v) => v !== null && v !== undefined && v !== '');
|
||||
}
|
||||
if (obj !== null && typeof obj === 'object') {
|
||||
return Object.entries(obj).reduce((acc: any, [key, value]) => {
|
||||
const cleaned = cleanObject(value);
|
||||
if (
|
||||
cleaned !== null &&
|
||||
cleaned !== undefined &&
|
||||
cleaned !== '' &&
|
||||
!(typeof cleaned === 'object' && Object.keys(cleaned).length === 0)
|
||||
) {
|
||||
acc[key] = cleaned;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
async function handleSaveSettings() {
|
||||
if (!selectedInvoiceType || !selectedOperationType || !companyStore?.activeCompany?.id) {
|
||||
toast.error('Por favor selecciona tipo de factura y operación');
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
// Use snaphot to get clean data from Svelte 5 proxies
|
||||
const rawSettings = {
|
||||
InvoiceTopFieldsFormData: $state.snapshot(InvoiceTopFieldsFormData),
|
||||
generalFormData: $state.snapshot(generalFormData),
|
||||
observationFormData: $state.snapshot(observationFormData),
|
||||
itemsFormData: $state.snapshot(itemsFormData),
|
||||
othersFormData: $state.snapshot(othersFormData),
|
||||
continuationFormData: $state.snapshot(continuationFormData)
|
||||
};
|
||||
|
||||
// Clean the settings to only save what's configured
|
||||
const cleanedSettings = cleanObject(rawSettings);
|
||||
|
||||
const settingsPayload = {
|
||||
invoice_type: selectedInvoiceType,
|
||||
operation_type: selectedOperationType,
|
||||
settings: cleanedSettings
|
||||
};
|
||||
|
||||
try {
|
||||
const token = $page.data.user?.token;
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/v1/a76/invoice-settings/?company_id=${companyStore.activeCompany.id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(settingsPayload)
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Configuración guardada correctamente.');
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
console.error('Save error details:', errorData);
|
||||
toast.error('Error al guardar la configuración.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
toast.error('Ocurrió un error al guardar.');
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for selection changes
|
||||
$effect(() => {
|
||||
if (selectedInvoiceType && selectedOperationType && companyStore?.activeCompany?.id) {
|
||||
loadSettings();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto py-6 pb-32">
|
||||
<!-- Added padding bottom for footer -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">Configuración de Facturas</h1>
|
||||
<p class="text-muted-foreground">
|
||||
Define los valores predeterminados para la creación de facturas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card.Root class="mb-6">
|
||||
<Card.Header>
|
||||
<Card.Title>Contexto de Configuración</Card.Title>
|
||||
<Card.Description
|
||||
>Selecciona el contexto para editar sus valores por defecto.</Card.Description
|
||||
>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex gap-4">
|
||||
<div class="w-[250px]">
|
||||
<Label>Tipo de Operación</Label>
|
||||
<Select type="single" bind:value={selectedOperationType}>
|
||||
<SelectTrigger>
|
||||
{operationTypes.find((t) => t.value === selectedOperationType)?.label ||
|
||||
'Seleccionar...'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each operationTypes as type}
|
||||
<SelectItem value={type.value}>{type.label}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="w-[350px]">
|
||||
<Label>Tipo de Factura</Label>
|
||||
<Select type="single" bind:value={selectedInvoiceType}>
|
||||
<SelectTrigger>
|
||||
{@const type = data.invoiceTypes.find((t) => t.key === selectedInvoiceType)}
|
||||
{type ? `${type.key} - ${type.description}` : 'Seleccionar...'}
|
||||
</SelectTrigger>
|
||||
<SelectContent class="max-h-[300px]">
|
||||
{#each data.invoiceTypes as type}
|
||||
<SelectItem value={type.key}>{type.key} - {type.description}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
{#if selectedInvoiceType && selectedOperationType}
|
||||
{#if isLoading}
|
||||
<div class="flex justify-center py-12">
|
||||
<LoaderCircle class="animate-spin h-8 w-8 text-primary" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-6">
|
||||
<!-- Top Fields (Header) -->
|
||||
<div class="bg-background rounded-lg border p-4">
|
||||
<h3 class="font-medium mb-4">Encabezado</h3>
|
||||
<InvoiceTopFields
|
||||
{invoice}
|
||||
bind:formData={InvoiceTopFieldsFormData}
|
||||
invoiceTypes={data.invoiceTypes || []}
|
||||
pedimentos={data.pedimentos || []}
|
||||
defaultOperationType={selectedOperationType}
|
||||
defaultInvoiceType={selectedInvoiceType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tabs for detailed sections -->
|
||||
<Tabs.Root bind:value={activeTab}>
|
||||
<Tabs.List class="grid w-full grid-cols-5 bg-muted rounded-md p-1 mb-4">
|
||||
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
||||
<Tabs.Trigger value="observations">Observaciones</Tabs.Trigger>
|
||||
<Tabs.Trigger value="items">Partidas</Tabs.Trigger>
|
||||
<Tabs.Trigger value="others">Otros</Tabs.Trigger>
|
||||
<Tabs.Trigger value="continuation">Continuación</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="general">
|
||||
<GeneralTabForm
|
||||
{invoice}
|
||||
bind:formData={generalFormData}
|
||||
invoiceTypes={data.invoiceTypes || []}
|
||||
customsBrokers={data.customsBrokers || []}
|
||||
clients={data.clients || []}
|
||||
providers={data.providers || []}
|
||||
currencyTypes={data.currencyTypes || []}
|
||||
transportTypes={data.transportTypes || []}
|
||||
transporters={data.transporters || []}
|
||||
vehicles={data.vehicles || []}
|
||||
drivers={data.drivers || []}
|
||||
trailers={data.trailers || []}
|
||||
customsSections={data.customsSections || []}
|
||||
codePedimentoRegimens={data.codePedimentoRegimens || []}
|
||||
operationType={selectedOperationType === 'exp' ? 1 : 2}
|
||||
exchangeRate={0}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="observations">
|
||||
<ObservationsTabForm
|
||||
{invoice}
|
||||
bind:formData={observationFormData}
|
||||
bind:exists={observationExists}
|
||||
seals={[]}
|
||||
incoterms={data.incoterms || []}
|
||||
enclosure={[]}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="items">
|
||||
<div class="p-4 border rounded bg-muted/20 text-center">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
La configuración predeterminada de partidas es limitada.
|
||||
</p>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="others">
|
||||
<OthersTabForm
|
||||
{invoice}
|
||||
bind:formData={othersFormData}
|
||||
bind:exists={othersExists}
|
||||
transportModes={data.transportModes || []}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="continuation">
|
||||
<ContinuationTabForm
|
||||
{invoice}
|
||||
bind:formData={continuationFormData}
|
||||
bind:exists={continuationExists}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-12 text-muted-foreground"
|
||||
>
|
||||
<FileText class="h-10 w-10 mb-2 opacity-20" />
|
||||
<p>Selecciona un tipo de operación y factura para comenzar.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sticky Footer -->
|
||||
<div
|
||||
class="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur border-t shadow-lg z-10 p-4 transition-all duration-300 group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] ml-[calc(var(--sidebar-width))]"
|
||||
>
|
||||
<div class="container mx-auto max-w-[1400px] flex justify-end gap-3">
|
||||
<Button variant="outline" onclick={resetForms} disabled={isSaving || !selectedInvoiceType}>
|
||||
Restablecer
|
||||
</Button>
|
||||
<Button onclick={handleSaveSettings} disabled={isSaving || !selectedInvoiceType}>
|
||||
{#if isSaving}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
Guardando...
|
||||
{:else}
|
||||
<Save class="mr-2 h-4 w-4" />
|
||||
Guardar Configuración
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -11,6 +11,13 @@ export default defineConfig({
|
||||
'anexo76-dev.aduanasoft.com',
|
||||
// 'otro-host.com' si necesitas más
|
||||
],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:8000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
|
||||
Reference in New Issue
Block a user