Compare commits
4 Commits
feature/cr
...
feature/cr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afe659e56a | ||
|
|
b47dc542f2 | ||
|
|
8431132b10 | ||
|
|
8c7aeef1a6 |
75
backend/alembic/versions/d4e5f6a7b8c9_case_expediente.py
Normal file
75
backend/alembic/versions/d4e5f6a7b8c9_case_expediente.py
Normal 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")
|
||||
@@ -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)
|
||||
0
backend/api/v1/modules/crm/cases/__init__.py
Normal file
0
backend/api/v1/modules/crm/cases/__init__.py
Normal file
31
backend/api/v1/modules/crm/cases/dto.py
Normal file
31
backend/api/v1/modules/crm/cases/dto.py
Normal 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] = []
|
||||
28
backend/api/v1/modules/crm/cases/models.py
Normal file
28
backend/api/v1/modules/crm/cases/models.py
Normal 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)
|
||||
51
backend/api/v1/modules/crm/cases/routes.py
Normal file
51
backend/api/v1/modules/crm/cases/routes.py
Normal 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)
|
||||
109
backend/api/v1/modules/crm/cases/service.py
Normal file
109
backend/api/v1/modules/crm/cases/service.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
79
backend/api/v1/modules/crm/catalogs/seed_locations.py
Normal file
79
backend/api/v1/modules/crm/catalogs/seed_locations.py
Normal 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()
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}"'},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 "", [])):
|
||||
|
||||
@@ -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
|
||||
|
||||
48
backend/tests/test_cases.py
Normal file
48
backend/tests/test_cases.py
Normal 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"
|
||||
50
frontend/src/lib/api/crm/cases.ts
Normal file
50
frontend/src/lib/api/crm/cases.ts
Normal 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)}`))
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -13,3 +13,4 @@ export { opportunitiesAPI } from './opportunities';
|
||||
export { activitiesAPI } from './activities';
|
||||
export { metricsAPI } from './metrics';
|
||||
export * from './commercial';
|
||||
export * from './cases';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
98
frontend/src/routes/dashboard/crm/expedientes/+page.svelte
Normal file
98
frontend/src/routes/dashboard/crm/expedientes/+page.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
Reference in New Issue
Block a user