diff --git a/backend/api/v1/modules/a76/classes/service.py b/backend/api/v1/modules/a76/classes/service.py index 5dd6c532..3f106c6b 100644 --- a/backend/api/v1/modules/a76/classes/service.py +++ b/backend/api/v1/modules/a76/classes/service.py @@ -370,11 +370,46 @@ class ClassService: def delete(db: Session, class_id: int, tenant_id: int, company_id: int) -> bool: """Delete a class (and its FA extension if exists)""" from api.v1.modules.a24.fa.fa_classes.models import QClasses - + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.parts.models import Part + class_obj = ClassService.get_by_id(db, class_id, tenant_id, company_id) if not class_obj: return False + used_in_parts = ( + db.query(Part.id) + .filter( + Part.part_class == class_obj.class_code, + Part.tenant_id == tenant_id, + Part.company_id == company_id, + ) + .first() + is not None + ) + used_in_invoices = ( + db.query(LineItem.id) + .filter( + LineItem.class_id == class_id, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, + ) + .first() + is not None + ) + + if used_in_parts or used_in_invoices: + reasons: list[str] = [] + if used_in_parts: + reasons.append("partes") + if used_in_invoices: + reasons.append("facturas") + usage = " y ".join(reasons) + raise HTTPException( + status_code=409, + detail=f"No se puede eliminar la clase porque ya fue utilizada en {usage}.", + ) + # Delete FA extension first (if exists) to avoid FK constraint violation fa_extension = db.query(QClasses).filter( QClasses.class_id == class_id, diff --git a/backend/api/v1/modules/a76/invoices/routes.py b/backend/api/v1/modules/a76/invoices/routes.py index fd2c8d62..68b0e2e3 100644 --- a/backend/api/v1/modules/a76/invoices/routes.py +++ b/backend/api/v1/modules/a76/invoices/routes.py @@ -271,6 +271,7 @@ def delete_invoice( def copy_invoice( invoice_id: int = Path(..., description="ID de la factura a copiar"), company_id: int = Query(..., description="Company ID"), + new_invoice_number: Optional[str] = Query(None, description="Número de factura para la copia"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): @@ -285,7 +286,7 @@ def copy_invoice( ) try: - return services.InvoiceService.copy(db, invoice_id, tenant_id, company_id) + return services.InvoiceService.copy(db, invoice_id, tenant_id, company_id, new_invoice_number) except HTTPException: raise except Exception as e: diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py index 5465fe34..fd965c33 100644 --- a/backend/api/v1/modules/a76/invoices/services.py +++ b/backend/api/v1/modules/a76/invoices/services.py @@ -13,6 +13,13 @@ from .exports.validators.create import validate_create as validate_create_export from .exports.validators.update import validate_update as validate_update_export from .common.common_validators import invoice_exists from api.v1.modules.a76.items.models import LineItem +from api.v1.modules.a76.items.line_financials.models import LineFinancial +from api.v1.modules.a76.items.line_quantities.models import LineQuantity +from api.v1.modules.a76.items.line_customs.models import LineCustom +from api.v1.modules.a76.items.line_descriptions.models import LineDescription +from api.v1.modules.a76.items.line_references.models import LineReference +from api.v1.modules.a76.items.series.models import Serie +from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail from . import models, schemas @@ -777,12 +784,15 @@ class InvoiceService: invoice_id: int, tenant_id: int, company_id: int, + new_invoice_number: Optional[str] = None, ) -> models.InvoiceHeader: - """Duplica una factura con todas sus tablas relacionadas. + """Duplica una factura con todas sus tablas relacionadas, incluyendo partidas. - El número de factura de la copia lleva sufijo '-COPIA' (o '-COPIA-N' si ya existe). + Si se provee new_invoice_number, se usa ese número (falla con 409 si ya existe). + Si no, genera sufijo '-COPIA' / '-COPIA-N' automáticamente. Estado reseteado a 'pending', sin pedimento ni datos de procesamiento. """ + from fastapi import HTTPException original = ( db.query(models.InvoiceHeader) .filter( @@ -793,21 +803,31 @@ class InvoiceService: .first() ) if not original: - from fastapi import HTTPException raise HTTPException(status_code=404, detail="Factura no encontrada") - # Generar número de factura único para la copia - base_number = original.invoice_number or "" - candidate = f"{base_number}-COPIA" - counter = 1 - while db.query(models.InvoiceHeader).filter( - models.InvoiceHeader.invoice_number == candidate, - models.InvoiceHeader.tenant_id == tenant_id, - models.InvoiceHeader.company_id == company_id, - ).first(): - counter += 1 - candidate = f"{base_number}-COPIA-{counter}" - new_invoice_number = candidate + if new_invoice_number: + exists = db.query(models.InvoiceHeader).filter( + models.InvoiceHeader.invoice_number == new_invoice_number, + models.InvoiceHeader.tenant_id == tenant_id, + models.InvoiceHeader.company_id == company_id, + ).first() + if exists: + raise HTTPException( + status_code=409, + detail=f"Ya existe una factura con el número '{new_invoice_number}'" + ) + else: + base_number = original.invoice_number or "" + candidate = f"{base_number}-COPIA" + counter = 1 + while db.query(models.InvoiceHeader).filter( + models.InvoiceHeader.invoice_number == candidate, + models.InvoiceHeader.tenant_id == tenant_id, + models.InvoiceHeader.company_id == company_id, + ).first(): + counter += 1 + candidate = f"{base_number}-COPIA-{counter}" + new_invoice_number = candidate username = _get_current_username() @@ -839,7 +859,7 @@ class InvoiceService: ).first() if comp: EXCLUDE_COMP = { - "invoice_id", "pedimento_id", "vucem_operation_num", + "invoice_id", "vucem_operation_num", "electronic_signature", "certificate_number", "niu_number", "code_signature", "edocument", "created_at", "updated_at", } @@ -915,6 +935,83 @@ class InvoiceService: col_data["company_id"] = company_id db.add(models.InvoiceCollections(**col_data)) + # Copiar partidas (LineItem) con todas sus sub-tablas + EXCLUDE_LINE = {"id", "invoice_id", "created_at", "updated_at"} + EXCLUDE_SUB = {"id", "item_line_id", "created_at", "updated_at"} + EXCLUDE_SERIE = {"id", "line_item_id", "created_at", "updated_at"} + EXCLUDE_IDENT = {"id", "item_line_id", "created_at", "updated_at"} + + line_items = db.query(LineItem).filter( + LineItem.invoice_id == invoice_id, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, + ).order_by(LineItem.line_number).all() + + for item in line_items: + line_data = { + c.name: getattr(item, c.name) + for c in LineItem.__table__.columns + if c.name not in EXCLUDE_LINE + } + line_data["invoice_id"] = new_invoice.id + new_item = LineItem(**line_data) + db.add(new_item) + db.flush() + + # Copiar series primero para reusar sus IDs en LineReference + serie_id_map: dict[int, int] = {} + for serie in db.query(Serie).filter(Serie.line_item_id == item.id).all(): + s_data = { + c.name: getattr(serie, c.name) + for c in Serie.__table__.columns + if c.name not in EXCLUDE_SERIE + } + s_data["line_item_id"] = new_item.id + new_serie = Serie(**s_data) + db.add(new_serie) + db.flush() + serie_id_map[serie.id] = new_serie.id + + fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == item.id).first() + if fin: + f_data = {c.name: getattr(fin, c.name) for c in LineFinancial.__table__.columns if c.name not in EXCLUDE_SUB} + f_data["item_line_id"] = new_item.id + db.add(LineFinancial(**f_data)) + + qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == item.id).first() + if qty: + q_data = {c.name: getattr(qty, c.name) for c in LineQuantity.__table__.columns if c.name not in EXCLUDE_SUB} + q_data["item_line_id"] = new_item.id + db.add(LineQuantity(**q_data)) + + cus = db.query(LineCustom).filter(LineCustom.item_line_id == item.id).first() + if cus: + cu_data = {c.name: getattr(cus, c.name) for c in LineCustom.__table__.columns if c.name not in EXCLUDE_SUB} + cu_data["item_line_id"] = new_item.id + db.add(LineCustom(**cu_data)) + + desc = db.query(LineDescription).filter(LineDescription.item_line_id == item.id).first() + if desc: + d_data = {c.name: getattr(desc, c.name) for c in LineDescription.__table__.columns if c.name not in EXCLUDE_SUB} + d_data["item_line_id"] = new_item.id + db.add(LineDescription(**d_data)) + + ref = db.query(LineReference).filter(LineReference.item_line_id == item.id).first() + if ref: + r_data = { + c.name: getattr(ref, c.name) + for c in LineReference.__table__.columns + if c.name not in {"id", "item_line_id", "serie_id", "created_at", "updated_at"} + } + r_data["item_line_id"] = new_item.id + r_data["serie_id"] = serie_id_map.get(ref.serie_id) if ref.serie_id else None + db.add(LineReference(**r_data)) + + for ident in db.query(IdentifierDetail).filter(IdentifierDetail.item_line_id == item.id).all(): + i_data = {c.name: getattr(ident, c.name) for c in IdentifierDetail.__table__.columns if c.name not in EXCLUDE_IDENT} + i_data["item_line_id"] = new_item.id + db.add(IdentifierDetail(**i_data)) + db.commit() db.refresh(new_invoice) return new_invoice diff --git a/backend/api/v1/modules/core/invites/routes.py b/backend/api/v1/modules/core/invites/routes.py index 9a9225fd..a134440a 100644 --- a/backend/api/v1/modules/core/invites/routes.py +++ b/backend/api/v1/modules/core/invites/routes.py @@ -4,13 +4,15 @@ import logging from core.database import get_core_db from core.security import get_current_user, validate_access_to_resource -from fastapi import APIRouter, Depends, Query, Request +from fastapi import APIRouter, Depends, Request +from fastapi.security import HTTPBearer from sqlalchemy.orm import Session from .dto import CreateInviteDTO, InviteResponseDTO from .service import InviteService router = APIRouter(prefix="/invites", tags=["Invites"]) +_bearer = HTTPBearer() logger = logging.getLogger(__name__) @@ -21,12 +23,13 @@ async def create_invite( request: Request, db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), + credentials=Depends(_bearer), ): """ Genera un token de invitación para que un usuario externo se registre. - Requiere permiso user.create. + Requiere permiso user.create. Usa el token del usuario actual para crear + el invite en el Hub — no requiere credenciales de hub_admin. """ - # Validar acceso (user.create) y obtener tenant_id validate_access_to_resource( db, data.company_id, @@ -42,4 +45,5 @@ async def create_invite( data=data, created_by=created_by, tenant_slug=tenant_slug, + user_access_token=credentials.credentials, ) diff --git a/backend/api/v1/modules/core/invites/service.py b/backend/api/v1/modules/core/invites/service.py index 447ad72d..9a138f70 100644 --- a/backend/api/v1/modules/core/invites/service.py +++ b/backend/api/v1/modules/core/invites/service.py @@ -45,6 +45,7 @@ class InviteService: data: CreateInviteDTO, created_by: str, tenant_slug: str, + user_access_token: str = "", ) -> InviteResponseDTO: import httpx from api.v1.modules.core.tenants.models import Tenant @@ -75,28 +76,40 @@ class InviteService: if not company_role: raise HTTPException(status_code=404, detail="Rol no encontrado") - # Crear invite en el Hub para que el usuario use el form del workspace + # Crear invite en el Hub usando el token del usuario actual. + # El usuario debe tener role='admin' en su tenant dentro del Hub. + # No se requieren credenciales de hub_admin — sin secretos en el .env del cliente. hub_invite_token: Optional[str] = None invite_url: str = "" - try: - async with httpx.AsyncClient(timeout=10.0) as client: - login_resp = await client.post( - f"{settings.HUB_URL}api/v1/auth/login", - json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD}, - ) - if login_resp.status_code == 200: - svc_token = login_resp.json().get("access_token", "") + if not user_access_token: + logger.error( + "[invite] user_access_token vacío — no se puede crear el invite en el Hub. " + "tenant=%s email=%s", + tenant_slug, + data.email, + ) + else: + try: + async with httpx.AsyncClient(timeout=10.0) as client: hub_resp = await client.post( f"{settings.HUB_URL}api/v1/hub/invites", json={"email": str(data.email), "tenant_slug": tenant_slug}, - headers={"Authorization": f"Bearer {svc_token}"}, + headers={"Authorization": f"Bearer {user_access_token}"}, ) if hub_resp.status_code in (200, 201): hub_data = hub_resp.json() hub_invite_token = hub_data.get("invite_token") or _extract_token_from_url(hub_data.get("invite_url", "")) invite_url = hub_data.get("invite_url", "") - except Exception as exc: - logger.warning("Hub invite creation failed (non-blocking): %s", exc) + else: + logger.error( + "[invite] Hub invite creation falló: status=%s body=%s tenant=%s email=%s", + hub_resp.status_code, + hub_resp.text[:300], + tenant_slug, + data.email, + ) + except Exception as exc: + logger.error("[invite] Hub invite creation excepción (non-blocking): %s", exc) # Fallback: URL del workspace (Hub) si la creación de invitación en Hub falló if not invite_url: diff --git a/frontend/src/lib/api/dashboard/a76/invoices.ts b/frontend/src/lib/api/dashboard/a76/invoices.ts index 5c9d9fa0..c0e8d5b5 100644 --- a/frontend/src/lib/api/dashboard/a76/invoices.ts +++ b/frontend/src/lib/api/dashboard/a76/invoices.ts @@ -598,8 +598,9 @@ export const invoicesApi = { ); }, - copyInvoice: (invoiceId: number, companyId: number) => { + copyInvoice: (invoiceId: number, companyId: number, newInvoiceNumber?: string) => { const params = new URLSearchParams({ company_id: companyId.toString() }); + if (newInvoiceNumber) params.set('new_invoice_number', newInvoiceNumber); return api.post(`/v1/a76/invoices/${invoiceId}/copy?${params.toString()}`, {}); }, copyInvoiceHeader: (invoiceId: number, companyId: number) => { diff --git a/frontend/src/lib/components/dashboard/invoices/invoice-list-navigation.ts b/frontend/src/lib/components/dashboard/invoices/invoice-list-navigation.ts new file mode 100644 index 00000000..fda53a54 --- /dev/null +++ b/frontend/src/lib/components/dashboard/invoices/invoice-list-navigation.ts @@ -0,0 +1,148 @@ +import type { InvoiceType } from '$lib/api/dashboard/reference_data/invoice_types'; + +export type InvoiceListFilters = { + operation_type?: string | null; + invoice_type?: string | null; +}; + +/** Claves de importación expuestas en el menú lateral. */ +export const IMPORT_INVOICE_TYPE_KEYS = ['TEM', 'DEF', 'MEX', 'CR', 'REP'] as const; + +/** Claves de exportación relevantes para configuración (Exportación y Reparación). */ +export const EXPORT_INVOICE_TYPE_KEYS = ['EXDEF', 'REPAR'] as const; + +/** + * Filtra tipos de factura por operación (imp/exp), incluyendo tipos marcados como `both`. + */ +export function filterInvoiceTypesByOperation( + invoiceTypes: InvoiceType[], + operationType: string +): InvoiceType[] { + if (!operationType) return []; + + return invoiceTypes.filter( + (type) => !type.operation || type.operation === 'both' || type.operation === operationType + ); +} + +/** + * Tipos visibles en parámetros. + * + * - Para importación: se limitan a las claves expuestas en el menú lateral (TEM, DEF, MEX, CR, REP). + * - Para exportación: se permiten todos los tipos cuyo `operation` sea `exp` (catálogo completo). + */ +export function getInvoiceTypesForSettings( + invoiceTypes: InvoiceType[], + operationType: string +): InvoiceType[] { + const filtered = filterInvoiceTypesByOperation(invoiceTypes, operationType); + if (!operationType) return filtered; + + // Importación: solo los tipos configurables desde el menú lateral + if (operationType === 'imp') { + const byKey = new Map(filtered.map((type) => [type.key, type])); + return IMPORT_INVOICE_TYPE_KEYS.map((key) => byKey.get(key)).filter( + (type): type is InvoiceType => !!type + ); + } + + if (operationType === 'exp') { + const byKey = new Map(filtered.map((type) => [type.key, type])); + const fromBackend = EXPORT_INVOICE_TYPE_KEYS.map((key) => byKey.get(key)).filter( + (type): type is InvoiceType => !!type + ); + + // Si el backend devolvió ambos tipos, usar sus descripciones reales. + if (fromBackend.length > 0) { + return fromBackend; + } + + // Fallback mínimo para no dejar el combo vacío + const fallback: InvoiceType[] = [ + { key: 'EXDEF', description: 'EXPORTACIÓN DEFINITIVA', operation: 'exp' }, + { key: 'REPAR', description: 'REPARACIÓN', operation: 'exp' } + ]; + return fallback; + } + + // Otra operación válida: devolvemos lo filtrado + return filtered; +} + +/** + * Tipo de factura por defecto cuando la URL no lo incluye. + */ +export function getDefaultInvoiceTypeForOperation( + operationType: string, + invoiceTypes: InvoiceType[] +): string { + const options = getInvoiceTypesForSettings(invoiceTypes, operationType); + if (options.length === 0) return ''; + + if (operationType === 'imp') { + return options.find((type) => type.key === 'TEM')?.key ?? options[0].key; + } + + if (operationType === 'exp') { + return ( + options.find((type) => type.key === 'EXDEF')?.key ?? + options.find((type) => type.key === 'REPAR')?.key ?? + options[0].key + ); + } + + return options[0].key; +} + +/** + * Construye la ruta del listado de facturas preservando query params y evitando + * navegar sin `operation_type` (el SSR del listado redirige al dashboard). + */ +export function buildInvoicesListPath( + searchParams: URLSearchParams, + fallbacks?: InvoiceListFilters +): string { + const params = new URLSearchParams(searchParams); + + if (!params.has('operation_type') && fallbacks?.operation_type) { + params.set('operation_type', fallbacks.operation_type); + } + + if (!params.has('invoice_type') && fallbacks?.invoice_type) { + params.set('invoice_type', fallbacks.invoice_type); + } + + const operationType = params.get('operation_type'); + if (!operationType) { + return '/dashboard'; + } + + const qs = params.toString(); + return qs ? `/dashboard/invoices?${qs}` : `/dashboard/invoices?operation_type=${operationType}`; +} + +/** + * Construye la ruta de parámetros de factura conservando el contexto del listado. + */ +export function buildInvoicesSettingsPath(searchParams: URLSearchParams): string { + const params = new URLSearchParams(searchParams); + const qs = params.toString(); + return qs ? `/dashboard/invoices/settings?${qs}` : '/dashboard/invoices/settings'; +} + +/** + * Resuelve operación y tipo inicial para la pantalla de parámetros. + */ +export function resolveInvoiceSettingsContext( + searchParams: URLSearchParams, + invoiceTypes: InvoiceType[] +): { operationType: string; invoiceType: string } { + const operationType = searchParams.get('operation_type') ?? ''; + let invoiceType = searchParams.get('invoice_type') ?? ''; + + if (operationType && !invoiceType) { + invoiceType = getDefaultInvoiceTypeForOperation(operationType, invoiceTypes); + } + + return { operationType, invoiceType }; +} diff --git a/frontend/src/lib/components/sidebar/app-launcher.svelte b/frontend/src/lib/components/sidebar/app-launcher.svelte index c74f05aa..f6d932be 100644 --- a/frontend/src/lib/components/sidebar/app-launcher.svelte +++ b/frontend/src/lib/components/sidebar/app-launcher.svelte @@ -38,14 +38,14 @@ {#snippet child({ props })} {/snippet} diff --git a/frontend/src/lib/components/sidebar/app-sidebar.svelte b/frontend/src/lib/components/sidebar/app-sidebar.svelte index c50ca3ad..7cca6ad1 100644 --- a/frontend/src/lib/components/sidebar/app-sidebar.svelte +++ b/frontend/src/lib/components/sidebar/app-sidebar.svelte @@ -84,6 +84,21 @@ inventory: BarChart3Icon, }; + const SYSTEM_COLORS: Record = { + fixed_asset: { + border: 'border-amber-500/40', + bg: 'bg-amber-500/5 dark:bg-amber-500/10', + iconBg: 'bg-amber-500/10', + iconText: 'text-amber-600 dark:text-amber-400', + }, + inventory: { + border: 'border-emerald-500/40', + bg: 'bg-emerald-500/5 dark:bg-emerald-500/10', + iconBg: 'bg-emerald-500/10', + iconText: 'text-emerald-600 dark:text-emerald-400', + }, + }; + onMount(() => { const expandForKeyboardNav = () => { sidebar.setOpen(true); @@ -104,15 +119,15 @@ {@const activeLabel = systemStore.activeLabel} {@const activeSystem = systemStore.activeSystem} {@const Icon = activeSystem ? ACTIVE_ICONS[activeSystem] : null} + {@const colors = activeSystem ? SYSTEM_COLORS[activeSystem] : null}
{#if Icon} -
+
{/if} diff --git a/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte b/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte index 3199be8a..dbf669da 100644 --- a/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte +++ b/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte @@ -7,6 +7,7 @@ import FixedAssetClassForm from '$lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte'; import { Folder, Save, Plus, RefreshCw } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; + import { friendlyApiErrorParts, type ApiResponse } from '$lib/api'; import { classesApi, type A76Class } from '$lib/api/dashboard/a76/classes'; import { faClassesApi, type FAClass } from '$lib/api/dashboard/a24/fa_classes'; import { companyStore } from '$lib/stores/company.svelte'; @@ -322,29 +323,33 @@ const idsToDelete = [...selectedClassIds]; let okCount = 0; - const errors: number[] = []; + const failedResponses: ApiResponse[] = []; for (const id of idsToDelete) { - try { - // El backend elimina automáticamente la extensión FA si existe - await classesApi.delete(id, companyId); + // El backend elimina automáticamente la extensión FA si existe + const res = await classesApi.delete(id, companyId); + if (res.status >= 400 || res.error) { + console.error(`Error deleting class ${id}:`, res.error ?? res.status); + failedResponses.push(res); + } else { okCount++; - } catch (error) { - console.error(`Error deleting class ${id}:`, error); - errors.push(id); } } - if (errors.length === 0) { + if (failedResponses.length === 0) { toast.success( okCount === 1 ? 'Clase eliminada correctamente' : `${okCount} clases eliminadas correctamente` ); } else if (okCount === 0) { - toast.error(`Error al eliminar ${errors.length} clase(s)`); + const { title, description } = friendlyApiErrorParts(failedResponses[0]); + toast.error(title, { description }); } else { - toast.error(`${okCount} eliminada(s), ${errors.length} con error`); + const { title, description } = friendlyApiErrorParts(failedResponses[0]); + toast.error(`${okCount} eliminada(s), ${failedResponses.length} con error`, { + description: description || title + }); } await loadClasses(); diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index ebb59f68..96018723 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -13,6 +13,7 @@ import { reportsWinsaaiApi } from '$lib/api/dashboard/a76/reports/reports-winsaai'; import DataTable from '$lib/components/dashboard/invoices/data-table.svelte'; import { createColumns } from '$lib/components/dashboard/invoices/columns'; + import { buildInvoicesSettingsPath } from '$lib/components/dashboard/invoices/invoice-list-navigation'; import * as Card from '$lib/components/ui/card'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; import * as Dialog from '$lib/components/ui/dialog'; @@ -319,6 +320,30 @@ if (fromContextMenu) contextMenuOpen = false; } + // --- Dialog: nombre para la copia de factura --- + let copyDialogOpen = $state(false); + let copyDialogInvoice = $state(null); + let copyDialogNumber = $state(''); + + function openCopyDialog(inv: Invoice) { + copyDialogInvoice = inv; + copyDialogNumber = `${inv.invoice_number ?? ''}-COPIA`; + copyDialogOpen = true; + } + + function handleCopyConfirm() { + if (!copyDialogInvoice || !copyDialogNumber.trim()) return; + const inv = copyDialogInvoice; + const num = copyDialogNumber.trim(); + copyDialogOpen = false; + invoicesApi.copyInvoice(inv.id, companyStore.activeCompany!.id, num) + .then((res) => { + if (res.error) { toast.error(res.error); return; } + toast.success('Factura copiada'); + reloadData(); + }); + } + function openExportItemsDialog(inv: Invoice) { exportItemsInvoice = inv; exportItemsFormat = 'csv'; @@ -1629,7 +1654,13 @@

{m.invoice_list_header_description()}

- @@ -1727,6 +1758,33 @@
+ + + + + Copiar Factura + + Indica el número que tendrá la factura copiada. + + +
+ + { if (e.key === 'Enter') handleCopyConfirm(); }} + /> +
+ + + + +
+
+ @@ -1940,12 +1998,8 @@ {/snippet} {@render cmItem(Copy, 'Copiar Factura', () => { - invoicesApi.copyInvoice(contextMenuInvoice!.id, companyStore.activeCompany!.id) - .then((res) => { - if (res.error) { toast.error(res.error); return; } - toast.success('Factura copiada'); - reloadData(); - }); + contextMenuOpen = false; + openCopyDialog(contextMenuInvoice!); })} {@render cmItem(Download, 'Exportar Partidas', () => { openExportItemsDialog(contextMenuInvoice!); @@ -2330,14 +2384,8 @@ { if (!selectedInvoice) return; - const invId = selectedInvoice.id; copiasInterfacesMenuOpen = false; - invoicesApi.copyInvoice(invId, companyStore.activeCompany!.id) - .then((res) => { - if (res.error) { toast.error(res.error); return; } - toast.success('Factura copiada'); - reloadData(); - }); + openCopyDialog(selectedInvoice); }} > diff --git a/frontend/src/routes/dashboard/invoices/settings/+page.server.ts b/frontend/src/routes/dashboard/invoices/settings/+page.server.ts index a27d86e7..e6caab03 100644 --- a/frontend/src/routes/dashboard/invoices/settings/+page.server.ts +++ b/frontend/src/routes/dashboard/invoices/settings/+page.server.ts @@ -15,6 +15,14 @@ export const load: PageServerLoad = async ({ cookies, fetch, url }) => { throw error(400, 'No se encontró una compañía seleccionada'); } + const operationTypeParam = url.searchParams.get('operation_type'); + const invoiceTypeParam = url.searchParams.get('invoice_type'); + + let parsedOperationType: string | null = null; + if (operationTypeParam && (operationTypeParam === 'exp' || operationTypeParam === 'imp')) { + parsedOperationType = operationTypeParam; + } + // Load necessary reference data for the settings form // We need: InvoiceTypes, CustomsBrokers, Incoterms, etc. to populate the reusable forms @@ -22,7 +30,7 @@ export const load: PageServerLoad = async ({ cookies, fetch, url }) => { // 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', + 'v1/public/reference_data/invoice-types?page=1&page_size=100', {}, cookies, fetch @@ -237,7 +245,11 @@ export const load: PageServerLoad = async ({ cookies, fetch, url }) => { seals: seals.items || [], enclosure: enclosures.items || [], pedimentos: pedimentos.items || [], - companyId + companyId, + filters: { + operation_type: parsedOperationType, + invoice_type: invoiceTypeParam || null + } }; } catch (err) { diff --git a/frontend/src/routes/dashboard/invoices/settings/+page.svelte b/frontend/src/routes/dashboard/invoices/settings/+page.svelte index 70e1869a..01a5da3a 100644 --- a/frontend/src/routes/dashboard/invoices/settings/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/settings/+page.svelte @@ -28,13 +28,29 @@ import SsisdefSettingsForm from '$lib/components/dashboard/invoices/settings/ssisdef-settings-form.svelte'; import SsicmSettingsForm from '$lib/components/dashboard/invoices/settings/ssicm-settings-form.svelte'; import SscrSettingsForm from '$lib/components/dashboard/invoices/settings/sscr-settings-form.svelte'; + import { + buildInvoicesListPath, + getDefaultInvoiceTypeForOperation, + getInvoiceTypesForSettings, + resolveInvoiceSettingsContext + } from '$lib/components/dashboard/invoices/invoice-list-navigation'; + import { m } from '$lib/i18n/messages'; // Props let { data } = $props(); + const initialParams = new URLSearchParams(); + if (data.filters?.operation_type) { + initialParams.set('operation_type', data.filters.operation_type); + } + if (data.filters?.invoice_type) { + initialParams.set('invoice_type', data.filters.invoice_type); + } + const initialContext = resolveInvoiceSettingsContext(initialParams, data.invoiceTypes || []); + // State - let selectedInvoiceType = $state(''); - let selectedOperationType = $state(''); + let selectedInvoiceType = $state(initialContext.invoiceType); + let selectedOperationType = $state(initialContext.operationType); let isLoading = $state(false); let isSaving = $state(false); let activeTab = $state('general'); @@ -85,6 +101,13 @@ { value: 'exp', label: 'Exportación' } ]; + let invoiceTypesForOperation = $derived( + getInvoiceTypesForSettings( + data.invoiceTypes || [], + selectedOperationType || initialContext.operationType + ) + ); + onMount(async () => { try { const companyStoreModule = await import('$lib/stores/company.svelte'); @@ -415,8 +438,15 @@ return obj; } + function invoicesListPath(): string { + return buildInvoicesListPath($page.url.searchParams, { + operation_type: selectedOperationType || null, + invoice_type: selectedInvoiceType || null + }); + } + function handleBack() { - goto('/dashboard/invoices'); + goto(invoicesListPath()); } let operationTypeText = $derived.by(() => { @@ -503,7 +533,6 @@ const cid = companyStore?.activeCompany?.id; if (browser && cid && selectedOperationType && selectedInvoiceType && !lastLoadedKey && !isLoading) { untrack(() => { - console.log('EFFECT: Initial load triggered'); loadSettings(); }); } @@ -512,6 +541,12 @@ // Handle explicit changes function handleOperationTypeChange(v: string) { selectedOperationType = v; + const options = getInvoiceTypesForSettings(data.invoiceTypes || [], v); + const stillValid = options.some((type) => type.key === selectedInvoiceType); + if (!stillValid) { + selectedInvoiceType = getDefaultInvoiceTypeForOperation(v, data.invoiceTypes || []); + lastLoadedKey = ''; + } loadSettings(); } @@ -540,7 +575,9 @@ {/if} {#if selectedInvoiceType} - {data.invoiceTypes.find((t: any) => t.key === selectedInvoiceType)?.description || selectedInvoiceType} + {invoiceTypesForOperation.find((t: any) => t.key === selectedInvoiceType)?.description || + data.invoiceTypes.find((t: any) => t.key === selectedInvoiceType)?.description || + selectedInvoiceType} {/if}
@@ -553,45 +590,32 @@
- - - Contexto de Configuración - Selecciona el contexto para editar sus valores por defecto. - - -
- - -
- -
- - -
-
-
+ {#if !selectedOperationType || selectedOperationType !== 'imp'} + + + Contexto de Configuración + Selecciona el contexto para editar sus valores por defecto. + + +
+ + +
+
+
+ {/if} {#if selectedInvoiceType && selectedOperationType} {#if isLoading} @@ -615,7 +639,7 @@ +