diff --git a/backend/alembic/versions/d3e4f5a6b7c8_session_fixes_accounts_forma_pago.py b/backend/alembic/versions/d3e4f5a6b7c8_session_fixes_accounts_forma_pago.py new file mode 100644 index 0000000..2b9d241 --- /dev/null +++ b/backend/alembic/versions/d3e4f5a6b7c8_session_fixes_accounts_forma_pago.py @@ -0,0 +1,56 @@ +"""Ajustes de sesión: país ISO-3 en accounts, giro "otro" y formas de pago SAT a 2 dígitos + +Revision ID: d3e4f5a6b7c8 +Revises: c2d3e4f5a6b7 +Create Date: 2026-08-04 01:00:00.000000 + +- crm.accounts.country String(2)→String(3) (ISO alfa-3, alineado a catálogo pais). +- crm.accounts.industry_other (especificar cuando el giro es "otro"). +- Normaliza formas de pago SAT de 1 dígito a 2 (01, 02, …) en el catálogo y en + los valores guardados en accounts/suppliers; y país 'MX'→'MEX'. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "d3e4f5a6b7c8" +down_revision: Union[str, None] = "c2d3e4f5a6b7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = "crm" + + +def upgrade() -> None: + # País a ISO alfa-3 en accounts (addresses ya es String(3)). + # Primero se amplía la columna; luego se normaliza el dato (evita truncamiento). + op.alter_column( + "accounts", "country", schema=SCHEMA, + existing_type=sa.String(length=2), type_=sa.String(length=3), + existing_nullable=True, server_default=sa.text("'MEX'"), + ) + op.execute("UPDATE crm.accounts SET country = 'MEX' WHERE country = 'MX'") + op.execute("UPDATE crm.addresses SET country = 'MEX' WHERE country = 'MX'") + # Giro "otro" — campo para especificar + op.add_column("accounts", sa.Column("industry_other", sa.String(length=120), nullable=True), schema=SCHEMA) + + # Formas de pago SAT: 1 dígito → 2 dígitos (catálogo + valores guardados) + op.execute( + "UPDATE crm.catalog_items SET code = lpad(code, 2, '0') " + "WHERE catalog = 'forma_pago' AND char_length(code) = 1" + ) + op.execute("UPDATE crm.accounts SET payment_form = lpad(payment_form, 2, '0') WHERE char_length(payment_form) = 1") + op.execute("UPDATE crm.suppliers SET payment_form = lpad(payment_form, 2, '0') WHERE char_length(payment_form) = 1") + + +def downgrade() -> None: + op.drop_column("accounts", "industry_other", schema=SCHEMA) + # Regresar país a String(2) sin truncar filas existentes + op.execute("UPDATE crm.accounts SET country = 'MX' WHERE country = 'MEX'") + op.alter_column( + "accounts", "country", schema=SCHEMA, + existing_type=sa.String(length=3), type_=sa.String(length=2), + existing_nullable=True, server_default=sa.text("'MX'"), + ) + # La normalización de formas de pago no se revierte (evita romper códigos multi-dígito). diff --git a/backend/alembic/versions/e4f5a6b7c8d9_opportunity_won_lost_dates.py b/backend/alembic/versions/e4f5a6b7c8d9_opportunity_won_lost_dates.py new file mode 100644 index 0000000..d261148 --- /dev/null +++ b/backend/alembic/versions/e4f5a6b7c8d9_opportunity_won_lost_dates.py @@ -0,0 +1,30 @@ +"""Fechas separadas de ganada/perdida en la oportunidad + +Revision ID: e4f5a6b7c8d9 +Revises: d3e4f5a6b7c8 +Create Date: 2026-08-04 02:00:00.000000 + +Agrega crm.opportunities.won_date y lost_date (fechas de cierre separadas, +editables) además de closed_at y lost_reason ya existentes. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "e4f5a6b7c8d9" +down_revision: Union[str, None] = "d3e4f5a6b7c8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = "crm" + + +def upgrade() -> None: + op.add_column("opportunities", sa.Column("won_date", sa.Date(), nullable=True), schema=SCHEMA) + op.add_column("opportunities", sa.Column("lost_date", sa.Date(), nullable=True), schema=SCHEMA) + + +def downgrade() -> None: + op.drop_column("opportunities", "lost_date", schema=SCHEMA) + op.drop_column("opportunities", "won_date", schema=SCHEMA) diff --git a/backend/api/v1/modules/crm/accounts/dto.py b/backend/api/v1/modules/crm/accounts/dto.py index 4951a14..4708fbe 100644 --- a/backend/api/v1/modules/crm/accounts/dto.py +++ b/backend/api/v1/modules/crm/accounts/dto.py @@ -13,6 +13,7 @@ class AccountBase(BaseModel): record_type: str = Field("cliente", max_length=20) # cliente | prospecto person_type: str | None = Field(None, max_length=10) # fisica | moral industry: str | None = Field(None, max_length=120) + industry_other: str | None = Field(None, max_length=120) account_type: str | None = Field(None, max_length=40) status: str = Field("active", max_length=20) # active | inactive # Comercial @@ -38,7 +39,7 @@ class AccountBase(BaseModel): address: str | None = None city: str | None = Field(None, max_length=120) state: str | None = Field(None, max_length=120) - country: str | None = Field("MX", max_length=2) + country: str | None = Field("MEX", max_length=3) # Observaciones notes: str | None = None internal_notes: str | None = None @@ -57,6 +58,7 @@ class AccountUpdate(BaseModel): record_type: str | None = Field(None, max_length=20) person_type: str | None = Field(None, max_length=10) industry: str | None = Field(None, max_length=120) + industry_other: str | None = Field(None, max_length=120) account_type: str | None = Field(None, max_length=40) status: str | None = Field(None, max_length=20) commercial_classification: str | None = Field(None, max_length=20) @@ -79,7 +81,7 @@ class AccountUpdate(BaseModel): address: str | None = None city: str | None = Field(None, max_length=120) state: str | None = Field(None, max_length=120) - country: str | None = Field(None, max_length=2) + country: str | None = Field(None, max_length=3) notes: str | None = None internal_notes: str | None = None owner_user_id: str | None = Field(None, max_length=64) diff --git a/backend/api/v1/modules/crm/accounts/models.py b/backend/api/v1/modules/crm/accounts/models.py index 70c8dad..4526b7d 100644 --- a/backend/api/v1/modules/crm/accounts/models.py +++ b/backend/api/v1/modules/crm/accounts/models.py @@ -31,6 +31,7 @@ class Account(Base, TenantScopedMixin, TimestampMixin): # Tipo de persona: fisica | moral person_type: Mapped[str | None] = mapped_column(String(10), nullable=True) industry: Mapped[str | None] = mapped_column(String(120), nullable=True) # giro / industria + industry_other: Mapped[str | None] = mapped_column(String(120), nullable=True) # especificar cuando giro = "otro" # Tipo operativo (immex | agencia_aduanal | importador | exportador | transportista | otro) account_type: Mapped[str | None] = mapped_column(String(40), nullable=True) # Estatus: active | inactive @@ -65,7 +66,7 @@ class Account(Base, TenantScopedMixin, TimestampMixin): address: Mapped[str | None] = mapped_column(Text, nullable=True) city: Mapped[str | None] = mapped_column(String(120), nullable=True) state: Mapped[str | None] = mapped_column(String(120), nullable=True) - country: Mapped[str | None] = mapped_column(String(2), nullable=True, server_default=text("'MX'")) + country: Mapped[str | None] = mapped_column(String(3), nullable=True, server_default=text("'MEX'")) # ----- Observaciones y auditoría ----- notes: Mapped[str | None] = mapped_column(Text, nullable=True) # comentarios generales diff --git a/backend/api/v1/modules/crm/addresses/dto.py b/backend/api/v1/modules/crm/addresses/dto.py index e04ec29..1a8cf73 100644 --- a/backend/api/v1/modules/crm/addresses/dto.py +++ b/backend/api/v1/modules/crm/addresses/dto.py @@ -14,7 +14,7 @@ class AddressBase(BaseModel): postal_code: str | None = Field(None, max_length=10) city: str | None = Field(None, max_length=120) state: str | None = Field(None, max_length=120) - country: str | None = Field("MX", max_length=2) + country: str | None = Field("MEX", max_length=3) # ISO 3166-1 alfa-3 (alineado a catálogo pais) reference_notes: str | None = None is_primary: bool = False @@ -32,7 +32,7 @@ class AddressUpdate(BaseModel): postal_code: str | None = Field(None, max_length=10) city: str | None = Field(None, max_length=120) state: str | None = Field(None, max_length=120) - country: str | None = Field(None, max_length=2) + country: str | None = Field(None, max_length=3) reference_notes: str | None = None is_primary: bool | None = None diff --git a/backend/api/v1/modules/crm/catalogs/seed_data.py b/backend/api/v1/modules/crm/catalogs/seed_data.py index d2e4f49..30da343 100644 --- a/backend/api/v1/modules/crm/catalogs/seed_data.py +++ b/backend/api/v1/modules/crm/catalogs/seed_data.py @@ -847,4 +847,23 @@ GLOBAL_CATALOGS.update({ {'code': 'ficha_tecnica', 'label': 'Ficha técnica'}, {'code': 'carta_instrucciones', 'label': 'Carta de instrucciones'}, {'code': 'otro', 'label': 'Otro'}]}, + 'incoterm': {'label': 'Incoterm (2020)', + 'is_system': True, + 'items': [{'code': 'EXW', 'label': 'EXW — Ex Works (en fábrica)'}, + {'code': 'FCA', 'label': 'FCA — Free Carrier (franco transportista)'}, + {'code': 'FAS', 'label': 'FAS — Free Alongside Ship (franco al costado del buque)'}, + {'code': 'FOB', 'label': 'FOB — Free On Board (franco a bordo)'}, + {'code': 'CFR', 'label': 'CFR — Cost and Freight (costo y flete)'}, + {'code': 'CIF', 'label': 'CIF — Cost, Insurance and Freight (costo, seguro y flete)'}, + {'code': 'CPT', 'label': 'CPT — Carriage Paid To (transporte pagado hasta)'}, + {'code': 'CIP', 'label': 'CIP — Carriage and Insurance Paid To (transporte y seguro pagados hasta)'}, + {'code': 'DAP', 'label': 'DAP — Delivered At Place (entregado en lugar)'}, + {'code': 'DPU', 'label': 'DPU — Delivered At Place Unloaded (entregado en lugar descargado)'}, + {'code': 'DDP', 'label': 'DDP — Delivered Duty Paid (entregado con derechos pagados)'}]}, }) + +# Formas de pago SAT de un dígito → dos dígitos (01, 02, 03, 04, 05, 06, 08). +# El SAT exige dos posiciones; se corrige el catálogo base. +for _fp in GLOBAL_CATALOGS.get('forma_pago', {}).get('items', []): + if len(_fp['code']) == 1: + _fp['code'] = _fp['code'].zfill(2) diff --git a/backend/api/v1/modules/crm/common/folios.py b/backend/api/v1/modules/crm/common/folios.py index 281b058..55ce31a 100644 --- a/backend/api/v1/modules/crm/common/folios.py +++ b/backend/api/v1/modules/crm/common/folios.py @@ -21,8 +21,8 @@ from sqlalchemy.orm import Mapped, mapped_column from api.v1.common.base_models import BaseTimestampMixin, TenantScopedMixin from core.database import Base -# Entidades válidas y su letra de folio. -ENTITIES = ("O", "S", "C", "OP") +# Entidades válidas y su letra de folio (F = factura, sin dirección impo/expo). +ENTITIES = ("O", "S", "C", "OP", "F") # Mapa dirección de operación → sufijo del folio. _DIRECTION_SUFFIX = {"importacion": "I", "exportacion": "E"} @@ -56,11 +56,13 @@ def next_folio( entity: str, direction: str | None, on_date: date | None = None, + with_direction: bool = True, ) -> str: """Genera el siguiente folio de una entidad, incrementando su consecutivo mensual. Reserva el número dentro de la transacción activa (no hace commit): el ``create_*`` - que lo invoca es quien confirma junto con la fila recién creada. + que lo invoca es quien confirma junto con la fila recién creada. ``with_direction=False`` + omite el sufijo I/E (p. ej. facturas → ``F2026-08-001``). """ if entity not in ENTITIES: raise ValueError(f"Entidad de folio inválida: {entity!r}") @@ -89,4 +91,6 @@ def next_folio( db.flush() sequence = f"{counter.last_number:03d}" + if not with_direction: + return f"{entity}{period}-{sequence}" return f"{entity}{period}-{sequence}-{direction_suffix(direction)}" diff --git a/backend/api/v1/modules/crm/opportunities/dto.py b/backend/api/v1/modules/crm/opportunities/dto.py index 48a533d..d37df49 100644 --- a/backend/api/v1/modules/crm/opportunities/dto.py +++ b/backend/api/v1/modules/crm/opportunities/dto.py @@ -31,6 +31,8 @@ class OpportunityUpdate(BaseModel): probability: int | None = Field(None, ge=0, le=100) status: str | None = Field(None, max_length=20) expected_close_date: date | None = None + won_date: date | None = None + lost_date: date | None = None lost_reason: str | None = Field(None, max_length=255) source: str | None = Field(None, max_length=60) owner_user_id: str | None = Field(None, max_length=64) @@ -59,6 +61,8 @@ class OpportunityResponse(BaseModel): status: str expected_close_date: date | None closed_at: datetime | None + won_date: date | None = None + lost_date: date | None = None lost_reason: str | None source: str | None owner_user_id: str | None diff --git a/backend/api/v1/modules/crm/opportunities/models.py b/backend/api/v1/modules/crm/opportunities/models.py index 2686511..05b5b62 100644 --- a/backend/api/v1/modules/crm/opportunities/models.py +++ b/backend/api/v1/modules/crm/opportunities/models.py @@ -34,6 +34,8 @@ class Opportunity(Base, TenantScopedMixin, TimestampMixin): status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'open'"), index=True) expected_close_date: Mapped[date | None] = mapped_column(Date, nullable=True) closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + won_date: Mapped[date | None] = mapped_column(Date, nullable=True) # fecha en que se ganó + lost_date: Mapped[date | None] = mapped_column(Date, nullable=True) # fecha en que se perdió lost_reason: Mapped[str | None] = mapped_column(String(255), nullable=True) source: Mapped[str | None] = mapped_column(String(60), nullable=True) owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) diff --git a/backend/api/v1/modules/crm/opportunities/service.py b/backend/api/v1/modules/crm/opportunities/service.py index 39ff209..975b443 100644 --- a/backend/api/v1/modules/crm/opportunities/service.py +++ b/backend/api/v1/modules/crm/opportunities/service.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import date, datetime, timezone from fastapi import HTTPException, status from sqlalchemy.orm import Session @@ -47,14 +47,20 @@ def _apply_stage_state(opportunity: Opportunity, stage: PipelineStage) -> None: opportunity.status = "won" opportunity.probability = 100 opportunity.closed_at = datetime.now(timezone.utc) + opportunity.won_date = opportunity.won_date or date.today() + opportunity.lost_date = None elif stage.is_lost: opportunity.status = "lost" opportunity.probability = 0 opportunity.closed_at = datetime.now(timezone.utc) + opportunity.lost_date = opportunity.lost_date or date.today() + opportunity.won_date = None else: opportunity.status = "open" opportunity.probability = stage.probability opportunity.closed_at = None + opportunity.won_date = None + opportunity.lost_date = None def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None: diff --git a/backend/api/v1/modules/crm/rates/routes.py b/backend/api/v1/modules/crm/rates/routes.py index 28d46e7..38f2803 100644 --- a/backend/api/v1/modules/crm/rates/routes.py +++ b/backend/api/v1/modules/crm/rates/routes.py @@ -255,3 +255,15 @@ def rate_quote( tenant_id, _ = _ctx(current_user) options = service.quote_cost(db, tenant_id, company_id, req) return CostResult(request=req, options=options) + + +@cost_router.get("/rate-locations") +def rate_locations( + mode: str = Query(...), + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Orígenes/destinos cotizables (de los tarifarios activos) para alinear el cotizador.""" + tenant_id, _ = _ctx(current_user) + return service.lane_locations(db, tenant_id, company_id, mode) diff --git a/backend/api/v1/modules/crm/rates/service.py b/backend/api/v1/modules/crm/rates/service.py index aff1881..c6d103f 100644 --- a/backend/api/v1/modules/crm/rates/service.py +++ b/backend/api/v1/modules/crm/rates/service.py @@ -479,6 +479,30 @@ def _apply_charges(db: Session, sheet: RateSheet, lane: RateLane, base: Decimal, return lines +def lane_locations(db: Session, tenant_id: int, company_id: int, mode: str) -> dict[str, list[str]]: + """Orígenes/destinos existentes en los tarifarios activos de un modo. + + Alinea el cotizador con las rutas realmente cotizables (los códigos provienen + de las lanes, por lo que el costeo siempre encontrará ruta). + """ + sheets = _sheet_query(db, tenant_id, company_id).filter( + RateSheet.mode == mode, RateSheet.status == "activo", + ).all() + origins: set[str] = set() + destinations: set[str] = set() + for sheet in sheets: + lanes = db.query(RateLane).filter( + RateLane.rate_sheet_id == sheet.id, RateLane.deleted_at.is_(None), + ).all() + for lane in lanes: + origin = lane.origin or sheet.default_origin + if origin: + origins.add(origin) + if lane.destination: + destinations.add(lane.destination) + return {"origins": sorted(origins), "destinations": sorted(destinations)} + + def quote_cost(db: Session, tenant_id: int, company_id: int, req: CostRequest) -> list[CostOption]: on_date = req.on_date or date.today() sheets = _sheet_query(db, tenant_id, company_id).filter( diff --git a/backend/api/v1/modules/fin/invoices/service.py b/backend/api/v1/modules/fin/invoices/service.py index 6820515..907653a 100644 --- a/backend/api/v1/modules/fin/invoices/service.py +++ b/backend/api/v1/modules/fin/invoices/service.py @@ -6,6 +6,7 @@ from sqlalchemy import func from sqlalchemy.orm import Session from api.v1.modules.crm.accounts.models import Account +from api.v1.modules.crm.common.folios import next_folio from api.v1.modules.crm.quotes.models import Quote, QuoteItem from api.v1.modules.ops.shipments.models import Shipment @@ -95,6 +96,9 @@ def create_invoice(db, payload: InvoiceCreate, tenant_id, company_id, user_id=No data = payload.model_dump() _validate_refs(db, data, tenant_id, company_id) obj = Invoice(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id) + # Folio F... auto-generado (mensual) si no viene uno explícito + if not obj.reference: + obj.reference = next_folio(db, tenant_id, company_id, "F", None, with_direction=False) db.add(obj) db.flush() _recompute(db, obj) diff --git a/backend/tests/test_accounts.py b/backend/tests/test_accounts.py index d91fc91..7de05b3 100644 --- a/backend/tests/test_accounts.py +++ b/backend/tests/test_accounts.py @@ -15,7 +15,7 @@ def test_create_and_get_account(db): ) assert acc.id is not None assert acc.status == "active" - assert acc.country == "MX" + assert acc.country == "MEX" # ISO 3166-1 alfa-3 (alineado al catálogo pais) got = service.get_account(db, acc.id, T, C) assert got.name == "Importadora Demo" assert got.rfc == "XAXX010101000" diff --git a/backend/tests/test_folios.py b/backend/tests/test_folios.py index 430659c..406f954 100644 --- a/backend/tests/test_folios.py +++ b/backend/tests/test_folios.py @@ -38,6 +38,12 @@ def test_folio_entities_do_not_share_counter(db): assert op == "OP2025-08-001-I" +def test_folio_invoice_without_direction(db): + # Facturas: entidad F sin sufijo de dirección (F2025-08-001) + folio = next_folio(db, T, C, "F", None, on_date=date(2025, 8, 3), with_direction=False) + assert folio == "F2025-08-001" + + def test_folio_unique_across_many(db): folios = {next_folio(db, T, C, "C", "importacion", on_date=date(2025, 8, 10)) for _ in range(25)} assert len(folios) == 25 # sin duplicados diff --git a/backend/tests/test_opportunities.py b/backend/tests/test_opportunities.py index 4dfa519..de8f320 100644 --- a/backend/tests/test_opportunities.py +++ b/backend/tests/test_opportunities.py @@ -41,6 +41,8 @@ def test_move_to_won_closes_and_sets_probability(db): assert moved.status == "won" assert moved.probability == 100 assert moved.closed_at is not None + assert moved.won_date is not None # fecha de ganada + assert moved.lost_date is None assert moved.stage_id == s_won.id @@ -51,6 +53,8 @@ def test_move_to_lost(db): assert moved.status == "lost" assert moved.probability == 0 assert moved.closed_at is not None + assert moved.lost_date is not None # fecha de perdida + assert moved.won_date is None def test_move_back_to_open_reopens(db): diff --git a/frontend/src/app.css b/frontend/src/app.css index 990d5f9..5b58f9d 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -143,6 +143,14 @@ filter: invert(1) brightness(1.15); opacity: 0.9; } + + /* Las opciones de los {#each crmCatalogs.options('tipo_registro') as r (r.value)}{/each} + {#if form.industry === 'otro'} + + {/if} diff --git a/frontend/src/lib/components/crm/RelatedManager.svelte b/frontend/src/lib/components/crm/RelatedManager.svelte index 15ce0a5..fb0866e 100644 --- a/frontend/src/lib/components/crm/RelatedManager.svelte +++ b/frontend/src/lib/components/crm/RelatedManager.svelte @@ -1,5 +1,5 @@ +{#snippet catCsv(labelText: string, catalog: string, value: string, set: (v: string) => void, placeholder: string)} + +{/snippet} + {#if tab === 'generales'}
@@ -56,10 +89,10 @@
- - - - + {@render catCsv('Países donde opera', 'pais', countriesStr, (v) => (countriesStr = v), 'México, Estados Unidos')} + {@render catCsv('Puertos donde opera', 'puerto', portsStr, (v) => (portsStr = v), 'Veracruz, Manzanillo')} + {@render catCsv('Aeropuertos donde opera', 'aeropuerto', airportsStr, (v) => (airportsStr = v), 'MEX, GDL')} + {@render catCsv('Aduanas donde opera', 'aduana', customsStr, (v) => (customsStr = v), 'Nuevo Laredo, Colombia')} diff --git a/frontend/src/routes/dashboard/crm/contactos/+page.svelte b/frontend/src/routes/dashboard/crm/contactos/+page.svelte index 41aff07..5c0f37c 100644 --- a/frontend/src/routes/dashboard/crm/contactos/+page.svelte +++ b/frontend/src/routes/dashboard/crm/contactos/+page.svelte @@ -4,11 +4,12 @@ import * as Table from '$lib/components/ui/table'; import { Button } from '$lib/components/ui/button'; import { companyStore } from '$lib/stores/company.svelte'; - import { contactsAPI, accountsAPI, type Contact, type ContactInput, type Account } from '$lib/api/crm'; + import { contactsAPI, accountsAPI, suppliersAPI, type Contact, type ContactInput, type Account, type Supplier } from '$lib/api/crm'; import { toast } from 'svelte-sonner'; let items = $state([]); let accounts = $state([]); + let suppliers = $state([]); let loading = $state(false); let search = $state(''); let modalOpen = $state(false); @@ -18,8 +19,18 @@ const companyId = $derived(companyStore.activeCompany?.id ?? null); - function accountName(id: number | null): string { - return accounts.find((a) => a.id === id)?.name ?? '—'; + // A quién pertenece el contacto: cliente/prospecto (cuenta) o proveedor + function ownerLabel(c: Contact): { kind: string; name: string } | null { + if (c.account_id) { + const a = accounts.find((x) => x.id === c.account_id); + const kind = a?.record_type === 'prospecto' ? 'Prospecto' : 'Cliente'; + return { kind, name: a?.name ?? `#${c.account_id}` }; + } + if (c.supplier_id) { + const s = suppliers.find((x) => x.id === c.supplier_id); + return { kind: 'Proveedor', name: s?.name ?? `#${c.supplier_id}` }; + } + return null; } const filtered = $derived( @@ -41,7 +52,7 @@ async function load(cid: number) { loading = true; try { - [items, accounts] = await Promise.all([contactsAPI.list(cid), accountsAPI.list(cid)]); + [items, accounts, suppliers] = await Promise.all([contactsAPI.list(cid), accountsAPI.list(cid), suppliersAPI.list(cid)]); } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los contactos'); } finally { @@ -136,7 +147,7 @@ Nombre - Cuenta + Pertenece a Puesto Email Teléfono @@ -150,7 +161,7 @@ {c.first_name} {c.last_name ?? ''} {#if c.is_primary}Principal{/if} - {accountName(c.account_id)} + {#if ownerLabel(c)}{@const o = ownerLabel(c)}{o?.kind} {o?.name}{:else}—{/if} {c.job_title ?? '—'} {c.email ?? '—'} {c.phone ?? c.mobile ?? '—'} diff --git a/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte index dd8cd01..9b227b8 100644 --- a/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte @@ -12,6 +12,7 @@ } from '$lib/api/crm'; import { shipmentsAPI } from '$lib/api/ops'; import { QUOTE_STATUS, QUOTE_CONCEPTS, labelOf, formatMoney } from '$lib/components/crm/format'; + import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte'; import { toast } from 'svelte-sonner'; const quoteId = $derived(Number(page.params.id)); @@ -39,6 +40,7 @@ async function load(cid: number, id: number) { loading = true; + void crmCatalogs.preload(['moneda']); try { [quote, items, accounts, requests, suppliers] = await Promise.all([ quotesAPI.get(id, cid), @@ -294,7 +296,7 @@ - + {#if quote.load_type}{/if} diff --git a/frontend/src/routes/dashboard/crm/cotizaciones/nuevo/+page.svelte b/frontend/src/routes/dashboard/crm/cotizaciones/nuevo/+page.svelte index 8a9a1de..ec5a726 100644 --- a/frontend/src/routes/dashboard/crm/cotizaciones/nuevo/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cotizaciones/nuevo/+page.svelte @@ -5,6 +5,7 @@ import { Button } from '$lib/components/ui/button'; import { companyStore } from '$lib/stores/company.svelte'; import { quotesAPI, accountsAPI, serviceRequestsAPI, type QuoteInput, type Account, type ServiceRequest } from '$lib/api/crm'; + import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte'; import { toast } from 'svelte-sonner'; let form = $state({ currency: 'USD', issue_date: new Date().toISOString().slice(0, 10) }); @@ -17,6 +18,7 @@ $effect(() => { const cid = companyId; if (!cid) return; + void crmCatalogs.preload(['moneda']); void (async () => { [accounts, requests] = await Promise.all([accountsAPI.list(cid), serviceRequestsAPI.list(cid)]); })(); @@ -49,7 +51,7 @@ - + diff --git a/frontend/src/routes/dashboard/crm/cotizador/+page.svelte b/frontend/src/routes/dashboard/crm/cotizador/+page.svelte index de1f072..a2982be 100644 --- a/frontend/src/routes/dashboard/crm/cotizador/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cotizador/+page.svelte @@ -22,6 +22,18 @@ let options = $state([]); let calculated = $state(false); let working = $state(false); + // Orígenes/destinos alineados a las rutas de los tarifarios activos del modo + let locs = $state<{ origins: string[]; destinations: string[] }>({ origins: [], destinations: [] }); + + $effect(() => { + const cid = companyId; + const mode = f.mode; + if (!cid) return; + void (async () => { + try { locs = await rateSheetsAPI.locations(cid, mode); } + catch { locs = { origins: [], destinations: [] }; } + })(); + }); const isFcl = $derived(f.mode === 'maritimo_fcl' || f.mode === 'terrestre'); const isAir = $derived(f.mode === 'aereo'); @@ -70,8 +82,20 @@ - - + + {#if isFcl} - +
@@ -384,3 +421,28 @@
{/if} + +{#if closeOpen && closeCtx} + +{/if} diff --git a/frontend/src/routes/dashboard/fin/facturas/nuevo/+page.svelte b/frontend/src/routes/dashboard/fin/facturas/nuevo/+page.svelte index b4c74ae..ce567a7 100644 --- a/frontend/src/routes/dashboard/fin/facturas/nuevo/+page.svelte +++ b/frontend/src/routes/dashboard/fin/facturas/nuevo/+page.svelte @@ -6,6 +6,7 @@ import { companyStore } from '$lib/stores/company.svelte'; import { invoicesAPI, type InvoiceInput } from '$lib/api/fin'; import { accountsAPI, type Account } from '$lib/api/crm'; + import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte'; import { toast } from 'svelte-sonner'; let form = $state({ currency: 'MXN', tax_rate: 16 }); @@ -16,6 +17,7 @@ $effect(() => { const cid = companyId; if (!cid) return; + void crmCatalogs.preload(['moneda']); void (async () => { accounts = await accountsAPI.list(cid); })(); }); @@ -44,9 +46,9 @@
- + - +