4 Commits

Author SHA1 Message Date
Ernesto Herrera
afe659e56a feat(crm): Expediente — referencia única de trazabilidad del trámite (Fase D)
- Tabla crm.cases (expediente) con folio EXP2026-08-001 (next_folio entidad EXP,
  sin dirección). Nace al crear la Oportunidad y se hereda vía case_id a
  solicitud → cotización → operación → factura. advance_stage solo avanza.
- case_id (FK a crm.cases) en crm.opportunities/service_requests/quotes,
  ops.shipments y fin.invoices; propagación en sus create_*. Migración
  d4e5f6a7b8c9 reversible.
- Endpoints GET /v1/crm/cases, /cases/{id}, /cases/by-ref/{ref} con timeline
  (historia completa para UI y otros sistemas).
- Frontend: casesAPI, ruta /dashboard/crm/expedientes (lista + timeline vertical),
  chip "📁 Expediente" en solicitud/cotización, "Expedientes" en el sidebar.
- Consecutivo de folios sin tope (soporta >10,000,000/mes).
- 4 pruebas de expediente (minteo, propagación, timeline, no-retroceso). Suite en verde (113).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 07:58:51 -06:00
Ernesto Herrera
b47dc542f2 feat(crm): Cotizaciones/Cotizador vinculados (Fase B)
- Lista de cotizaciones muestra la Solicitud referenciada (QuoteResponse enriquecido
  con service_request_reference; columna con enlace a la solicitud).
- Cotizador vinculable a una solicitud (?service_request_id=) → prellena modo
  (mapper transport+load→RateMode), ruta, peso/dimensiones, etc.
- Cotizador vinculable a una cotización (?quote_id=) → botón "Agregar" por opción
  que crea el concepto de flete + cargos en la cotización y regresa a ella.
- Botón "Cotizador" en el detalle de la cotización (entrada vinculada).

Suite de cotizaciones en verde. svelte-check sin errores nuevos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 07:43:53 -06:00
Ernesto Herrera
8431132b10 feat(crm): Prospecto — medio de contacto preferido + fix visualización de documentos (Fase C)
- Prospecto (lead): se conserva "Origen" y se agrega "Medio de contacto preferido"
  (catálogo medio_contacto). Backend leads.preferred_contact_method + migración
  f0a1b2c3d4e5 reversible.
- Bug documentos: endpoint proxy GET /v1/crm/uploads/download transmite el archivo
  por el backend (valida aislamiento tenant/company) — evita la URL prefirmada al
  host interno minio:9000. RelatedManager.openDoc usa blob→objectURL.

Suite backend en verde (109). svelte-check sin errores nuevos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 07:37:30 -06:00
Ernesto Herrera
8c7aeef1a6 feat(crm): refinamientos de Solicitud (Fase A del PDF 07-ago)
- Fecha de solicitud automática (hoy) editable al crear.
- Modalidad de carga dependiente del transporte: marítimo→FCL/LCL/Ambas,
  aéreo→Aérea (autoselección), terrestre→FTL/LTL, ferroviario/multimodal sin modalidad.
- Ciudad y Puerto/Aeropuerto dependientes del país (catálogos por parent_code) con
  respaldo de texto; el campo Puerto/Aeropuerto une ambos catálogos.
- Agente en destino filtrado a proveedores clasificados corresponsal/aduanal.
- Volumen SIEMPRE en m³ (conversión desde dimensiones según unidad de medida).
- P/Vol aéreo etiquetado con unidad (kg) y honra la unidad de medida.
- Moneda visible junto al valor de la mercancía.
- Lista de solicitudes con columna Cliente + filtro por cliente + búsqueda por nombre.
- Formulario de tarifa: campo "Válida hasta" (calendario).
- Catálogos globales ciudad/puerto/aeropuerto por país (seed_locations, extensible).
- Nuevos load types FTL/LTL. Suite backend en verde (109).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 07:25:20 -06:00
48 changed files with 1011 additions and 40 deletions

View File

@@ -0,0 +1,75 @@
"""Expediente (crm.cases) + case_id en el ciclo comercial
Revision ID: d4e5f6a7b8c9
Revises: f0a1b2c3d4e5
Create Date: 2026-08-07 02:00:00.000000
Crea crm.cases (expediente, hilo maestro con folio EXP...) y agrega case_id a
crm.opportunities/service_requests/quotes, ops.shipments y fin.invoices.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "d4e5f6a7b8c9"
down_revision: Union[str, None] = "f0a1b2c3d4e5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# (schema, tabla) donde se agrega case_id
_CASE_FK_TABLES = [
("crm", "opportunities"),
("crm", "service_requests"),
("crm", "quotes"),
("ops", "shipments"),
("fin", "invoices"),
]
def upgrade() -> None:
op.create_table(
"cases",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("reference", sa.String(length=40), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("title", sa.String(length=255), nullable=True),
sa.Column("stage", sa.String(length=20), nullable=False, server_default=sa.text("'oportunidad'")),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'abierto'")),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["account_id"], ["crm.accounts.id"]),
schema="crm",
)
op.create_index("ix_crm_cases_id", "cases", ["id"], schema="crm")
op.create_index("ix_crm_cases_reference", "cases", ["reference"], schema="crm")
op.create_index("ix_crm_cases_tenant_id", "cases", ["tenant_id"], schema="crm")
op.create_index("ix_crm_cases_company_id", "cases", ["company_id"], schema="crm")
op.create_index("ix_crm_cases_account_id", "cases", ["account_id"], schema="crm")
op.create_index("ix_crm_cases_status", "cases", ["status"], schema="crm")
for schema, table in _CASE_FK_TABLES:
op.add_column(table, sa.Column("case_id", sa.Integer(), nullable=True), schema=schema)
op.create_index(f"ix_{schema}_{table}_case_id", table, ["case_id"], schema=schema)
op.create_foreign_key(
f"fk_{schema}_{table}_case_id", table, "cases",
["case_id"], ["id"], source_schema=schema, referent_schema="crm",
)
def downgrade() -> None:
for schema, table in _CASE_FK_TABLES:
op.drop_constraint(f"fk_{schema}_{table}_case_id", table, schema=schema, type_="foreignkey")
op.drop_index(f"ix_{schema}_{table}_case_id", table_name=table, schema=schema)
op.drop_column(table, "case_id", schema=schema)
for idx in ("status", "account_id", "company_id", "tenant_id", "reference", "id"):
op.drop_index(f"ix_crm_cases_{idx}", table_name="cases", schema="crm")
op.drop_table("cases", schema="crm")

View File

@@ -0,0 +1,25 @@
"""Medio de contacto preferido en el prospecto (lead)
Revision ID: f0a1b2c3d4e5
Revises: e4f5a6b7c8d9
Create Date: 2026-08-07 01:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "f0a1b2c3d4e5"
down_revision: Union[str, None] = "e4f5a6b7c8d9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
op.add_column("leads", sa.Column("preferred_contact_method", sa.String(length=20), nullable=True), schema=SCHEMA)
def downgrade() -> None:
op.drop_column("leads", "preferred_contact_method", schema=SCHEMA)

View File

@@ -0,0 +1,31 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class CaseResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
reference: str | None
account_id: int | None
title: str | None
stage: str
status: str
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime
class CaseTimelineEvent(BaseModel):
kind: str # oportunidad | solicitud | cotizacion | operacion | factura
id: int
reference: str | None = None
status: str | None = None
created_at: datetime
url: str
class CaseWithTimeline(CaseResponse):
timeline: list[CaseTimelineEvent] = []

View File

@@ -0,0 +1,28 @@
from sqlalchemy import ForeignKey, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class Case(Base, TenantScopedMixin, TimestampMixin):
"""Expediente: hilo maestro de un trámite (Oportunidad → Solicitud → Cotización →
Operación → Factura). Una sola referencia (``EXP…``) que agrupa toda la historia.
Nace al crear la Oportunidad y se hereda a las entidades siguientes vía ``case_id``.
"""
__tablename__ = "cases"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio EXP...
account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Etapa más avanzada alcanzada: oportunidad|solicitud|cotizacion|operacion|facturacion|cerrado
stage: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'oportunidad'"))
# abierto | cerrado
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'abierto'"), index=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)

View File

@@ -0,0 +1,51 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from . import service
from .dto import CaseResponse, CaseWithTimeline
router = APIRouter()
def _with_timeline(db, case) -> CaseWithTimeline:
data = CaseWithTimeline.model_validate(case)
data.timeline = service.build_timeline(db, case) # type: ignore[assignment]
return data
@router.get("/cases", response_model=list[CaseResponse])
def list_cases(
company_id: int = Query(..., description="Company ID"),
search: str | None = Query(None),
account_id: int | None = Query(None),
stage: str | None = Query(None),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.get_cases(db, current_user["tenant_id"], company_id, search, account_id, stage)
@router.get("/cases/by-ref/{reference}", response_model=CaseWithTimeline)
def get_case_by_ref(
reference: str,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Expediente + historia completa por su referencia (para UI y otros sistemas)."""
case = service.get_case_by_reference(db, reference, current_user["tenant_id"], company_id)
return _with_timeline(db, case)
@router.get("/cases/{case_id}", response_model=CaseWithTimeline)
def get_case(
case_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
case = service.get_case(db, case_id, current_user["tenant_id"], company_id)
return _with_timeline(db, case)

View File

@@ -0,0 +1,109 @@
"""Lógica del Expediente: minteo del folio, avance de etapa y armado del timeline."""
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..common.folios import next_folio
from .models import Case
# Orden de etapas (solo se avanza, nunca retrocede)
STAGE_ORDER = ["oportunidad", "solicitud", "cotizacion", "operacion", "facturacion", "cerrado"]
def create_case(
db: Session, tenant_id: int, company_id: int, *, account_id: int | None = None,
title: str | None = None, stage: str = "oportunidad", user_id: str | None = None,
) -> Case:
"""Mintea un expediente con folio EXP... (sin commit; lo confirma quien lo invoca)."""
case = Case(
reference=next_folio(db, tenant_id, company_id, "EXP", None, with_direction=False),
account_id=account_id, title=title, stage=stage, status="abierto",
tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id,
)
db.add(case)
db.flush()
return case
def advance_stage(db: Session, case_id: int | None, stage: str) -> None:
"""Avanza la etapa del expediente si la nueva es posterior a la actual."""
if not case_id or stage not in STAGE_ORDER:
return
case = db.query(Case).filter(Case.id == case_id).first()
if not case:
return
current = case.stage if case.stage in STAGE_ORDER else "oportunidad"
if STAGE_ORDER.index(stage) > STAGE_ORDER.index(current):
case.stage = stage
def get_cases(
db: Session, tenant_id: int, company_id: int, search: str | None = None,
account_id: int | None = None, stage: str | None = None,
) -> list[Case]:
q = db.query(Case).filter(
Case.tenant_id == tenant_id, Case.company_id == company_id, Case.deleted_at.is_(None),
)
if account_id is not None:
q = q.filter(Case.account_id == account_id)
if stage:
q = q.filter(Case.stage == stage)
if search:
q = q.filter(Case.reference.ilike(f"%{search}%"))
return q.order_by(Case.created_at.desc()).all()
def get_case(db: Session, case_id: int, tenant_id: int, company_id: int) -> Case:
obj = (
db.query(Case)
.filter(Case.id == case_id, Case.tenant_id == tenant_id, Case.company_id == company_id, Case.deleted_at.is_(None))
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Expediente no encontrado")
return obj
def get_case_by_reference(db: Session, reference: str, tenant_id: int, company_id: int) -> Case:
obj = (
db.query(Case)
.filter(Case.reference == reference, Case.tenant_id == tenant_id, Case.company_id == company_id,
Case.deleted_at.is_(None))
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Expediente no encontrado")
return obj
def build_timeline(db: Session, case: Case) -> list[dict]:
"""Devuelve la historia del expediente: todas las entidades ligadas por case_id,
en orden cronológico. Un único lookup para la UI y para otros sistemas."""
# Import local para evitar ciclos de importación entre módulos.
from ..opportunities.models import Opportunity
from ..quotes.models import Quote
from ..service_requests.models import ServiceRequest
from api.v1.modules.fin.invoices.models import Invoice
from api.v1.modules.ops.shipments.models import Shipment
events: list[dict] = []
specs = [
("oportunidad", Opportunity, "/dashboard/crm/oportunidades"),
("solicitud", ServiceRequest, "/dashboard/crm/solicitudes"),
("cotizacion", Quote, "/dashboard/crm/cotizaciones"),
("operacion", Shipment, "/dashboard/ops/embarques"),
("factura", Invoice, "/dashboard/fin/facturas"),
]
for kind, model, base_url in specs:
rows = db.query(model).filter(model.case_id == case.id, model.deleted_at.is_(None)).all()
for r in rows:
events.append({
"kind": kind,
"id": r.id,
"reference": getattr(r, "reference", None),
"status": getattr(r, "status", None),
"created_at": r.created_at,
"url": f"{base_url}/{r.id}",
})
events.sort(key=lambda e: e["created_at"])
return events

View File

@@ -867,3 +867,8 @@ GLOBAL_CATALOGS.update({
for _fp in GLOBAL_CATALOGS.get('forma_pago', {}).get('items', []):
if len(_fp['code']) == 1:
_fp['code'] = _fp['code'].zfill(2)
# Ubicaciones por país (ciudad/puerto/aeropuerto), dependientes de `pais`.
from .seed_locations import LOCATION_CATALOGS # noqa: E402
GLOBAL_CATALOGS.update(LOCATION_CATALOGS)

View File

@@ -0,0 +1,79 @@
"""Catálogos de ubicaciones por país: ciudad, puerto (UN/LOCODE), aeropuerto (IATA).
Dependientes de `pais` (`parent_catalog='pais'`, `parent_code=<ISO3>`). Curado a las
rutas de comercio más usadas (extensible: agregar países/nodos según tarifarios).
Los códigos de puerto/aeropuerto se alinean con los que usan las lanes del tarifario
para que el Cotizador encuentre ruta.
"""
# (ISO3, ciudades[(code,label)], puertos[(code,label)], aeropuertos[(code,label)])
_LOC = [
("MEX",
[("MX-CDMX", "Ciudad de México"), ("MX-GDL", "Guadalajara"), ("MX-MTY", "Monterrey"),
("MX-QRO", "Querétaro"), ("MX-TIJ", "Tijuana"), ("MX-VER", "Veracruz")],
[("MXZLO", "Manzanillo"), ("MXVER", "Veracruz"), ("MXATM", "Altamira"),
("MXLZC", "Lázaro Cárdenas"), ("MXPGO", "Progreso"), ("MXESE", "Ensenada")],
[("MEX", "AICM Ciudad de México"), ("NLU", "AIFA Santa Lucía"), ("GDL", "Guadalajara"),
("MTY", "Monterrey"), ("TIJ", "Tijuana"), ("CUN", "Cancún")]),
("USA",
[("US-LAX", "Los Ángeles"), ("US-NYC", "Nueva York"), ("US-HOU", "Houston"),
("US-CHI", "Chicago"), ("US-MIA", "Miami"), ("US-LRD", "Laredo")],
[("USLAX", "Los Angeles"), ("USLGB", "Long Beach"), ("USNYC", "Nueva York/NJ"),
("USHOU", "Houston"), ("USSAV", "Savannah"), ("USSEA", "Seattle"), ("USOAK", "Oakland")],
[("LAX", "Los Ángeles"), ("JFK", "Nueva York JFK"), ("ORD", "Chicago O'Hare"),
("MIA", "Miami"), ("DFW", "Dallas Fort Worth"), ("ATL", "Atlanta")]),
("CHN",
[("CN-SHA", "Shanghái"), ("CN-SZX", "Shenzhen"), ("CN-CAN", "Guangzhou"),
("CN-NGB", "Ningbo"), ("CN-TAO", "Qingdao"), ("CN-PEK", "Pekín")],
[("CNSHA", "Shanghái"), ("CNNGB", "Ningbo"), ("CNSZX", "Shenzhen"),
("CNTAO", "Qingdao"), ("CNCAN", "Guangzhou"), ("CNXMN", "Xiamen"), ("CNTXG", "Tianjin")],
[("PVG", "Shanghái Pudong"), ("PEK", "Pekín Capital"), ("CAN", "Guangzhou"),
("SZX", "Shenzhen"), ("HKG", "Hong Kong")]),
("DEU",
[("DE-HAM", "Hamburgo"), ("DE-FRA", "Fráncfort"), ("DE-MUC", "Múnich"), ("DE-BER", "Berlín")],
[("DEHAM", "Hamburgo"), ("DEBRV", "Bremerhaven")],
[("FRA", "Fráncfort"), ("MUC", "Múnich"), ("HAM", "Hamburgo")]),
("ESP",
[("ES-MAD", "Madrid"), ("ES-BCN", "Barcelona"), ("ES-VLC", "Valencia")],
[("ESVLC", "Valencia"), ("ESBCN", "Barcelona"), ("ESALG", "Algeciras")],
[("MAD", "Madrid Barajas"), ("BCN", "Barcelona")]),
("NLD",
[("NL-RTM", "Róterdam"), ("NL-AMS", "Ámsterdam")],
[("NLRTM", "Róterdam")],
[("AMS", "Ámsterdam Schiphol")]),
("BRA",
[("BR-SAO", "São Paulo"), ("BR-SSZ", "Santos"), ("BR-RIO", "Río de Janeiro")],
[("BRSSZ", "Santos"), ("BRPNG", "Paranaguá"), ("BRRIG", "Rio Grande")],
[("GRU", "São Paulo Guarulhos"), ("GIG", "Río de Janeiro")]),
("CAN",
[("CA-YVR", "Vancouver"), ("CA-YYZ", "Toronto"), ("CA-YMQ", "Montreal")],
[("CAVAN", "Vancouver"), ("CAMTR", "Montreal"), ("CAHAL", "Halifax")],
[("YVR", "Vancouver"), ("YYZ", "Toronto Pearson")]),
("JPN",
[("JP-TYO", "Tokio"), ("JP-OSA", "Osaka"), ("JP-YOK", "Yokohama")],
[("JPYOK", "Yokohama"), ("JPTYO", "Tokio"), ("JPNGO", "Nagoya"), ("JPKOB", "Kobe")],
[("NRT", "Tokio Narita"), ("HND", "Tokio Haneda"), ("KIX", "Osaka Kansai")]),
("KOR",
[("KR-SEL", "Seúl"), ("KR-PUS", "Busan")],
[("KRPUS", "Busan"), ("KRINC", "Incheon")],
[("ICN", "Seúl Incheon")]),
]
def _build() -> dict:
ciudad, puerto, aeropuerto = [], [], []
for iso3, cities, ports, airports in _LOC:
for code, label in cities:
ciudad.append({"code": code, "label": label, "parent_catalog": "pais", "parent_code": iso3})
for code, label in ports:
puerto.append({"code": code, "label": f"{label} ({code})", "parent_catalog": "pais", "parent_code": iso3})
for code, label in airports:
aeropuerto.append({"code": code, "label": f"{label} ({code})", "parent_catalog": "pais", "parent_code": iso3})
return {
"ciudad": {"label": "Ciudad", "is_system": True, "items": ciudad},
"puerto": {"label": "Puerto", "is_system": True, "items": puerto},
"aeropuerto": {"label": "Aeropuerto", "is_system": True, "items": aeropuerto},
}
LOCATION_CATALOGS = _build()

View File

@@ -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 (F = factura, sin dirección impo/expo).
ENTITIES = ("O", "S", "C", "OP", "F")
# Entidades válidas y su letra de folio (F = factura, EXP = expediente; sin dirección).
ENTITIES = ("O", "S", "C", "OP", "F", "EXP")
# Mapa dirección de operación → sufijo del folio.
_DIRECTION_SUFFIX = {"importacion": "I", "exportacion": "E"}

View File

@@ -11,6 +11,7 @@ class LeadCreate(BaseModel):
phone: str | None = Field(None, max_length=40)
company_name: str | None = Field(None, max_length=255)
source: str | None = Field(None, max_length=60)
preferred_contact_method: str | None = Field(None, max_length=20)
status: str = Field("new", max_length=20)
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
owner_user_id: str | None = Field(None, max_length=64)
@@ -24,6 +25,7 @@ class LeadUpdate(BaseModel):
phone: str | None = Field(None, max_length=40)
company_name: str | None = Field(None, max_length=255)
source: str | None = Field(None, max_length=60)
preferred_contact_method: str | None = Field(None, max_length=20)
status: str | None = Field(None, max_length=20)
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
owner_user_id: str | None = Field(None, max_length=64)
@@ -50,6 +52,7 @@ class LeadResponse(BaseModel):
phone: str | None
company_name: str | None
source: str | None
preferred_contact_method: str | None = None
status: str
estimated_value: Decimal | None
owner_user_id: str | None

View File

@@ -19,6 +19,8 @@ class Lead(Base, TenantScopedMixin, TimestampMixin):
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Origen: web | referido | evento | llamada | email | otro
source: Mapped[str | None] = mapped_column(String(60), nullable=True)
# Medio de contacto preferido (catálogo medio_contacto): llamada|correo|whatsapp|…
preferred_contact_method: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Estado: new | contacted | qualified | unqualified | converted
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'new'"), index=True)
estimated_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)

View File

@@ -69,6 +69,7 @@ class OpportunityResponse(BaseModel):
notes: str | None
operation_type: str | None = None
reference: str | None = None
case_id: int | None = None
converted_service_request_id: int | None = None
tenant_id: int
company_id: int

View File

@@ -43,6 +43,8 @@ class Opportunity(Base, TenantScopedMixin, TimestampMixin):
# Dirección de la operación (importacion|exportacion): se hereda a Solicitud→Cotización→Embarque
operation_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio O...
# Expediente (hilo maestro del trámite); nace aquí y se hereda hacia abajo
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True)
# Solicitud generada al convertir la oportunidad (back-link idempotente)
converted_service_request_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.service_requests.id"), nullable=True

View File

@@ -4,6 +4,7 @@ from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..cases import service as cases_service
from ..common.folios import next_folio
from ..contacts.models import Contact
from ..pipelines.models import Pipeline, PipelineStage
@@ -159,6 +160,12 @@ def create_opportunity(
# Folio O... auto-generado (mensual). La dirección impo/expo se hereda al ciclo.
if not opportunity.reference:
opportunity.reference = next_folio(db, tenant_id, company_id, "O", opportunity.operation_type)
# Expediente: nace con la oportunidad y se hereda a solicitud/cotización/operación/factura
if not opportunity.case_id:
case = cases_service.create_case(
db, tenant_id, company_id, account_id=opportunity.account_id, title=opportunity.name, stage="oportunidad",
)
opportunity.case_id = case.id
db.add(opportunity)
db.commit()
db.refresh(opportunity)

View File

@@ -85,6 +85,8 @@ class QuoteResponse(QuoteBase):
model_config = ConfigDict(from_attributes=True)
id: int
service_request_reference: str | None = None # folio de la solicitud referenciada
case_id: int | None = None
status: str
total_cost: Decimal
total_sale: Decimal

View File

@@ -15,6 +15,7 @@ class Quote(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
service_request_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.service_requests.id"), nullable=True, index=True
)

View File

@@ -6,6 +6,7 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..cases import service as cases_service
from ..catalogs.models import CatalogItem
from ..common.folios import next_folio
from ..common.pricing import air_chargeable_kg
@@ -73,7 +74,18 @@ def get_quotes(
query = query.filter(Quote.account_id == account_id)
if search:
query = query.filter(Quote.reference.ilike(f"%{search}%"))
return query.order_by(Quote.created_at.desc()).all()
quotes = query.order_by(Quote.created_at.desc()).all()
# Enriquecer con el folio de la solicitud referenciada (para verlo en la lista)
sr_ids = {q.service_request_id for q in quotes if q.service_request_id}
if sr_ids:
refs = dict(
db.query(ServiceRequest.id, ServiceRequest.reference)
.filter(ServiceRequest.id.in_(sr_ids))
.all()
)
for q in quotes:
q.service_request_reference = refs.get(q.service_request_id)
return quotes
def get_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
@@ -112,6 +124,12 @@ def create_quote(
# Folio C... auto-generado (mensual), con la dirección heredada de la solicitud
if not obj.reference:
obj.reference = next_folio(db, tenant_id, company_id, "C", _sr_direction(db, obj.service_request_id))
# Expediente heredado de la solicitud
if obj.service_request_id and not obj.case_id:
sr = db.query(ServiceRequest).filter(ServiceRequest.id == obj.service_request_id).first()
if sr:
obj.case_id = sr.case_id
cases_service.advance_stage(db, obj.case_id, "cotizacion")
db.add(obj)
db.commit()
db.refresh(obj)
@@ -173,6 +191,7 @@ def create_quotes_from_service_request(
notes=sr.client_notes or sr.notes,
owner_user_id=sr.owner_user_id,
reference=next_folio(db, tenant_id, company_id, "C", sr.operation_type),
case_id=sr.case_id,
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
@@ -214,6 +233,7 @@ def create_quotes_from_service_request(
_recompute_totals(db, quote)
created.append(quote)
cases_service.advance_stage(db, sr.case_id, "cotizacion")
db.commit()
for quote in created:
db.refresh(quote)

View File

@@ -13,6 +13,7 @@ from . import permissions # noqa: F401 (side-effect: registra permisos del CRM
from .accounts.routes import router as accounts_router
from .activities.routes import router as activities_router
from .addresses.routes import router as addresses_router
from .cases.routes import router as cases_router
from .catalogs.routes import router as catalogs_router
from .contacts.routes import router as contacts_router
from .documents.routes import router as documents_router
@@ -43,6 +44,7 @@ router.include_router(leads_router)
router.include_router(pipelines_router)
router.include_router(opportunities_router)
router.include_router(activities_router)
router.include_router(cases_router)
router.include_router(metrics_router)
router.include_router(catalogs_router)
router.include_router(uploads_router)

View File

@@ -162,6 +162,7 @@ class ServiceRequestResponse(ServiceRequestBase):
model_config = ConfigDict(from_attributes=True)
id: int
case_id: int | None = None
first_contact_at: datetime | None = None
first_contact_notes: str | None = None
tenant_id: int

View File

@@ -19,6 +19,7 @@ class ServiceRequest(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)

View File

@@ -4,6 +4,7 @@ from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..cases import service as cases_service
from ..catalogs.data import INCOTERM_CODES
from ..common.folios import next_folio
from ..contacts.models import Contact
@@ -110,6 +111,12 @@ def create_service_request(
# Folio S... auto-generado (mensual) si no viene uno explícito
if not obj.reference:
obj.reference = next_folio(db, tenant_id, company_id, "S", obj.operation_type)
# Expediente: normalmente nace en la oportunidad; si la solicitud es directa, se mintea aquí
if not obj.case_id:
case = cases_service.create_case(
db, tenant_id, company_id, account_id=obj.account_id, title=obj.reference, stage="solicitud", user_id=user_id,
)
obj.case_id = case.id
db.add(obj)
db.commit()
db.refresh(obj)
@@ -200,6 +207,7 @@ def create_from_opportunity(
notes=payload.notes,
owner_user_id=opp.owner_user_id,
reference=next_folio(db, tenant_id, company_id, "S", operation_type),
case_id=opp.case_id,
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
@@ -207,6 +215,13 @@ def create_from_opportunity(
)
db.add(obj)
db.flush()
# Expediente heredado de la oportunidad (fallback si la oportunidad es antigua sin expediente)
if not obj.case_id:
obj.case_id = cases_service.create_case(
db, tenant_id, company_id, account_id=opp.account_id, title=obj.reference, stage="solicitud", user_id=user_id,
).id
opp.case_id = obj.case_id
cases_service.advance_stage(db, obj.case_id, "solicitud")
# Back-link para cerrar el ciclo Oportunidad→Solicitud (y garantizar idempotencia)
opp.converted_service_request_id = obj.id
db.commit()

View File

@@ -7,10 +7,10 @@ pide una URL firmada fresca en ``/uploads/url`` (las presignadas expiran).
import re
import uuid
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status
from core.security import get_current_user
from core.storage_s3 import presigned_get_url, put_object_bytes
from core.storage_s3 import get_object_bytes, presigned_get_url, put_object_bytes
router = APIRouter()
@@ -61,3 +61,29 @@ def get_upload_url(
if not key.startswith(prefix):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
return {"url": presigned_get_url(key)}
@router.get("/uploads/download")
def download_file(
key: str = Query(..., description="Object key del archivo en el almacén"),
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
):
"""Transmite el archivo por el backend (sin exponer MinIO al navegador).
Evita el bug de la URL prefirmada que apunta al host interno ``minio:9000``.
"""
tenant_id = current_user["tenant_id"]
prefix = f"tenants/{tenant_id}/companies/{company_id}/"
if not key.startswith(prefix):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
try:
data = get_object_bytes(key)
except Exception:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Archivo no encontrado")
filename = key.rsplit("/", 1)[-1]
return Response(
content=data,
media_type="application/octet-stream",
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)

View File

@@ -100,6 +100,7 @@ class InvoiceResponse(InvoiceBase):
model_config = ConfigDict(from_attributes=True)
id: int
case_id: int | None = None
status: str
subtotal: Decimal
tax_amount: Decimal

View File

@@ -15,6 +15,7 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
shipment_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("ops.shipments.id"), nullable=True, index=True
)

View File

@@ -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.cases import service as cases_service
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
@@ -99,6 +100,12 @@ def create_invoice(db, payload: InvoiceCreate, tenant_id, company_id, user_id=No
# 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)
# Expediente heredado del embarque (si la factura se genera de uno)
if obj.shipment_id and not obj.case_id:
sh = db.query(Shipment).filter(Shipment.id == obj.shipment_id).first()
if sh:
obj.case_id = sh.case_id
cases_service.advance_stage(db, obj.case_id, "facturacion")
db.add(obj)
db.flush()
_recompute(db, obj)
@@ -285,6 +292,7 @@ def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None)
invoice = Invoice(
reference=shipment.reference,
case_id=shipment.case_id,
shipment_id=shipment.id,
quote_id=shipment.quote_id,
account_id=shipment.account_id,
@@ -298,6 +306,7 @@ def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None)
)
db.add(invoice)
db.flush()
cases_service.advance_stage(db, shipment.case_id, "facturacion")
if quote:
q_items = db.query(QuoteItem).filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None)).all()

View File

@@ -82,6 +82,7 @@ class ShipmentResponse(ShipmentBase):
model_config = ConfigDict(from_attributes=True)
id: int
case_id: int | None = None
closed_at: datetime | None = None
closed_by: str | None = None
created_by: str | None = None

View File

@@ -15,6 +15,7 @@ class Shipment(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio de embarque
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
quote_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.quotes.id"), nullable=True, index=True
)

View File

@@ -5,6 +5,7 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from api.v1.modules.crm.accounts.models import Account
from api.v1.modules.crm.cases import service as cases_service
from api.v1.modules.crm.common.folios import next_folio
from api.v1.modules.crm.quotes.models import Quote
from api.v1.modules.crm.service_requests.models import ServiceRequest
@@ -218,6 +219,7 @@ def create_shipment_from_quote(
shipment = Shipment(
reference=next_folio(db, tenant_id, company_id, "OP", resolved),
case_id=quote.case_id,
quote_id=quote.id,
service_request_id=quote.service_request_id,
account_id=quote.account_id,
@@ -238,6 +240,7 @@ def create_shipment_from_quote(
db.add(shipment)
if sr:
sr.status = "liberada"
cases_service.advance_stage(db, quote.case_id, "operacion")
db.flush()
# Siembra automática de hitos si ya se conoce la dirección de la operación
for position, (event_type, title, kind) in enumerate(_DEFAULT_MILESTONES.get(resolved or "", [])):

View File

@@ -28,6 +28,7 @@ from core.database import Base # noqa: E402
import api.v1.modules.crm.accounts.models # noqa: E402,F401
import api.v1.modules.crm.activities.models # noqa: E402,F401
import api.v1.modules.crm.addresses.models # noqa: E402,F401
import api.v1.modules.crm.cases.models # noqa: E402,F401
import api.v1.modules.crm.catalogs.models # noqa: E402,F401
import api.v1.modules.crm.common.folios # noqa: E402,F401
import api.v1.modules.crm.contacts.models # noqa: E402,F401

View File

@@ -0,0 +1,48 @@
"""Pruebas del Expediente (crm.cases): minteo, propagación y timeline."""
from api.v1.modules.crm.cases import service as cases_service
from api.v1.modules.crm.opportunities import service as opp_service
from api.v1.modules.crm.opportunities.dto import OpportunityCreate
from api.v1.modules.crm.quotes import service as q_service
from api.v1.modules.crm.service_requests import service as sr_service
from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate, ServiceRequestFromOpportunityInput
T, C = 1, 1
def test_opportunity_mints_expediente(db):
opp = opp_service.create_opportunity(db, OpportunityCreate(name="Negocio", operation_type="exportacion"), T, C)
assert opp.case_id is not None
case = cases_service.get_case(db, opp.case_id, T, C)
assert (case.reference or "").startswith("EXP")
assert case.stage == "oportunidad"
def test_case_propagates_and_advances(db):
opp = opp_service.create_opportunity(db, OpportunityCreate(name="Negocio", operation_type="importacion"), T, C)
sr = sr_service.create_from_opportunity(db, opp.id, ServiceRequestFromOpportunityInput(), T, C)
assert sr.case_id == opp.case_id
assert cases_service.get_case(db, opp.case_id, T, C).stage == "solicitud"
quotes = q_service.create_quotes_from_service_request(db, sr.id, T, C)
assert quotes[0].case_id == opp.case_id
case = cases_service.get_case(db, opp.case_id, T, C)
assert case.stage == "cotizacion"
# El timeline reúne toda la historia ligada al expediente
kinds = {e["kind"] for e in cases_service.build_timeline(db, case)}
assert {"oportunidad", "solicitud", "cotizacion"} <= kinds
def test_direct_service_request_mints_expediente(db):
# Solicitud directa (sin oportunidad) también obtiene expediente (fallback)
sr = sr_service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C)
assert sr.case_id is not None
assert cases_service.get_case(db, sr.case_id, T, C).stage == "solicitud"
def test_advance_stage_never_regresses(db):
opp = opp_service.create_opportunity(db, OpportunityCreate(name="N", operation_type="exportacion"), T, C)
cases_service.advance_stage(db, opp.case_id, "facturacion")
cases_service.advance_stage(db, opp.case_id, "solicitud") # no debe retroceder
assert cases_service.get_case(db, opp.case_id, T, C).stage == "facturacion"

View File

@@ -0,0 +1,50 @@
/**
* Cliente API — Expedientes (referencia única de trazabilidad del trámite).
*/
import { api } from '$lib/api';
export interface Case {
id: number;
reference: string | null;
account_id: number | null;
title: string | null;
stage: string;
status: string;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export interface CaseTimelineEvent {
kind: string; // oportunidad | solicitud | cotizacion | operacion | factura
id: number;
reference: string | null;
status: string | null;
created_at: string;
url: string;
}
export interface CaseWithTimeline extends Case {
timeline: CaseTimelineEvent[];
}
function qp(companyId: number, extra?: Record<string, string | number | undefined>) {
const qs = new URLSearchParams({ company_id: String(companyId) });
for (const [k, v] of Object.entries(extra ?? {})) if (v !== undefined && v !== '') qs.set(k, String(v));
return qs.toString();
}
async function unwrap<T>(p: Promise<{ data?: T; error?: string }>): Promise<T> {
const res = await p;
if (res.error) throw new Error(res.error);
return res.data as T;
}
export const casesAPI = {
list: (companyId: number, params?: { search?: string; account_id?: number; stage?: string }) =>
unwrap<Case[]>(api.get(`/v1/crm/cases?${qp(companyId, params)}`)),
get: (id: number, companyId: number) =>
unwrap<CaseWithTimeline>(api.get(`/v1/crm/cases/${id}?${qp(companyId)}`)),
byRef: (reference: string, companyId: number) =>
unwrap<CaseWithTimeline>(api.get(`/v1/crm/cases/by-ref/${encodeURIComponent(reference)}?${qp(companyId)}`))
};

View File

@@ -10,6 +10,7 @@ export type QuoteStatus = 'borrador' | 'enviada' | 'aceptada' | 'rechazada';
export interface ServiceRequest {
id: number;
reference: string | null;
case_id: number | null;
account_id: number | null;
contact_id: number | null;
opportunity_id: number | null;
@@ -106,6 +107,8 @@ export interface Quote {
id: number;
reference: string | null;
service_request_id: number | null;
service_request_reference: string | null;
case_id: number | null;
account_id: number | null;
currency: string;
load_type: string | null;

View File

@@ -13,3 +13,4 @@ export { opportunitiesAPI } from './opportunities';
export { activitiesAPI } from './activities';
export { metricsAPI } from './metrics';
export * from './commercial';
export * from './cases';

View File

@@ -192,6 +192,7 @@ export interface Lead {
phone: string | null;
company_name: string | null;
source: string | null;
preferred_contact_method: string | null;
status: LeadStatus;
estimated_value: number | null;
owner_user_id: string | null;

View File

@@ -31,3 +31,10 @@ export async function uploadUrl(fileKey: string, companyId: number): Promise<str
if (res.error) throw new Error(res.error);
return res.data!.url;
}
/** Descarga el archivo por el backend (sin exponer MinIO) y devuelve un blob. */
export async function downloadBlob(fileKey: string, companyId: number): Promise<Blob> {
return (api as any).getBlob(
`/v1/crm/uploads/download?key=${encodeURIComponent(fileKey)}&company_id=${companyId}`
) as Promise<Blob>;
}

View File

@@ -11,7 +11,7 @@
import { DOC_TYPES, labelOf } from '$lib/components/crm/format';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { onMount } from 'svelte';
import { uploadFile, uploadUrl } from '$lib/api/uploads';
import { uploadFile, downloadBlob } from '$lib/api/uploads';
import { toast } from 'svelte-sonner';
onMount(() => {
@@ -62,9 +62,17 @@
async function openDoc(d: Document) {
if (!companyId) return;
try {
const url = d.file_key ? await uploadUrl(d.file_key, companyId) : d.file_url;
if (url) window.open(url, '_blank', 'noopener');
else toast.error('El documento no tiene archivo');
if (d.file_key) {
// Descarga por el backend (evita exponer MinIO / host interno)
const blob = await downloadBlob(d.file_key, companyId);
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener');
setTimeout(() => URL.revokeObjectURL(url), 60000);
} else if (d.file_url) {
window.open(d.file_url, '_blank', 'noopener');
} else {
toast.error('El documento no tiene archivo');
}
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo');
}

View File

@@ -21,25 +21,85 @@
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
// FCL/LCL condicionales; "AMBAS" muestra ambas secciones; "AEREO" muestra la sección aérea
const isFcl = $derived(form.load_type === 'FCL' || form.load_type === 'AMBAS');
const isLcl = $derived(form.load_type === 'LCL' || form.load_type === 'AMBAS');
const isAir = $derived(form.load_type === 'AEREO');
// Peso/Volumen aéreo (P/Vol) = (L×A×H cm × cantidad de bultos) / 6000; a cobrar = max(bruto, P/Vol)
const airQty = $derived(Number(form.pallets_count) || Number(form.pieces_count) || 1);
const airVolumetric = $derived(
Number(form.length_cm) > 0 && Number(form.width_cm) > 0 && Number(form.height_cm) > 0
? (Number(form.length_cm) * Number(form.width_cm) * Number(form.height_cm) * airQty) / 6000
: 0
// Modalidad de carga según el tipo de transporte (solo se habilita lo que corresponde)
const MODALIDAD_BY_TRANSPORT: Record<string, string[]> = {
maritimo: ['FCL', 'LCL', 'AMBAS'],
aereo: ['AEREO'],
terrestre: ['FTL', 'LTL']
// ferroviario / multimodal: sin modalidad
};
const modalidadOptions = $derived(
LOAD_TYPES.filter((l) => (MODALIDAD_BY_TRANSPORT[form.transport_mode ?? ''] ?? []).includes(l.value))
);
const airChargeable = $derived(Math.max(Number(form.weight) || 0, airVolumetric));
const showModalidad = $derived(modalidadOptions.length > 0);
// Reglas: al cambiar el transporte, la modalidad inválida se limpia; si solo hay una (aéreo), se autoselecciona
$effect(() => {
const allowed = MODALIDAD_BY_TRANSPORT[form.transport_mode ?? ''] ?? [];
if (allowed.length === 0) {
if (form.load_type) form.load_type = undefined;
return;
}
if (form.load_type && !allowed.includes(form.load_type)) form.load_type = undefined;
if (!form.load_type && allowed.length === 1) form.load_type = allowed[0];
});
// La modalidad aérea fija el medio de transporte en "aéreo"
$effect(() => {
if (form.load_type === 'AEREO' && form.transport_mode !== 'aereo') form.transport_mode = 'aereo';
});
const isFcl = $derived(form.load_type === 'FCL' || form.load_type === 'AMBAS');
const isLcl = $derived(form.load_type === 'LCL' || form.load_type === 'AMBAS');
const isAir = $derived(form.load_type === 'AEREO');
// Conversión de dimensiones a cm según la unidad de medida (para volumen m³ y P/Vol)
const UNIT_TO_CM: Record<string, number> = { cm: 1, m: 100, in: 2.54, ft: 30.48 };
const unitCm = $derived(UNIT_TO_CM[form.measurement_unit ?? 'cm'] ?? 1);
const airQty = $derived(Number(form.pallets_count) || Number(form.pieces_count) || 1);
const dimL = $derived((Number(form.length_cm) || 0) * unitCm);
const dimW = $derived((Number(form.width_cm) || 0) * unitCm);
const dimH = $derived((Number(form.height_cm) || 0) * unitCm);
const hasDims = $derived(dimL > 0 && dimW > 0 && dimH > 0);
// Volumen SIEMPRE en m³ (cm³ / 1,000,000)
const volumeM3 = $derived(hasDims ? (dimL * dimW * dimH * airQty) / 1_000_000 : 0);
// P/Vol aéreo (kg) = (L×A×H cm × bultos) / 6000; a cobrar = max(bruto, P/Vol)
const airVolumetric = $derived(hasDims ? (dimL * dimW * dimH * airQty) / 6000 : 0);
const airChargeable = $derived(Math.max(Number(form.weight) || 0, airVolumetric));
// Autocompletar el volumen en m³ a partir de las dimensiones/unidad
$effect(() => {
if (hasDims) form.volume = Math.round(volumeM3 * 1000) / 1000;
});
// Ciudad y puerto/aeropuerto dependen del país (catálogos dependientes, como estado←país)
$effect(() => {
if (form.origin_country) {
void crmCatalogs.ensure('ciudad', form.origin_country);
void crmCatalogs.ensure('puerto', form.origin_country);
void crmCatalogs.ensure('aeropuerto', form.origin_country);
}
});
$effect(() => {
if (form.destination_country) {
void crmCatalogs.ensure('ciudad', form.destination_country);
void crmCatalogs.ensure('puerto', form.destination_country);
void crmCatalogs.ensure('aeropuerto', form.destination_country);
}
});
// Campo "Puerto/Aeropuerto": une puertos + aeropuertos del país
function portOptions(country: string | null | undefined) {
return [
...crmCatalogs.options('puerto', country ?? undefined),
...crmCatalogs.options('aeropuerto', country ?? undefined)
];
}
// Agente en destino: solo proveedores clasificados como corresponsal/aduanal (fallback: todos)
const destinationAgents = $derived(
suppliers.filter((s) => (s.classifications ?? []).some((c) => c === 'agente_corresponsal' || c === 'agente_aduanal'))
);
const agentList = $derived(destinationAgents.length ? destinationAgents : suppliers);
// Contactos del cliente seleccionado (o todos si no hay cliente)
const clientContacts = $derived(
form.account_id ? contacts.filter((c) => c.account_id === form.account_id) : contacts
@@ -91,13 +151,19 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de transporte</span><select class={inputCls} bind:value={form.transport_mode}><option value={undefined}>—</option>{#each (crmCatalogs.options('medio_transporte').length ? crmCatalogs.options('medio_transporte') : TRANSPORT_MODES) as m (m.value)}<option value={m.value}>{m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de servicio</span><select class={inputCls} bind:value={form.service_type}><option value={undefined}>—</option>{#each SERVICE_TYPES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Incoterm</span><select class={inputCls} bind:value={form.incoterm}><option value={undefined}>—</option>{#each crmCatalogs.options('incoterm') as i (i.value)}<option value={i.value}>{i.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Modalidad de carga</span><select class={inputCls} bind:value={form.load_type}><option value={undefined}>—</option>{#each LOAD_TYPES as l (l.value)}<option value={l.value}>{l.label}</option>{/each}</select></label>
{#if showModalidad}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Modalidad de carga</span><select class={inputCls} bind:value={form.load_type}><option value={undefined}>—</option>{#each modalidadOptions as l (l.value)}<option value={l.value}>{l.label}</option>{/each}</select></label>
{/if}
<p class="mt-1 text-xs font-semibold uppercase text-muted-foreground sm:col-span-2">Origen</p>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">País de origen</span><select class={inputCls} bind:value={form.origin_country}><option value={undefined}>—</option>{#each crmCatalogs.options('pais') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ciudad de origen</span><input class={inputCls} bind:value={form.origin_city} /></label>
{#if crmCatalogs.options('puerto').length}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puerto/Aeropuerto de origen</span><select class={inputCls} bind:value={form.origin_port}><option value={undefined}>—</option>{#each crmCatalogs.options('puerto') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
{#if crmCatalogs.options('ciudad', form.origin_country ?? undefined).length}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ciudad de origen</span><select class={inputCls} bind:value={form.origin_city}><option value={undefined}>—</option>{#each crmCatalogs.options('ciudad', form.origin_country ?? undefined) as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
{:else}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ciudad de origen</span><input class={inputCls} bind:value={form.origin_city} /></label>
{/if}
{#if portOptions(form.origin_country).length}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puerto/Aeropuerto de origen</span><select class={inputCls} bind:value={form.origin_port}><option value={undefined}>—</option>{#each portOptions(form.origin_country) as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
{:else}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puerto/Aeropuerto de origen</span><input class={inputCls} bind:value={form.origin_port} /></label>
{/if}
@@ -105,9 +171,13 @@
<p class="mt-1 text-xs font-semibold uppercase text-muted-foreground sm:col-span-2">Destino</p>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">País de destino</span><select class={inputCls} bind:value={form.destination_country}><option value={undefined}>—</option>{#each crmCatalogs.options('pais') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ciudad de destino</span><input class={inputCls} bind:value={form.destination_city} /></label>
{#if crmCatalogs.options('puerto').length}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puerto/Aeropuerto de destino</span><select class={inputCls} bind:value={form.destination_port}><option value={undefined}>—</option>{#each crmCatalogs.options('puerto') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
{#if crmCatalogs.options('ciudad', form.destination_country ?? undefined).length}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ciudad de destino</span><select class={inputCls} bind:value={form.destination_city}><option value={undefined}>—</option>{#each crmCatalogs.options('ciudad', form.destination_country ?? undefined) as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
{:else}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ciudad de destino</span><input class={inputCls} bind:value={form.destination_city} /></label>
{/if}
{#if portOptions(form.destination_country).length}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puerto/Aeropuerto de destino</span><select class={inputCls} bind:value={form.destination_port}><option value={undefined}>—</option>{#each portOptions(form.destination_country) as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
{:else}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puerto/Aeropuerto de destino</span><input class={inputCls} bind:value={form.destination_port} /></label>
{/if}
@@ -115,14 +185,14 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha estimada de embarque</span><input type="date" class={inputCls} bind:value={form.estimated_shipment_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha requerida</span><input type="date" class={inputCls} bind:value={form.required_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Agente en destino</span><select class={inputCls} bind:value={form.destination_agent_id}><option value={undefined}>—</option>{#each suppliers as s (s.id)}<option value={s.id}>{s.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Agente en destino</span><select class={inputCls} bind:value={form.destination_agent_id}><option value={undefined}>—</option>{#each agentList as s (s.id)}<option value={s.id}>{s.name}</option>{/each}</select></label>
</div>
{:else if tab === 'mercancia'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de mercancía</span><select class={inputCls} bind:value={form.cargo_type}><option value={undefined}>—</option>{#each crmCatalogs.options('tipo_mercancia') as t (t.value)}<option value={t.value}>{t.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fracción arancelaria (HS)</span><input class="font-mono {inputCls}" maxlength="20" bind:value={form.hs_code} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">País de origen de la mercancía</span><select class={inputCls} bind:value={form.goods_origin_country}><option value={undefined}>—</option>{#each crmCatalogs.options('pais') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Valor de la mercancía</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={form.cargo_value} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Valor de la mercancía</span><div class="flex items-center gap-1"><input type="number" min="0" step="0.01" class="{inputCls} w-full" bind:value={form.cargo_value} /><span class="text-xs text-muted-foreground">{form.currency ?? 'moneda'}</span></div></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Descripción de la mercancía</span><textarea rows="2" class={inputCls} bind:value={form.commodity}></textarea></label>
<div class="flex flex-wrap gap-5 sm:col-span-2">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={form.insurance_required} /><span>Requiere seguro</span></label>
@@ -172,9 +242,9 @@
<p class="text-xs text-muted-foreground sm:col-span-2">P/Vol = (Largo × Ancho × Alto en cm) × cantidad de bultos ÷ 6000 (factor internacional). Se cobra el mayor entre el peso bruto y el P/Vol. Captura Largo/Ancho/Alto y piezas/pallets arriba; el resultado se recalcula solo.</p>
<div class="rounded-md bg-muted/40 p-3 text-sm sm:col-span-2">
<div class="flex justify-between"><span class="text-muted-foreground">Cantidad de bultos</span><span class="font-medium">{airQty}</span></div>
<div class="flex justify-between"><span class="text-muted-foreground">Peso volumétrico (P/Vol)</span><span class="font-medium">{airVolumetric.toFixed(2)}</span></div>
<div class="flex justify-between"><span class="text-muted-foreground">Peso volumétrico (P/Vol)</span><span class="font-medium">{airVolumetric.toFixed(2)} kg</span></div>
<div class="flex justify-between"><span class="text-muted-foreground">Peso bruto</span><span class="font-medium">{(Number(form.weight) || 0).toFixed(2)} kg</span></div>
<div class="mt-1 flex justify-between border-t pt-1"><span class="font-medium">Peso a cobrar</span><span class="font-semibold">{airChargeable.toFixed(2)} kg</span></div>
<div class="mt-1 flex justify-between border-t pt-1"><span class="font-medium">Peso a cobrar (P/Vol)</span><span class="font-semibold">{airChargeable.toFixed(2)} kg</span></div>
</div>
</div>
{/if}

View File

@@ -45,6 +45,7 @@ export function getNavMain(): NavMainItem[] {
// Orden por flujo comercial: captación → embudo → solicitud → cotización → apoyo
items: [
{ title: 'Panel', url: '/dashboard/crm' },
{ title: 'Expedientes', url: '/dashboard/crm/expedientes' },
{ title: 'Clientes / Prospectos', url: '/dashboard/crm/cuentas' },
{ title: 'Contactos', url: '/dashboard/crm/contactos' },
{ title: 'Prospectos (embudo)', url: '/dashboard/crm/prospectos' },

View File

@@ -93,6 +93,7 @@
<Table.Header>
<Table.Row>
<Table.Head>Folio</Table.Head>
<Table.Head>Solicitud</Table.Head>
<Table.Head>Estatus</Table.Head>
<Table.Head class="text-right">Total venta</Table.Head>
<Table.Head class="text-right">Margen</Table.Head>
@@ -103,6 +104,7 @@
{#each filtered as q (q.id)}
<Table.Row>
<Table.Cell class="font-medium"><a class="hover:underline" href={`/dashboard/crm/cotizaciones/${q.id}`}>{q.reference ?? `#${q.id}`}</a></Table.Cell>
<Table.Cell>{#if q.service_request_id}<a class="text-sm hover:underline" href={`/dashboard/crm/solicitudes/${q.service_request_id}`}>{q.service_request_reference ?? `#${q.service_request_id}`}</a>{:else}<span class="text-sm text-muted-foreground">—</span>{/if}</Table.Cell>
<Table.Cell><span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[q.status] ?? ''}">{labelOf(QUOTE_STATUS, q.status)}</span></Table.Cell>
<Table.Cell class="text-right">{formatMoney(q.total_sale, q.currency)}</Table.Cell>
<Table.Cell class="text-right">{formatMoney(q.margin, q.currency)}</Table.Cell>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { ArrowLeft, Receipt, Plus, Trash2, Send, Check, X, Ship, FileText, Mail } from '@lucide/svelte';
import { ArrowLeft, Receipt, Plus, Trash2, Send, Check, X, Ship, FileText, Mail, Calculator } from '@lucide/svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
@@ -220,11 +220,13 @@
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Receipt class="h-6 w-6" /> {quote.reference ?? `Cotización #${quote.id}`}</h1>
<p class="mt-1 text-sm">
<p class="mt-1 flex items-center gap-2 text-sm">
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[quote.status] ?? ''}">{labelOf(QUOTE_STATUS, quote.status)}</span>
{#if quote.case_id}<a class="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 font-mono text-[11px] hover:underline" href={`/dashboard/crm/expedientes/${quote.case_id}`}>📁 Expediente</a>{/if}
</p>
</div>
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="outline" href={`/dashboard/crm/cotizador?quote_id=${quote.id}${quote.service_request_id ? `&service_request_id=${quote.service_request_id}` : ''}`}><Calculator class="mr-1 h-4 w-4" /> Cotizador</Button>
<Button size="sm" variant="outline" onclick={openPdf} disabled={busy}><FileText class="mr-1 h-4 w-4" /> Ver PDF</Button>
<Button size="sm" variant="outline" onclick={openEmail} disabled={busy}><Mail class="mr-1 h-4 w-4" /> Enviar por correo</Button>
{#if quote.status === 'borrador'}

View File

@@ -1,16 +1,30 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Calculator } from '@lucide/svelte';
import { Calculator, Plus } from '@lucide/svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { rateSheetsAPI, type CostOption, type RateMode } from '$lib/api/crm/rates';
import { serviceRequestsAPI, quoteItemsAPI } from '$lib/api/crm';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { formatMoney } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const companyId = $derived(companyStore.activeCompany?.id ?? null);
// Vinculación: ?service_request_id= (prellenar) y ?quote_id= (enviar resultado a concepto)
const srId = $derived(Number(page.url.searchParams.get('service_request_id')) || null);
const quoteId = $derived(Number(page.url.searchParams.get('quote_id')) || null);
// transport_mode + load_type de la solicitud → modo del tarifario
function transportModeToRateMode(transport: string | null, load: string | null): RateMode {
if (transport === 'aereo') return 'aereo';
if (transport === 'terrestre') return 'terrestre';
if (transport === 'maritimo') return load === 'LCL' ? 'maritimo_lcl' : 'maritimo_fcl';
return 'aereo';
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
let f = $state({
@@ -48,6 +62,62 @@
onMount(() => void crmCatalogs.preload(['modo_tarifario', 'tipo_equipo']));
// Prellenar desde la solicitud vinculada (si viene ?service_request_id=)
$effect(() => {
const cid = companyId;
const id = srId;
if (!cid || !id) return;
void (async () => {
try {
const sr = await serviceRequestsAPI.get(id, cid);
f = {
...f,
mode: transportModeToRateMode(sr.transport_mode, sr.load_type),
origin: sr.origin_port || sr.origin || '',
destination: sr.destination_port || sr.destination || '',
on_date: sr.estimated_shipment_date || sr.required_date || '',
gross_weight_kg: sr.weight ?? null,
volume_m3: sr.volume ?? null,
length_cm: sr.length_cm ?? null,
width_cm: sr.width_cm ?? null,
height_cm: sr.height_cm ?? null,
equipment_type: sr.container_equipment || '',
quantity: sr.container_count || sr.pallets_count || sr.pieces_count || 1,
dangerous: !!sr.hazardous_imo
};
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cargar la solicitud');
}
})();
});
// Enviar una opción del cotizador como concepto(s) de la cotización vinculada
async function addToQuote(o: CostOption) {
if (!companyId || !quoteId) return;
working = true;
try {
// Línea base (flete) + una línea por cada cargo adicional
await quoteItemsAPI.create({
quote_id: quoteId, concept: 'flete_internacional',
description: `${o.rate_sheet_name}${o.detail ? ' — ' + o.detail : ''}`,
supplier_id: o.supplier_id ?? undefined, quantity: 1,
unit_cost: o.base_cost, unit_sale: o.base_cost, currency: o.currency ?? undefined
}, companyId);
for (const c of o.charges) {
await quoteItemsAPI.create({
quote_id: quoteId, concept: 'otros', description: c.concept,
quantity: 1, unit_cost: c.amount, unit_sale: c.amount, currency: o.currency ?? undefined
}, companyId);
}
toast.success('Concepto(s) agregado(s) a la cotización');
await goto(`/dashboard/crm/cotizaciones/${quoteId}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo agregar a la cotización');
} finally {
working = false;
}
}
async function calc() {
if (!companyId) return;
if (!f.destination.trim()) { toast.error('Indica el destino'); return; }
@@ -139,7 +209,7 @@
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header><Table.Row><Table.Head>Tarifario</Table.Head><Table.Head>Base</Table.Head><Table.Head>Cargos</Table.Head><Table.Head>Total</Table.Head><Table.Head>Detalle</Table.Head><Table.Head>Tránsito</Table.Head></Table.Row></Table.Header>
<Table.Header><Table.Row><Table.Head>Tarifario</Table.Head><Table.Head>Base</Table.Head><Table.Head>Cargos</Table.Head><Table.Head>Total</Table.Head><Table.Head>Detalle</Table.Head><Table.Head>Tránsito</Table.Head>{#if quoteId}<Table.Head></Table.Head>{/if}</Table.Row></Table.Header>
<Table.Body>
{#each options as o, i (o.rate_sheet_id + '-' + i)}
<Table.Row class={i === 0 ? 'bg-emerald-50/60 dark:bg-emerald-950/20' : ''}>
@@ -149,6 +219,7 @@
<Table.Cell class="font-semibold">{money(o.total_cost, o.currency)}</Table.Cell>
<Table.Cell class="text-xs text-muted-foreground">{o.detail ?? '—'}</Table.Cell>
<Table.Cell>{o.transit_days ?? '—'}</Table.Cell>
{#if quoteId}<Table.Cell class="text-right"><Button size="sm" variant="outline" onclick={() => addToQuote(o)} disabled={working}><Plus class="mr-1 h-4 w-4" /> Agregar</Button></Table.Cell>{/if}
</Table.Row>
{/each}
</Table.Body>

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import { FolderKanban, Search, ChevronRight } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { casesAPI, accountsAPI, type Case, type Account } from '$lib/api/crm';
import { formatDate } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const STAGE_LABEL: Record<string, string> = {
oportunidad: 'Oportunidad', solicitud: 'Solicitud', cotizacion: 'Cotización',
operacion: 'Operación', facturacion: 'Facturación', cerrado: 'Cerrado'
};
let items = $state<Case[]>([]);
let accounts = $state<Account[]>([]);
let loading = $state(false);
let search = $state('');
const companyId = $derived(companyStore.activeCompany?.id ?? null);
function accountName(id: number | null): string {
return accounts.find((a) => a.id === id)?.name ?? '—';
}
const filtered = $derived(
search.trim()
? items.filter((c) => `${c.reference ?? ''} ${c.title ?? ''} ${accountName(c.account_id)}`.toLowerCase().includes(search.trim().toLowerCase()))
: items
);
$effect(() => {
const cid = companyId;
if (!cid) return;
void load(cid);
});
async function load(cid: number) {
loading = true;
try {
[items, accounts] = await Promise.all([casesAPI.list(cid), accountsAPI.list(cid)]);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los expedientes');
} finally {
loading = false;
}
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FolderKanban class="h-6 w-6" /> Expedientes</h1>
<p class="mt-1 text-sm text-muted-foreground">Referencia única que hila todo el trámite (oportunidad → solicitud → cotización → operación → factura).</p>
</div>
<Card.Root>
<Card.Header>
<div class="relative max-w-sm">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<input class="w-full py-2 pl-8 pr-3 {inputCls}" placeholder="Buscar por folio o cliente…" bind:value={search} />
</div>
</Card.Header>
<Card.Content>
{#if loading}
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
{:else if filtered.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">Sin expedientes. Se crean automáticamente al generar una oportunidad.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Expediente</Table.Head>
<Table.Head>Cliente</Table.Head>
<Table.Head>Etapa</Table.Head>
<Table.Head>Estatus</Table.Head>
<Table.Head>Creado</Table.Head>
<Table.Head></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filtered as c (c.id)}
<Table.Row>
<Table.Cell class="font-mono font-medium"><a class="hover:underline" href={`/dashboard/crm/expedientes/${c.id}`}>{c.reference ?? `#${c.id}`}</a></Table.Cell>
<Table.Cell>{accountName(c.account_id)}</Table.Cell>
<Table.Cell>{STAGE_LABEL[c.stage] ?? c.stage}</Table.Cell>
<Table.Cell>{c.status}</Table.Cell>
<Table.Cell>{formatDate(c.created_at)}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" href={`/dashboard/crm/expedientes/${c.id}`} aria-label="Abrir"><ChevronRight class="h-4 w-4" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { ArrowLeft, FolderKanban, Target, FileText, Receipt, Ship, DollarSign } from '@lucide/svelte';
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { casesAPI, type CaseWithTimeline } from '$lib/api/crm';
import { formatDate } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const caseId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let data = $state<CaseWithTimeline | null>(null);
let loading = $state(false);
const STAGE_LABEL: Record<string, string> = {
oportunidad: 'Oportunidad', solicitud: 'Solicitud', cotizacion: 'Cotización',
operacion: 'Operación', facturacion: 'Facturación', cerrado: 'Cerrado'
};
const KIND: Record<string, { label: string; icon: any }> = {
oportunidad: { label: 'Oportunidad', icon: Target },
solicitud: { label: 'Solicitud', icon: FileText },
cotizacion: { label: 'Cotización', icon: Receipt },
operacion: { label: 'Operación / Embarque', icon: Ship },
factura: { label: 'Factura', icon: DollarSign }
};
$effect(() => {
const cid = companyId;
const id = caseId;
if (!cid || !id) return;
void (async () => {
loading = true;
try { data = await casesAPI.get(id, cid); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo cargar el expediente'); }
finally { loading = false; }
})();
});
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/crm/expedientes"><ArrowLeft class="mr-1 h-4 w-4" /> Expedientes</Button>
{#if loading && !data}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if data}
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FolderKanban class="h-6 w-6" /> <span class="font-mono">{data.reference ?? `Expediente #${data.id}`}</span></h1>
<p class="mt-1 text-sm text-muted-foreground">Etapa: <b>{STAGE_LABEL[data.stage] ?? data.stage}</b> · {data.status}{#if data.title} · {data.title}{/if}</p>
</div>
<Card.Root>
<Card.Header><Card.Title class="text-base">Historia del trámite</Card.Title>
<Card.Description>Todos los documentos ligados a este expediente, en orden cronológico.</Card.Description>
</Card.Header>
<Card.Content>
{#if data.timeline.length === 0}
<p class="text-sm text-muted-foreground">Sin movimientos aún.</p>
{:else}
<ol class="relative ml-3 border-l pl-6">
{#each data.timeline as ev (ev.kind + '-' + ev.id)}
{@const K = KIND[ev.kind] ?? { label: ev.kind, icon: FileText }}
<li class="mb-5">
<span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border bg-background">
<K.icon class="h-3.5 w-3.5" />
</span>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs uppercase text-muted-foreground">{K.label}</span>
<a class="font-mono text-sm font-medium hover:underline" href={ev.url}>{ev.reference ?? `#${ev.id}`}</a>
{#if ev.status}<span class="rounded-full bg-muted px-2 py-0.5 text-[10px]">{ev.status}</span>{/if}
<span class="text-xs text-muted-foreground">{formatDate(ev.created_at)}</span>
</div>
</li>
{/each}
</ol>
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>

View File

@@ -4,10 +4,14 @@
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { onMount } from 'svelte';
import { leadsAPI, type Lead, type LeadInput } from '$lib/api/crm';
import { LEAD_SOURCES, LEAD_STATUS, labelOf, formatMoney } from '$lib/components/crm/format';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { toast } from 'svelte-sonner';
onMount(() => void crmCatalogs.ensure('medio_contacto'));
let items = $state<Lead[]>([]);
let loading = $state(false);
let search = $state('');
@@ -233,6 +237,13 @@
{#each LEAD_SOURCES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Medio de contacto preferido</span>
<select class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.preferred_contact_method}>
<option value={undefined}>—</option>
{#each crmCatalogs.options('medio_contacto') as m (m.value)}<option value={m.value}>{m.label}</option>{/each}
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Estado</span>
<select class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.status}>

View File

@@ -4,22 +4,28 @@
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { serviceRequestsAPI, type ServiceRequest } from '$lib/api/crm';
import { serviceRequestsAPI, accountsAPI, type ServiceRequest, type Account } from '$lib/api/crm';
import { OPERATION_TYPES, SR_STATUS, TRANSPORT_MODES, labelOf } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
let items = $state<ServiceRequest[]>([]);
let accounts = $state<Account[]>([]);
let loading = $state(false);
let search = $state('');
let statusFilter = $state('');
let clientFilter = $state('');
const companyId = $derived(companyStore.activeCompany?.id ?? null);
function accountName(id: number | null): string {
return accounts.find((a) => a.id === id)?.name ?? '—';
}
const filtered = $derived(
items.filter((r) => {
if (statusFilter && r.status !== statusFilter) return false;
if (clientFilter && String(r.account_id ?? '') !== clientFilter) return false;
if (search.trim()) {
const q = search.trim().toLowerCase();
return `${r.reference ?? ''} ${r.origin ?? ''} ${r.destination ?? ''}`.toLowerCase().includes(q);
return `${r.reference ?? ''} ${r.origin ?? ''} ${r.destination ?? ''} ${accountName(r.account_id)}`.toLowerCase().includes(q);
}
return true;
})
@@ -34,7 +40,7 @@
async function load(cid: number) {
loading = true;
try {
items = await serviceRequestsAPI.list(cid);
[items, accounts] = await Promise.all([serviceRequestsAPI.list(cid), accountsAPI.list(cid)]);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar las solicitudes');
} finally {
@@ -76,6 +82,10 @@
<option value="">Todos los estatus</option>
{#each SR_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
</select>
<select class={inputCls} bind:value={clientFilter}>
<option value="">Todos los clientes</option>
{#each accounts as a (a.id)}<option value={String(a.id)}>{a.name}</option>{/each}
</select>
</div>
</Card.Header>
<Card.Content>
@@ -89,6 +99,7 @@
<Table.Header>
<Table.Row>
<Table.Head>Folio</Table.Head>
<Table.Head>Cliente</Table.Head>
<Table.Head>Operación</Table.Head>
<Table.Head>Medio</Table.Head>
<Table.Head>Ruta</Table.Head>
@@ -100,6 +111,7 @@
{#each filtered as r (r.id)}
<Table.Row>
<Table.Cell class="font-medium"><a class="hover:underline" href={`/dashboard/crm/solicitudes/${r.id}`}>{r.reference ?? `#${r.id}`}</a></Table.Cell>
<Table.Cell>{accountName(r.account_id)}</Table.Cell>
<Table.Cell>{labelOf(OPERATION_TYPES, r.operation_type)}</Table.Cell>
<Table.Cell>{labelOf(TRANSPORT_MODES, r.transport_mode)}</Table.Cell>
<Table.Cell class="text-sm">{[r.origin, r.destination].filter(Boolean).join(' → ') || '—'}</Table.Cell>

View File

@@ -160,6 +160,7 @@
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FileText class="h-6 w-6" /> {sr.reference ?? `Solicitud #${sr.id}`}</h1>
<p class="mt-1 text-sm text-muted-foreground">{labelOf(OPERATION_TYPES, sr.operation_type)} · {labelOf(SR_STATUS, sr.status)}</p>
{#if sr.case_id}<a class="mt-1 inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 font-mono text-[11px] hover:underline" href={`/dashboard/crm/expedientes/${sr.case_id}`}>📁 Expediente</a>{/if}
</div>
<div class="flex flex-wrap gap-2">
{#if sr.status === 'nueva' || sr.status === 'contacto'}<Button size="sm" variant="outline" onclick={registerContact} disabled={busy}>Registrar contacto</Button>{/if}
@@ -186,6 +187,7 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tarifa</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newRate.rate_amount} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={newRate.currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estatus</span><select class={inputCls} bind:value={newRate.status}>{#each RATE_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Válida hasta</span><input type="date" class={inputCls} bind:value={newRate.valid_until} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={newRate.description} /></label>
<div class="flex justify-end gap-2 sm:col-span-2"><Button variant="outline" size="sm" onclick={() => (adding = false)}>Cancelar</Button><Button size="sm" onclick={saveRate}>Guardar</Button></div>
</div>

View File

@@ -9,7 +9,7 @@
import ServiceRequestFields from '$lib/components/crm/ServiceRequestFields.svelte';
import { toast } from 'svelte-sonner';
let form = $state<ServiceRequestInput>({ operation_type: 'exportacion', status: 'nueva', additional_services: [], additional_service_costs: {} });
let form = $state<ServiceRequestInput>({ operation_type: 'exportacion', status: 'nueva', additional_services: [], additional_service_costs: {}, request_date: new Date().toISOString().slice(0, 10) });
let accounts = $state<Account[]>([]);
let suppliers = $state<Supplier[]>([]);
let contacts = $state<Contact[]>([]);