diff --git a/backend/alembic/versions/703f96cf6025_create_invoice_settings_table.py b/backend/alembic/versions/703f96cf6025_create_invoice_settings_table.py new file mode 100644 index 00000000..0dfdbf2c --- /dev/null +++ b/backend/alembic/versions/703f96cf6025_create_invoice_settings_table.py @@ -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 diff --git a/backend/alembic/versions/8a607a5e88bf_create_item_presets_table.py b/backend/alembic/versions/8a607a5e88bf_create_item_presets_table.py new file mode 100644 index 00000000..2b02febc --- /dev/null +++ b/backend/alembic/versions/8a607a5e88bf_create_item_presets_table.py @@ -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 diff --git a/backend/api/v1/modules/a76/invoice_settings/dto.py b/backend/api/v1/modules/a76/invoice_settings/dto.py new file mode 100644 index 00000000..07c954a8 --- /dev/null +++ b/backend/api/v1/modules/a76/invoice_settings/dto.py @@ -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) diff --git a/backend/api/v1/modules/a76/invoice_settings/models.py b/backend/api/v1/modules/a76/invoice_settings/models.py new file mode 100644 index 00000000..c9751664 --- /dev/null +++ b/backend/api/v1/modules/a76/invoice_settings/models.py @@ -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"" diff --git a/backend/api/v1/modules/a76/invoice_settings/routes.py b/backend/api/v1/modules/a76/invoice_settings/routes.py new file mode 100644 index 00000000..07e37459 --- /dev/null +++ b/backend/api/v1/modules/a76/invoice_settings/routes.py @@ -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 + ) diff --git a/backend/api/v1/modules/a76/invoice_settings/services.py b/backend/api/v1/modules/a76/invoice_settings/services.py new file mode 100644 index 00000000..3499a700 --- /dev/null +++ b/backend/api/v1/modules/a76/invoice_settings/services.py @@ -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 diff --git a/backend/api/v1/modules/a76/item_presets/dto.py b/backend/api/v1/modules/a76/item_presets/dto.py new file mode 100644 index 00000000..e37655c9 --- /dev/null +++ b/backend/api/v1/modules/a76/item_presets/dto.py @@ -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) diff --git a/backend/api/v1/modules/a76/item_presets/models.py b/backend/api/v1/modules/a76/item_presets/models.py new file mode 100644 index 00000000..a63dcd4b --- /dev/null +++ b/backend/api/v1/modules/a76/item_presets/models.py @@ -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"" diff --git a/backend/api/v1/modules/a76/item_presets/routes.py b/backend/api/v1/modules/a76/item_presets/routes.py new file mode 100644 index 00000000..0c5b356b --- /dev/null +++ b/backend/api/v1/modules/a76/item_presets/routes.py @@ -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"} diff --git a/backend/api/v1/modules/a76/item_presets/services.py b/backend/api/v1/modules/a76/item_presets/services.py new file mode 100644 index 00000000..85274c1a --- /dev/null +++ b/backend/api/v1/modules/a76/item_presets/services.py @@ -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 diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index d4ad9ad2..89ef4479 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -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"] diff --git a/frontend/src/lib/api/dashboard/a76/item-presets.ts b/frontend/src/lib/api/dashboard/a76/item-presets.ts new file mode 100644 index 00000000..a59f076c --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/item-presets.ts @@ -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(`/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(`/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(`/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(`/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()}`); + } +}; diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/class-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/class-dialog.svelte index a1c9d75b..1e74393d 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/class-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/class-dialog.svelte @@ -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 @@ Seleccionar Clase - - Busca y selecciona una clase para la partida - + Busca y selecciona una clase para la partida
@@ -146,9 +146,14 @@ {:else} {#each displayedClasses as classItem} - handleSelect(classItem)}> + handleSelect(classItem)} + > {classItem.class_code} - {classItem.description_es || classItem.description_en || '-'} + {classItem.description_es || classItem.description_en || '-'} {classItem.unit_of_measure || '-'}
- +
-
+ + + +
+
+ +
{#if line} @@ -187,23 +216,5 @@ {/if}
- -
-
- - -
-
- - - \ No newline at end of file +
+ \ No newline at end of file diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte index 7300fc2d..3a6954bc 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte @@ -1,10 +1,11 @@ - - - - {isEditMode ? 'Editar Item' : 'Agregar Nuevo Item'} - SCAII (Inventario) - - {isEditMode - ? 'Modifica los campos del inventario y guarda los cambios.' - : 'Completa la información del nuevo item de inventario.'} - - - - - - General - Clasificación - Cantidades - Otros - - - - - -
-

Información de la Factura (SCAII - Inventario)

- {#if !invoice?.id} -
- ⚠️ Esta factura aún no se ha guardado. Los items se asociarán cuando guardes la - factura. -
+ + +
+
+ + {isEditMode ? 'Editar Item' : 'Agregar Nuevo Item'} - SCAII (Inventario) + + + {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} + + {editingItem.lines.length} items en esta partida + + {/if} + +
+
+ + +
+
+ +
+ + + General + Clasificación + Cantidades + Otros + + + + + + {#if !isTargetingPreset} +
+

Información de la Factura (SCAII - Inventario)

+ {#if !invoice?.id} +
+ ⚠️ Esta factura aún no se ha guardado. Los items se asociarán cuando guardes la + factura. +
+ {:else} +
+
+ ID Factura: + {invoice.id} +
+
+ Tipo Operación: + {invoice.operation_type || 'N/A'} +
+
+ Número de Factura: + {invoice.invoice_number || 'Pendiente'} +
+
+ Sistema: + SCAII (Inventory) +
+
+ {/if}
{/if} -
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- - -
- - -
- -
-
- - -
-
- - -
-
- - - - -
-
- - -
- - + +
- - -
-
- -
- - -
- -
-
- - -
-
- - -
-
-
-
- - - -
-
-
- - -
-
- - + +
- - + +
- - -
-
- -
-
- - -
-
- - + +
-
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
-
-
- - - -
- - + + {#if line?.description} + + {/if}
- - + + {#if line} + + {/if}
- - + + {#if line?.description} + + {/if}
+ -
+ + +
- - + + {#if line?.customs} + + {/if}
+ +
+
+ + {#if line} + + {/if} +
+
+ + {#if line} + + {/if} +
+
+
- - + + +
+ +
+
+ + +
+
+ + +
+
-
- - + + +
+
+
+ + {#if line?.quantity} + + {/if} +
+
+ + {#if line?.quantity} + + {/if} +
+
+ +
+
+ + {#if line?.quantity} + + {/if} +
+
+ + {#if line?.quantity} + + {/if} +
+
+ +
+
+ + {#if line?.financial} + + {/if} +
+
+ + +
+
+ + +
+
+ + {#if line?.quantity} + + {/if} +
+
+ + {#if line?.quantity} + + {/if} +
+
+ +
+
+ + {#if line?.quantity} + + {/if} +
+
+ + {#if line?.quantity} + + {/if} +
+
-
- - + - - - - - - + + +
+
+ + {#if line?.description} + + {/if} +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + {#if line?.description} + + {/if} +
+
+
+ +
+ + diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte index 785a6bb3..e94caead 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte @@ -2,15 +2,31 @@ import * as Table from '$lib/components/ui/table'; import * as Dialog from '$lib/components/ui/dialog'; import { Button } from '$lib/components/ui/button'; - import { Plus, Pencil, Trash2, Loader2 } from 'lucide-svelte'; + import { Input } from '$lib/components/ui/input'; + import { Textarea } from '$lib/components/ui/textarea'; + import { Badge } from '$lib/components/ui/badge'; + import { + Plus, + Pencil, + Trash2, + Loader2, + Save as SaveIcon, + Sparkles, + Search, + PackageOpen, + Calendar, + LayoutTemplate, + X + } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; import type { Invoice } from '$lib/api/dashboard/a76/invoices'; import { itemsApi, type Item } from '$lib/api/dashboard/a76/items'; import { companyStore } from '$lib/stores/company.svelte'; import ItemSheetFa from './fa/item-sheet-fa.svelte'; import ItemSheetInv from './inv/item-sheet-inv.svelte'; - import { useShortcuts } from '$lib/hooks/use-shortcuts'; - import { obtenerAtajosListaPartidas } from '$lib/config/shortcuts/dashboard/invoices/item/list'; + import { itemPresetsApi, type ItemPreset } from '$lib/api/dashboard/a76/item-presets'; + import { Checkbox } from '$lib/components/ui/checkbox'; + import { cleanLineData } from '$lib/utils/items-logic'; let { invoice, @@ -22,40 +38,103 @@ exists?: boolean; } = $props(); - let imported = 0; - let net_weight = 0; - let gross_weight = 0; - + // 1. Core State let items = $state([]); let displayedItems = $state([]); + let imported = $state(0); + let net_weight = $state(0); + let gross_weight = $state(0); let itemsPerPage = 20; + let isLoadingItems = $state(false); + let isLoadingMore = $state(false); let currentPage = $state(1); - // Aplanar items en líneas para la tabla - const flattenedLines = $derived( - items.flatMap((item) => - (item.lines || []).map((line) => ({ - ...line, - item_id: item.id, - reference_number: item.reference_number, - order: item.order, - warehouse: item.warehouse, - location: item.location, - full_item: item - })) - ) - ); - let tableContainer: HTMLDivElement | undefined = $state(); - let isLoadingMore = $state(false); - let isLoadingItems = $state(false); + // 2. Preset State + let presets = $state([]); + let selectedPreset = $state(null); + let searchPresets = $state(''); + let isLoadingPresets = $state(false); + let isApplyingPreset = $state(false); + let showUsePresetDialog = $state(false); + // Preset Creation State + let showCreatePresetDialog = $state(false); + let createPresetName = $state(''); + let createPresetDescription = $state(''); + let builderItems: Item[] = $state([]); + let isSavingPreset = $state(false); let isSaving = $state(false); + let isTargetingPreset = $state(false); + let editingBuilderIndex = $state(null); - // Sheet states + // 3. Selection & Filtering State + let selectedLineIds = $state([]); + let tableContainer = $state(); + + // 4. Derived Values (Ordered correctly to avoid TDZ) + const flattenedLines = $derived.by(() => { + const sourceItems = items?.length ? items : formData?.items || []; + return (sourceItems || []).flatMap((item: any, itemIndex: number) => { + const lines = item?.lines || []; + return lines.map((line: any, idx: number) => ({ + ...line, + id: line?.id || `${item?.id || itemIndex}-line-${line?.line_number ?? idx + 1}`, + line_number: line?.line_number ?? idx + 1, + reference_number: line?.reference_number ?? item?.reference_number, + is_subitem: line?.is_subitem ?? false, + class_code: line?.class_code ?? line?.class_id, + class_description: + line?.class_description || + line?.description?.description_spanish || + line?.description?.description_english || + '', + unit_of_measure_code: line?.quantity?.unit_of_measure || line?.unit_of_measure, + fa_data: line?.fa_data || {}, + warehouse: line?.warehouse || item?.warehouse, + full_item: item + })); + }); + }); + + const isAllSelected = $derived( + flattenedLines.length > 0 && selectedLineIds.length === flattenedLines.length + ); + + const sourceItemsForPreset = $derived.by(() => { + if (selectedLineIds.length === 0) return []; + return (items || formData.items || []).filter((item: any) => + item.lines?.some((line: any) => + selectedLineIds.includes(line.id || `${item.id}-line-${line.line_number}`) + ) + ); + }); + + const invoiceSystem = $derived(invoice?.system || 'scaii'); + const invoiceLabel = $derived.by(() => { + if (invoice?.invoice_number) return `Factura ${invoice.invoice_number}`; + if (invoice?.id) return `Factura ${invoice.id}`; + return 'Factura nueva'; + }); + + const filteredPresets = $derived.by(() => { + const term = searchPresets.trim().toLowerCase(); + if (!term) return presets; + return presets.filter( + (p: ItemPreset) => + p.name.toLowerCase().includes(term) || p.description?.toLowerCase().includes(term) + ); + }); + + const selectedPresetItems = $derived(selectedPreset?.items || []); + const selectedPresetCount = $derived(selectedPresetItems.length); + const activeCompanyId = $derived(companyStore?.activeCompany?.id); + const formItemsCount = $derived(formData?.items?.length || 0); + + // 5. Form/Sheet State let showItemSheet = $state(false); let isEditMode = $state(false); let showDeleteDialog = $state(false); let selectedItem = $state(null); - let originalItemData = $state | null>(null); // Guardar estado original para cancelar + let originalItemData = $state | null>(null); let editingItem = $state>({ invoice_id: undefined, reference_number: '', @@ -63,20 +142,48 @@ warehouse: '', location: '' }); + let builderDraft = $state({ + description: '', + quantity: 1, + unit_cost_usd: 0, + reference_number: '' + }); - // Determinar el tipo de sistema (SCAF o SCAII) - const invoiceSystem = $derived(invoice?.system || 'scaii'); // Por defecto SCAII si no se especifica + // 6. Effects + $effect(() => { + currentPage = 1; + displayedItems = flattenedLines.slice(0, itemsPerPage); + }); - // Derived value para company ID - const activeCompanyId = $derived(companyStore.activeCompany?.id); - - // Cargar items cuando la factura tenga ID $effect(() => { if (invoice?.id && activeCompanyId) { loadItems(); } }); + $effect(() => { + if (invoice?.id && activeCompanyId && formItemsCount > 0) { + loadItems(); + } + }); + + // 7. Functions + function toggleSelectAll() { + if (isAllSelected) { + selectedLineIds = []; + } else { + selectedLineIds = flattenedLines.map((l) => l.id.toString()); + } + } + + function toggleSelectLine(id: string) { + if (selectedLineIds.includes(id)) { + selectedLineIds = selectedLineIds.filter((i) => i !== id); + } else { + selectedLineIds = [...selectedLineIds, id]; + } + } + async function loadItems() { if (!invoice?.id || !activeCompanyId) return; @@ -123,8 +230,8 @@ } function handleAdd() { - // Validar que la factura esté guardada (tiene ID) - if (!invoice?.id) { + // Validar que la factura esté guardada (tiene ID) si no estamos en modo plantilla + if (!showCreatePresetDialog && !invoice?.id) { toast.warning('Factura no guardada', { description: 'Debes guardar la factura primero antes de agregar partidas.', duration: 5000 @@ -133,6 +240,8 @@ } isEditMode = false; + isTargetingPreset = showCreatePresetDialog; + editingBuilderIndex = null; showItemSheet = true; // Auto-asignar valores desde la factura con estructura completa editingItem = { @@ -214,6 +323,235 @@ }; } + async function loadPresets(force = false) { + if (!activeCompanyId) return; + if (!force && isLoadingPresets) return; + isLoadingPresets = true; + try { + const response = await itemPresetsApi.list(activeCompanyId); + presets = response.data || []; + if (selectedPreset) { + selectedPreset = presets.find((p) => p.id === selectedPreset?.id) || null; + } + } catch (error) { + console.error('Error loading presets:', error); + toast.error('No se pudieron cargar las plantillas'); + } finally { + isLoadingPresets = false; + } + } + + function openUsePresetDialog() { + if (!invoice?.id) { + toast.warning('Primero guarda la factura para usar plantillas.'); + return; + } + showUsePresetDialog = true; + if (!presets.length) void loadPresets(); + } + + function openCreatePresetDialog() { + builderItems = []; + createPresetName = ''; + createPresetDescription = ''; + isTargetingPreset = false; + editingBuilderIndex = null; + showCreatePresetDialog = true; + } + + function handleEditInPreset(index: number) { + const itemToEdit = builderItems[index]; + isEditMode = true; + isTargetingPreset = true; + editingBuilderIndex = index; + editingItem = normalizeItemData(JSON.parse(JSON.stringify(itemToEdit))); + showItemSheet = true; + } + + function handleRemoveFromPreset(index: number) { + builderItems = builderItems.filter((_: any, i: number) => i !== index); + } + + function saveItemToPreset() { + // Sanitizar datos para la plantilla + const cleanedItem = JSON.parse(JSON.stringify(editingItem)); + + // Limpiar líneas para asegurar que son compatibles + if (cleanedItem.lines) { + cleanedItem.lines = cleanedItem.lines.map((line: any) => ({ + ...cleanLineData(line), + id: undefined // Las plantillas no deben tener IDs reales + })); + } + + if (editingBuilderIndex !== null) { + // Update existing item in builder + builderItems[editingBuilderIndex] = cleanedItem; + toast.success('Partida actualizada en la plantilla'); + } else { + // Add new item to builder + builderItems = [...builderItems, cleanedItem]; + toast.success('Partida agregada a la plantilla'); + } + + showItemSheet = false; + isTargetingPreset = false; + editingBuilderIndex = null; + } + + function sanitizeLineForPreset(line: any) { + const { id, item_id, created_at, updated_at, temp_id, ...rest } = line || {}; + return cleanLineData({ ...rest }); + } + + function cloneItemForPreset(item: Item) { + const { id, tenant_id, company_id, created_at, updated_at, temp_id, ...rest } = item as any; + return { + ...rest, + id: undefined, + invoice_id: undefined, + lines: (item.lines || []).map(sanitizeLineForPreset) + }; + } + + function buildManualItem(draft: any, index: number) { + return { + id: undefined, + temp_id: undefined, + invoice_id: undefined, + reference_number: draft.reference_number || undefined, + lines: [ + cleanLineData({ + line_number: index + 1, + description: { + description_spanish: draft.description || 'Sin descripción' + }, + quantity: { + quantity: Number(draft.quantity) || 0 + }, + financial: { + unit_cost_usd: + draft.unit_cost_usd !== null ? Number(draft.unit_cost_usd) || 0 : undefined + } + }) + ] + }; + } + + function handleAddManualItem() { + if (!builderDraft.description.trim()) { + toast.warning('Agrega una descripción para la partida'); + return; + } + builderItems = [...builderItems, { ...builderDraft }]; + builderDraft = { + description: '', + quantity: 1, + unit_cost_usd: 0, + reference_number: '' + }; + } + + async function applySelectedPreset() { + if (!selectedPreset) { + toast.warning('Selecciona una plantilla para aplicarla.'); + return; + } + if (!selectedPreset.items?.length) { + toast.warning('Esta plantilla no tiene partidas.'); + return; + } + + // Inject into active sheet if open + if (showItemSheet) { + const presetLines = selectedPreset.items.flatMap((item: any) => item.lines || []); + const cleanedNewLines = presetLines.map((line: any) => ({ + ...sanitizeLineForPreset(line), + id: undefined // Force new IDs + })); + + editingItem.lines = [...(editingItem.lines || []), ...cleanedNewLines]; + toast.success('Líneas inyectadas en la partida actual'); + showUsePresetDialog = false; + return; + } + + if (!invoice?.id || !activeCompanyId) { + toast.warning('Primero guarda la factura para usar plantillas.'); + return; + } + + isApplyingPreset = true; + try { + const payloads = selectedPreset.items.map((item) => ({ + ...cloneItemForPreset(item), + invoice_id: invoice.id + })); + + await Promise.all(payloads.map((payload) => itemsApi.create(activeCompanyId, payload))); + await loadItems(); + toast.success('Plantilla aplicada a la factura'); + showUsePresetDialog = false; + } catch (error) { + console.error('Error applying preset:', error); + toast.error('No se pudo aplicar la plantilla'); + } finally { + isApplyingPreset = false; + } + } + + async function saveCurrentItemsAsPreset() { + if (!createPresetName.trim()) { + toast.warning('Asigna un nombre a la plantilla'); + return; + } + if (!activeCompanyId) return; + + if (builderItems.length === 0) { + toast.warning('No hay partidas o líneas para guardar como plantilla'); + return; + } + + isSavingPreset = true; + try { + // We group everything as ONE Partida Template for injection + const lines = builderItems.flatMap((item: Item, idx: number) => { + return (item.lines || []).map((line: any) => ({ + ...cleanLineData(line), + line_number: line.line_number || idx + 1, // Ensure line_number is present + id: undefined // Ensure no IDs are saved in the preset + })); + }); + + const payloadItems = [ + { + reference_number: builderItems[0]?.reference_number || undefined, + lines: lines + } + ] as any; + + await itemPresetsApi.create(activeCompanyId, { + name: createPresetName.trim(), + description: createPresetDescription.trim() || undefined, + items: payloadItems + }); + + createPresetName = ''; + createPresetDescription = ''; + builderItems = []; + selectedLineIds = []; + builderDraft = { description: '', quantity: 1, unit_cost_usd: 0, reference_number: '' }; + toast.success('Plantilla guardada correctamente'); + await loadPresets(true); + showCreatePresetDialog = false; + } catch (error) { + console.error('Error saving preset:', error); + toast.error('No se pudo guardar la plantilla'); + } finally { + isSavingPreset = false; + } + } + function handleEdit(lineData: any) { isEditMode = true; selectedItem = lineData.full_item; @@ -389,65 +727,6 @@ showDeleteDialog = true; } - // Helper function to check if an object has any meaningful values - 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 - 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; - delete cleaned.part_number; - delete cleaned.part_description_es; - delete cleaned.part_description_en; - delete cleaned.unit_code; - delete cleaned.unit_description; - - // Remove display-only fields from nested objects - if (cleaned.customs) { - delete cleaned.customs.origin_country_name; - delete cleaned.customs.fraction_description; - } - - // Remove empty nested objects - if (!hasValues(cleaned.financial)) delete cleaned.financial; - if (!hasValues(cleaned.quantity)) delete cleaned.quantity; - if (!hasValues(cleaned.customs)) delete cleaned.customs; - if (!hasValues(cleaned.description)) delete cleaned.description; - if (!hasValues(cleaned.reference)) delete cleaned.reference; - if (!hasValues(cleaned.fa_data)) delete cleaned.fa_data; - - return cleaned; - } - async function saveNewItem() { if (!invoice?.id || !activeCompanyId) return; @@ -646,7 +925,9 @@ } // Si pasa la validación, continuar con el guardado - if (isEditMode) { + if (isTargetingPreset) { + saveItemToPreset(); + } else if (isEditMode) { saveEditedItem(); } else { saveNewItem(); @@ -687,24 +968,37 @@ // Cerrar el sheet showItemSheet = false; } - - useShortcuts( - 'Invoice Items List', - obtenerAtajosListaPartidas({ - manejarAgregar: handleAdd, - manejarActualizar: loadItems - }) - );
-
-

Items de la Factura

- +
+
+

Items de la Factura

+

+ Carga partidas, crea o aplica plantillas sin salir de esta vista. +

+
+
+ + + +
+ + + Línea P/S Clase @@ -748,10 +1045,20 @@ {item.warehouse || '-'}
- -
@@ -811,33 +1118,9 @@
- - -{#if invoiceSystem === 'fixed_asset'} - -{:else} - -{/if} - - + Confirmar Eliminación @@ -845,10 +1128,15 @@ - - + +
+
+ + +
+ +
+
+
+ + +
+
+ +
+ {#if isLoadingPresets} +
+ + Cargando... +
+ {:else if filteredPresets.length === 0} +
+ +

No se encontraron plantillas

+
+ {:else} + {#each filteredPresets as preset} + + {/each} + {/if} +
+
+ + +
+ {#if !selectedPreset} +
+
+ +
+

Selecciona una plantilla para ver sus detalles

+
+ {:else} +
+ +
+
+

+ {selectedPreset.name} +

+

+ {selectedPreset.description || 'Sin descripción disponible.'} +

+
+
+
+ Creada +
+
+ {selectedPreset.created_at + ? new Date(selectedPreset.created_at).toLocaleDateString(undefined, { + dateStyle: 'long' + }) + : '-'} +
+
+
+ + +
+ + + + # + Descripción del Item + Cant. + Costo (USD) + + + + {#if selectedPresetItems.length === 0} + + +
+ + Esta plantilla no contiene items. +
+
+
+ {:else} + {#each selectedPresetItems as item, i} + + + {i + 1} + + +
+ + {item.lines?.[0]?.description?.description_spanish || + 'Sin descripción'} + + {#if item.reference_number} + + REF: {item.reference_number} + + {/if} +
+
+ + {item.lines?.[0]?.quantity?.quantity || 0} + + + ${(item.lines?.[0]?.financial?.unit_cost_usd || 0).toLocaleString( + undefined, + { minimumFractionDigits: 2 } + )} + +
+ {/each} + {/if} +
+
+
+
+ {/if} +
+
+ + +
+ + +
+ + + + { + if (!open) { + createPresetName = ''; + createPresetDescription = ''; + } + }} +> + + + Crear plantilla + Guarda los elementos actuales como una plantilla reutilizable para inyectar en otras + partidas. + + +
+
+
+ + +
+
+ +