feature/presets-invoices-and-teplates-partidas

This commit is contained in:
hreyes
2026-02-11 10:18:18 -06:00
parent 8fe7f28713
commit 11867196b5
26 changed files with 3639 additions and 548 deletions

View File

@@ -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

View File

@@ -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

View 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)

View 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})>"

View 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
)

View 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

View 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)

View 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}')>"

View 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"}

View 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

View File

@@ -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"]

View 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()}`);
}
};

View File

@@ -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)}>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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('');

View 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;
}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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,

View File

@@ -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({

View File

@@ -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}

View 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
};
}
};

View 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>

View File

@@ -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(),