feat(crm): ajustes de la sesión doc 2 (catálogos, bugs, oportunidades, facturación, UI) #3

Closed
admin wants to merge 1 commits from feature/crm-ajustes-sesion-doc2 into feature/crm-cotizacion-aerea
30 changed files with 399 additions and 49 deletions
Showing only changes of commit 9c46f5bf3c - Show all commits

View File

@@ -0,0 +1,56 @@
"""Ajustes de sesión: país ISO-3 en accounts, giro "otro" y formas de pago SAT a 2 dígitos
Revision ID: d3e4f5a6b7c8
Revises: c2d3e4f5a6b7
Create Date: 2026-08-04 01:00:00.000000
- crm.accounts.country String(2)→String(3) (ISO alfa-3, alineado a catálogo pais).
- crm.accounts.industry_other (especificar cuando el giro es "otro").
- Normaliza formas de pago SAT de 1 dígito a 2 (01, 02, …) en el catálogo y en
los valores guardados en accounts/suppliers; y país 'MX''MEX'.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "d3e4f5a6b7c8"
down_revision: Union[str, None] = "c2d3e4f5a6b7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
# País a ISO alfa-3 en accounts (addresses ya es String(3)).
# Primero se amplía la columna; luego se normaliza el dato (evita truncamiento).
op.alter_column(
"accounts", "country", schema=SCHEMA,
existing_type=sa.String(length=2), type_=sa.String(length=3),
existing_nullable=True, server_default=sa.text("'MEX'"),
)
op.execute("UPDATE crm.accounts SET country = 'MEX' WHERE country = 'MX'")
op.execute("UPDATE crm.addresses SET country = 'MEX' WHERE country = 'MX'")
# Giro "otro" — campo para especificar
op.add_column("accounts", sa.Column("industry_other", sa.String(length=120), nullable=True), schema=SCHEMA)
# Formas de pago SAT: 1 dígito → 2 dígitos (catálogo + valores guardados)
op.execute(
"UPDATE crm.catalog_items SET code = lpad(code, 2, '0') "
"WHERE catalog = 'forma_pago' AND char_length(code) = 1"
)
op.execute("UPDATE crm.accounts SET payment_form = lpad(payment_form, 2, '0') WHERE char_length(payment_form) = 1")
op.execute("UPDATE crm.suppliers SET payment_form = lpad(payment_form, 2, '0') WHERE char_length(payment_form) = 1")
def downgrade() -> None:
op.drop_column("accounts", "industry_other", schema=SCHEMA)
# Regresar país a String(2) sin truncar filas existentes
op.execute("UPDATE crm.accounts SET country = 'MX' WHERE country = 'MEX'")
op.alter_column(
"accounts", "country", schema=SCHEMA,
existing_type=sa.String(length=3), type_=sa.String(length=2),
existing_nullable=True, server_default=sa.text("'MX'"),
)
# La normalización de formas de pago no se revierte (evita romper códigos multi-dígito).

View File

@@ -0,0 +1,30 @@
"""Fechas separadas de ganada/perdida en la oportunidad
Revision ID: e4f5a6b7c8d9
Revises: d3e4f5a6b7c8
Create Date: 2026-08-04 02:00:00.000000
Agrega crm.opportunities.won_date y lost_date (fechas de cierre separadas,
editables) además de closed_at y lost_reason ya existentes.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "e4f5a6b7c8d9"
down_revision: Union[str, None] = "d3e4f5a6b7c8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
op.add_column("opportunities", sa.Column("won_date", sa.Date(), nullable=True), schema=SCHEMA)
op.add_column("opportunities", sa.Column("lost_date", sa.Date(), nullable=True), schema=SCHEMA)
def downgrade() -> None:
op.drop_column("opportunities", "lost_date", schema=SCHEMA)
op.drop_column("opportunities", "won_date", schema=SCHEMA)

View File

@@ -13,6 +13,7 @@ class AccountBase(BaseModel):
record_type: str = Field("cliente", max_length=20) # cliente | prospecto
person_type: str | None = Field(None, max_length=10) # fisica | moral
industry: str | None = Field(None, max_length=120)
industry_other: str | None = Field(None, max_length=120)
account_type: str | None = Field(None, max_length=40)
status: str = Field("active", max_length=20) # active | inactive
# Comercial
@@ -38,7 +39,7 @@ class AccountBase(BaseModel):
address: str | None = None
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field("MX", max_length=2)
country: str | None = Field("MEX", max_length=3)
# Observaciones
notes: str | None = None
internal_notes: str | None = None
@@ -57,6 +58,7 @@ class AccountUpdate(BaseModel):
record_type: str | None = Field(None, max_length=20)
person_type: str | None = Field(None, max_length=10)
industry: str | None = Field(None, max_length=120)
industry_other: str | None = Field(None, max_length=120)
account_type: str | None = Field(None, max_length=40)
status: str | None = Field(None, max_length=20)
commercial_classification: str | None = Field(None, max_length=20)
@@ -79,7 +81,7 @@ class AccountUpdate(BaseModel):
address: str | None = None
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field(None, max_length=2)
country: str | None = Field(None, max_length=3)
notes: str | None = None
internal_notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)

View File

@@ -31,6 +31,7 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
# Tipo de persona: fisica | moral
person_type: Mapped[str | None] = mapped_column(String(10), nullable=True)
industry: Mapped[str | None] = mapped_column(String(120), nullable=True) # giro / industria
industry_other: Mapped[str | None] = mapped_column(String(120), nullable=True) # especificar cuando giro = "otro"
# Tipo operativo (immex | agencia_aduanal | importador | exportador | transportista | otro)
account_type: Mapped[str | None] = mapped_column(String(40), nullable=True)
# Estatus: active | inactive
@@ -65,7 +66,7 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
address: Mapped[str | None] = mapped_column(Text, nullable=True)
city: Mapped[str | None] = mapped_column(String(120), nullable=True)
state: Mapped[str | None] = mapped_column(String(120), nullable=True)
country: Mapped[str | None] = mapped_column(String(2), nullable=True, server_default=text("'MX'"))
country: Mapped[str | None] = mapped_column(String(3), nullable=True, server_default=text("'MEX'"))
# ----- Observaciones y auditoría -----
notes: Mapped[str | None] = mapped_column(Text, nullable=True) # comentarios generales

View File

@@ -14,7 +14,7 @@ class AddressBase(BaseModel):
postal_code: str | None = Field(None, max_length=10)
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field("MX", max_length=2)
country: str | None = Field("MEX", max_length=3) # ISO 3166-1 alfa-3 (alineado a catálogo pais)
reference_notes: str | None = None
is_primary: bool = False
@@ -32,7 +32,7 @@ class AddressUpdate(BaseModel):
postal_code: str | None = Field(None, max_length=10)
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field(None, max_length=2)
country: str | None = Field(None, max_length=3)
reference_notes: str | None = None
is_primary: bool | None = None

View File

@@ -847,4 +847,23 @@ GLOBAL_CATALOGS.update({
{'code': 'ficha_tecnica', 'label': 'Ficha técnica'},
{'code': 'carta_instrucciones', 'label': 'Carta de instrucciones'},
{'code': 'otro', 'label': 'Otro'}]},
'incoterm': {'label': 'Incoterm (2020)',
'is_system': True,
'items': [{'code': 'EXW', 'label': 'EXW — Ex Works (en fábrica)'},
{'code': 'FCA', 'label': 'FCA — Free Carrier (franco transportista)'},
{'code': 'FAS', 'label': 'FAS — Free Alongside Ship (franco al costado del buque)'},
{'code': 'FOB', 'label': 'FOB — Free On Board (franco a bordo)'},
{'code': 'CFR', 'label': 'CFR — Cost and Freight (costo y flete)'},
{'code': 'CIF', 'label': 'CIF — Cost, Insurance and Freight (costo, seguro y flete)'},
{'code': 'CPT', 'label': 'CPT — Carriage Paid To (transporte pagado hasta)'},
{'code': 'CIP', 'label': 'CIP — Carriage and Insurance Paid To (transporte y seguro pagados hasta)'},
{'code': 'DAP', 'label': 'DAP — Delivered At Place (entregado en lugar)'},
{'code': 'DPU', 'label': 'DPU — Delivered At Place Unloaded (entregado en lugar descargado)'},
{'code': 'DDP', 'label': 'DDP — Delivered Duty Paid (entregado con derechos pagados)'}]},
})
# Formas de pago SAT de un dígito → dos dígitos (01, 02, 03, 04, 05, 06, 08).
# El SAT exige dos posiciones; se corrige el catálogo base.
for _fp in GLOBAL_CATALOGS.get('forma_pago', {}).get('items', []):
if len(_fp['code']) == 1:
_fp['code'] = _fp['code'].zfill(2)

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.
ENTITIES = ("O", "S", "C", "OP")
# Entidades válidas y su letra de folio (F = factura, sin dirección impo/expo).
ENTITIES = ("O", "S", "C", "OP", "F")
# Mapa dirección de operación → sufijo del folio.
_DIRECTION_SUFFIX = {"importacion": "I", "exportacion": "E"}
@@ -56,11 +56,13 @@ def next_folio(
entity: str,
direction: str | None,
on_date: date | None = None,
with_direction: bool = True,
) -> str:
"""Genera el siguiente folio de una entidad, incrementando su consecutivo mensual.
Reserva el número dentro de la transacción activa (no hace commit): el ``create_*``
que lo invoca es quien confirma junto con la fila recién creada.
que lo invoca es quien confirma junto con la fila recién creada. ``with_direction=False``
omite el sufijo I/E (p. ej. facturas → ``F2026-08-001``).
"""
if entity not in ENTITIES:
raise ValueError(f"Entidad de folio inválida: {entity!r}")
@@ -89,4 +91,6 @@ def next_folio(
db.flush()
sequence = f"{counter.last_number:03d}"
if not with_direction:
return f"{entity}{period}-{sequence}"
return f"{entity}{period}-{sequence}-{direction_suffix(direction)}"

View File

@@ -31,6 +31,8 @@ class OpportunityUpdate(BaseModel):
probability: int | None = Field(None, ge=0, le=100)
status: str | None = Field(None, max_length=20)
expected_close_date: date | None = None
won_date: date | None = None
lost_date: date | None = None
lost_reason: str | None = Field(None, max_length=255)
source: str | None = Field(None, max_length=60)
owner_user_id: str | None = Field(None, max_length=64)
@@ -59,6 +61,8 @@ class OpportunityResponse(BaseModel):
status: str
expected_close_date: date | None
closed_at: datetime | None
won_date: date | None = None
lost_date: date | None = None
lost_reason: str | None
source: str | None
owner_user_id: str | None

View File

@@ -34,6 +34,8 @@ class Opportunity(Base, TenantScopedMixin, TimestampMixin):
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'open'"), index=True)
expected_close_date: Mapped[date | None] = mapped_column(Date, nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
won_date: Mapped[date | None] = mapped_column(Date, nullable=True) # fecha en que se ganó
lost_date: Mapped[date | None] = mapped_column(Date, nullable=True) # fecha en que se perdió
lost_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
source: Mapped[str | None] = mapped_column(String(60), nullable=True)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)

View File

@@ -1,4 +1,4 @@
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
@@ -47,14 +47,20 @@ def _apply_stage_state(opportunity: Opportunity, stage: PipelineStage) -> None:
opportunity.status = "won"
opportunity.probability = 100
opportunity.closed_at = datetime.now(timezone.utc)
opportunity.won_date = opportunity.won_date or date.today()
opportunity.lost_date = None
elif stage.is_lost:
opportunity.status = "lost"
opportunity.probability = 0
opportunity.closed_at = datetime.now(timezone.utc)
opportunity.lost_date = opportunity.lost_date or date.today()
opportunity.won_date = None
else:
opportunity.status = "open"
opportunity.probability = stage.probability
opportunity.closed_at = None
opportunity.won_date = None
opportunity.lost_date = None
def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:

View File

@@ -255,3 +255,15 @@ def rate_quote(
tenant_id, _ = _ctx(current_user)
options = service.quote_cost(db, tenant_id, company_id, req)
return CostResult(request=req, options=options)
@cost_router.get("/rate-locations")
def rate_locations(
mode: str = Query(...),
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Orígenes/destinos cotizables (de los tarifarios activos) para alinear el cotizador."""
tenant_id, _ = _ctx(current_user)
return service.lane_locations(db, tenant_id, company_id, mode)

View File

@@ -479,6 +479,30 @@ def _apply_charges(db: Session, sheet: RateSheet, lane: RateLane, base: Decimal,
return lines
def lane_locations(db: Session, tenant_id: int, company_id: int, mode: str) -> dict[str, list[str]]:
"""Orígenes/destinos existentes en los tarifarios activos de un modo.
Alinea el cotizador con las rutas realmente cotizables (los códigos provienen
de las lanes, por lo que el costeo siempre encontrará ruta).
"""
sheets = _sheet_query(db, tenant_id, company_id).filter(
RateSheet.mode == mode, RateSheet.status == "activo",
).all()
origins: set[str] = set()
destinations: set[str] = set()
for sheet in sheets:
lanes = db.query(RateLane).filter(
RateLane.rate_sheet_id == sheet.id, RateLane.deleted_at.is_(None),
).all()
for lane in lanes:
origin = lane.origin or sheet.default_origin
if origin:
origins.add(origin)
if lane.destination:
destinations.add(lane.destination)
return {"origins": sorted(origins), "destinations": sorted(destinations)}
def quote_cost(db: Session, tenant_id: int, company_id: int, req: CostRequest) -> list[CostOption]:
on_date = req.on_date or date.today()
sheets = _sheet_query(db, tenant_id, company_id).filter(

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.common.folios import next_folio
from api.v1.modules.crm.quotes.models import Quote, QuoteItem
from api.v1.modules.ops.shipments.models import Shipment
@@ -95,6 +96,9 @@ def create_invoice(db, payload: InvoiceCreate, tenant_id, company_id, user_id=No
data = payload.model_dump()
_validate_refs(db, data, tenant_id, company_id)
obj = Invoice(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
# Folio F... auto-generado (mensual) si no viene uno explícito
if not obj.reference:
obj.reference = next_folio(db, tenant_id, company_id, "F", None, with_direction=False)
db.add(obj)
db.flush()
_recompute(db, obj)

View File

@@ -15,7 +15,7 @@ def test_create_and_get_account(db):
)
assert acc.id is not None
assert acc.status == "active"
assert acc.country == "MX"
assert acc.country == "MEX" # ISO 3166-1 alfa-3 (alineado al catálogo pais)
got = service.get_account(db, acc.id, T, C)
assert got.name == "Importadora Demo"
assert got.rfc == "XAXX010101000"

View File

@@ -38,6 +38,12 @@ def test_folio_entities_do_not_share_counter(db):
assert op == "OP2025-08-001-I"
def test_folio_invoice_without_direction(db):
# Facturas: entidad F sin sufijo de dirección (F2025-08-001)
folio = next_folio(db, T, C, "F", None, on_date=date(2025, 8, 3), with_direction=False)
assert folio == "F2025-08-001"
def test_folio_unique_across_many(db):
folios = {next_folio(db, T, C, "C", "importacion", on_date=date(2025, 8, 10)) for _ in range(25)}
assert len(folios) == 25 # sin duplicados

View File

@@ -41,6 +41,8 @@ def test_move_to_won_closes_and_sets_probability(db):
assert moved.status == "won"
assert moved.probability == 100
assert moved.closed_at is not None
assert moved.won_date is not None # fecha de ganada
assert moved.lost_date is None
assert moved.stage_id == s_won.id
@@ -51,6 +53,8 @@ def test_move_to_lost(db):
assert moved.status == "lost"
assert moved.probability == 0
assert moved.closed_at is not None
assert moved.lost_date is not None # fecha de perdida
assert moved.won_date is None
def test_move_back_to_open_reopens(db):

View File

@@ -143,6 +143,14 @@
filter: invert(1) brightness(1.15);
opacity: 0.9;
}
/* Las opciones de los <select> (catálogos) deben ser legibles en modo oscuro:
el control es transparente y el popup nativo hereda colores del sistema. */
select option,
select optgroup {
background-color: var(--color-popover);
color: var(--color-popover-foreground);
}
}
@layer components {

View File

@@ -7,11 +7,12 @@ import type { Contact, ContactInput } from './types';
export const contactsAPI = {
async list(
companyId: number,
params?: { search?: string; account_id?: number }
params?: { search?: string; account_id?: number; supplier_id?: number }
): Promise<Contact[]> {
const qs = new URLSearchParams({ company_id: String(companyId) });
if (params?.search) qs.set('search', params.search);
if (params?.account_id) qs.set('account_id', String(params.account_id));
if (params?.supplier_id) qs.set('supplier_id', String(params.supplier_id));
const res = await api.get<Contact[]>(`/v1/crm/contacts?${qs}`);
if (res.error) throw new Error(res.error);
return res.data!;

View File

@@ -104,5 +104,9 @@ export const rateSheetsAPI = {
return res.data as RateSheet;
},
quote: (req: CostRequest, companyId: number) => unwrap<CostResult>(api.post(`/v1/crm/rate-quote?${qp(companyId)}`, req))
quote: (req: CostRequest, companyId: number) => unwrap<CostResult>(api.post(`/v1/crm/rate-quote?${qp(companyId)}`, req)),
/** Orígenes/destinos que existen en los tarifarios activos (para alinear el cotizador con las rutas cotizables). */
locations: (companyId: number, mode: RateMode) =>
unwrap<{ origins: string[]; destinations: string[] }>(api.get(`/v1/crm/rate-locations?${qp(companyId, { mode })}`))
};

View File

@@ -20,6 +20,7 @@ export interface Account {
record_type: RecordType;
person_type: string | null;
industry: string | null;
industry_other: string | null;
account_type: string | null;
status: AccountStatus;
commercial_classification: string | null;
@@ -261,6 +262,8 @@ export interface Opportunity {
status: OpportunityStatus;
expected_close_date: string | null;
closed_at: string | null;
won_date: string | null;
lost_date: string | null;
lost_reason: string | null;
source: string | null;
owner_user_id: string | null;

View File

@@ -29,6 +29,9 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de registro</span><select class={inputCls} bind:value={form.record_type}>{#each crmCatalogs.options('tipo_registro') as r (r.value)}<option value={r.value}>{r.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de persona</span><select class={inputCls} bind:value={form.person_type}><option value={undefined}>—</option>{#each crmCatalogs.options('tipo_persona') 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">Giro / Industria</span><select class={inputCls} bind:value={form.industry}><option value={undefined}>—</option>{#each crmCatalogs.options('giro') as g (g.value)}<option value={g.value}>{g.label}</option>{/each}</select></label>
{#if form.industry === 'otro'}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Especifica el giro</span><input class={inputCls} bind:value={form.industry_other} placeholder="Indica cuál" /></label>
{/if}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo operativo</span><select class={inputCls} bind:value={form.account_type}><option value={undefined}>—</option>{#each ACCOUNT_TYPES 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">Estatus</span><select class={inputCls} bind:value={form.status}>{#each crmCatalogs.options('estatus') as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { MapPin, Users, FileText, Plus, Trash2 } from '@lucide/svelte';
import { MapPin, Users, FileText, Plus, Trash2, Pencil } 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';
@@ -35,6 +35,7 @@
let contacts = $state<Contact[]>([]);
let documents = $state<Document[]>([]);
let activeModal = $state<'address' | 'contact' | 'document' | null>(null);
let editingId = $state<number | null>(null); // null = alta; con valor = edición
let saving = $state(false);
let addressForm = $state<AddressInput>({ address_type: 'fiscal', country: 'MEX', is_primary: false });
@@ -94,19 +95,37 @@
}
function openModal(kind: 'address' | 'contact' | 'document') {
editingId = null;
if (kind === 'address') addressForm = { address_type: 'fiscal', country: 'MEX', is_primary: false };
if (kind === 'contact') contactForm = { first_name: '' };
if (kind === 'document') documentForm = { doc_type: 'constancia_fiscal', name: '' };
activeModal = kind;
}
function editAddress(a: Address) {
editingId = a.id;
addressForm = { ...a };
activeModal = 'address';
}
function editContact(c: Contact) {
editingId = c.id;
contactForm = { ...c };
activeModal = 'contact';
}
function editDocument(d: Document) {
editingId = d.id;
documentForm = { ...d };
activeModal = 'document';
}
async function saveAddress(e: SubmitEvent) {
e.preventDefault();
if (!companyId) return;
saving = true;
try {
await addressesAPI.create({ ...addressForm, ...ownerParam }, companyId);
toast.success('Dirección agregada');
if (editingId) await addressesAPI.update(editingId, addressForm, companyId);
else await addressesAPI.create({ ...addressForm, ...ownerParam }, companyId);
toast.success(editingId ? 'Dirección actualizada' : 'Dirección agregada');
activeModal = null;
await load(companyId);
} catch (err) {
@@ -122,8 +141,9 @@
if (!contactForm.first_name?.trim()) { toast.error('El nombre es obligatorio'); return; }
saving = true;
try {
await contactsAPI.create({ ...contactForm, ...ownerParam }, companyId);
toast.success('Contacto agregado');
if (editingId) await contactsAPI.update(editingId, contactForm, companyId);
else await contactsAPI.create({ ...contactForm, ...ownerParam }, companyId);
toast.success(editingId ? 'Contacto actualizado' : 'Contacto agregado');
activeModal = null;
await load(companyId);
} catch (err) {
@@ -139,8 +159,9 @@
if (!documentForm.name?.trim()) { toast.error('El nombre es obligatorio'); return; }
saving = true;
try {
await documentsAPI.create({ ...documentForm, ...ownerParam }, companyId);
toast.success('Documento agregado');
if (editingId) await documentsAPI.update(editingId, documentForm, companyId);
else await documentsAPI.create({ ...documentForm, ...ownerParam }, companyId);
toast.success(editingId ? 'Documento actualizado' : 'Documento agregado');
activeModal = null;
await load(companyId);
} catch (err) {
@@ -189,7 +210,7 @@
<Table.Cell class="text-sm">{[a.street, a.ext_number, a.neighborhood].filter(Boolean).join(' ') || '—'}</Table.Cell>
<Table.Cell>{a.postal_code ?? '—'}</Table.Cell>
<Table.Cell>{[a.city, a.state].filter(Boolean).join(', ') || '—'}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeAddress(a)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => editAddress(a)} aria-label="Editar"><Pencil class="h-4 w-4" /></Button><Button variant="ghost" size="sm" onclick={() => removeAddress(a)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
@@ -218,7 +239,7 @@
<Table.Cell class="text-sm">{[c.job_title, c.area ? crmCatalogs.label('area', c.area) : null].filter(Boolean).join(' · ') || '—'}</Table.Cell>
<Table.Cell>{c.email ?? '—'}</Table.Cell>
<Table.Cell>{c.phone ?? c.mobile ?? '—'}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeContact(c)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => editContact(c)} aria-label="Editar"><Pencil class="h-4 w-4" /></Button><Button variant="ghost" size="sm" onclick={() => removeContact(c)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
@@ -246,7 +267,7 @@
<Table.Cell>{labelOf(DOC_TYPES, d.doc_type)}</Table.Cell>
<Table.Cell class="font-medium">{d.name}</Table.Cell>
<Table.Cell>{#if d.file_key || d.file_url}<button type="button" class="text-primary hover:underline" onclick={() => openDoc(d)}>Ver</button>{:else}{/if}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeDocument(d)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => editDocument(d)} aria-label="Editar"><Pencil class="h-4 w-4" /></Button><Button variant="ghost" size="sm" onclick={() => removeDocument(d)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
@@ -261,7 +282,7 @@
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" role="presentation" onclick={() => (activeModal = null)}>
<div class="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-lg border bg-card p-6 shadow-lg" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
{#if activeModal === 'address'}
<h3 class="mb-4 text-base font-semibold">Nueva dirección</h3>
<h3 class="mb-4 text-base font-semibold">{editingId ? 'Editar dirección' : 'Nueva dirección'}</h3>
<form class="grid gap-3 sm:grid-cols-2" onsubmit={saveAddress}>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de domicilio</span><select class={inputCls} bind:value={addressForm.address_type}>{#each crmCatalogs.options('tipo_domicilio') 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">Código Postal</span><input class={inputCls} maxlength="10" bind:value={addressForm.postal_code} /></label>
@@ -270,14 +291,14 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Núm. interior</span><input class={inputCls} bind:value={addressForm.int_number} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Colonia</span><input class={inputCls} bind:value={addressForm.neighborhood} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Municipio / Ciudad</span><input class={inputCls} bind:value={addressForm.city} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estado</span>{#if crmCatalogs.options('estado', addressForm.country).length > 0}<select class={inputCls} bind:value={addressForm.state}><option value={undefined}>—</option>{#each crmCatalogs.options('estado', addressForm.country) as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select>{:else}<input class={inputCls} bind:value={addressForm.state} placeholder="Estado / provincia" />{/if}</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estado</span>{#if crmCatalogs.options('estado', addressForm.country ?? undefined).length > 0}<select class={inputCls} bind:value={addressForm.state}><option value={undefined}>—</option>{#each crmCatalogs.options('estado', addressForm.country ?? undefined) as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select>{:else}<input class={inputCls} bind:value={addressForm.state} placeholder="Estado / provincia" />{/if}</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">País</span><select class={inputCls} bind:value={addressForm.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 sm:col-span-2"><span class="font-medium">Referencias</span><textarea rows="2" class={inputCls} bind:value={addressForm.reference_notes}></textarea></label>
<label class="flex items-center gap-2 text-sm sm:col-span-2"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={addressForm.is_primary} /><span>Domicilio principal</span></label>
<div class="flex justify-end gap-2 sm:col-span-2"><Button type="button" variant="outline" onclick={() => (activeModal = null)}>Cancelar</Button><Button type="submit" disabled={saving}>Guardar</Button></div>
</form>
{:else if activeModal === 'contact'}
<h3 class="mb-4 text-base font-semibold">Nuevo contacto</h3>
<h3 class="mb-4 text-base font-semibold">{editingId ? 'Editar contacto' : 'Nuevo contacto'}</h3>
<form class="grid gap-3 sm:grid-cols-2" onsubmit={saveContact}>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nombre *</span><input class={inputCls} bind:value={contactForm.first_name} required /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Apellidos</span><input class={inputCls} bind:value={contactForm.last_name} /></label>
@@ -297,7 +318,7 @@
<div class="flex justify-end gap-2 sm:col-span-2"><Button type="button" variant="outline" onclick={() => (activeModal = null)}>Cancelar</Button><Button type="submit" disabled={saving}>Guardar</Button></div>
</form>
{:else if activeModal === 'document'}
<h3 class="mb-4 text-base font-semibold">Nuevo documento</h3>
<h3 class="mb-4 text-base font-semibold">{editingId ? 'Editar documento' : 'Nuevo documento'}</h3>
<form class="grid gap-3" onsubmit={saveDocument}>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo</span><select class={inputCls} bind:value={documentForm.doc_type}>{#each DOC_TYPES 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">Nombre *</span><input class={inputCls} bind:value={documentForm.name} required /></label>

View File

@@ -65,7 +65,7 @@
void crmCatalogs.preload([
'pais', 'moneda', 'prioridad', 'tipo_mercancia', 'unidad_medida',
'tipo_embalaje', 'servicio_adicional', 'forma_pago', 'tipo_equipo',
'puerto', 'aeropuerto'
'puerto', 'aeropuerto', 'incoterm', 'medio_transporte'
]);
if (!form.additional_services) form.additional_services = [];
if (!form.additional_service_costs) form.additional_service_costs = {};
@@ -88,9 +88,9 @@
</div>
{:else if tab === 'ruta'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Medio de transporte</span><select class={inputCls} bind:value={form.transport_mode}><option value={undefined}>—</option>{#each 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 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><input class={inputCls} maxlength="10" bind:value={form.incoterm} placeholder="FOB, CIF…" /></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>
<p class="mt-1 text-xs font-semibold uppercase text-muted-foreground sm:col-span-2">Origen</p>

View File

@@ -25,14 +25,47 @@
const hasOtro = $derived((form.classifications ?? []).includes('otro'));
// Utilidades para editar listas separadas por coma desde un catálogo (select + chips)
function csvArr(s: string | undefined): string[] {
return (s || '').split(',').map((x) => x.trim()).filter(Boolean);
}
function csvToggle(s: string | undefined, code: string): string {
const arr = csvArr(s);
const i = arr.indexOf(code);
if (i >= 0) arr.splice(i, 1);
else arr.push(code);
return arr.join(', ');
}
onMount(() => {
void crmCatalogs.preload([
'tipo_persona', 'estatus', 'clasificacion_proveedor', 'cobertura', 'moneda',
'regimen_fiscal', 'metodo_pago', 'forma_pago'
'regimen_fiscal', 'metodo_pago', 'forma_pago', 'pais', 'puerto', 'aeropuerto', 'aduana'
]);
});
</script>
{#snippet catCsv(labelText: string, catalog: string, value: string, set: (v: string) => void, placeholder: string)}
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">{labelText}</span>
{#if crmCatalogs.options(catalog).length}
<select class={inputCls} onchange={(e) => { set(csvToggle(value, e.currentTarget.value)); e.currentTarget.value = ''; }}>
<option value="">+ Agregar…</option>
{#each crmCatalogs.options(catalog) as o (o.value)}<option value={o.value}>{o.label}</option>{/each}
</select>
{#if csvArr(value).length}
<div class="mt-1 flex flex-wrap gap-1">
{#each csvArr(value) as code (code)}
<span class="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs">{crmCatalogs.label(catalog, code)}<button type="button" class="text-muted-foreground hover:text-destructive" onclick={() => set(csvToggle(value, code))} aria-label="Quitar">×</button></span>
{/each}
</div>
{/if}
{:else}
<input class={inputCls} value={value} oninput={(e) => set(e.currentTarget.value)} {placeholder} />
{/if}
</label>
{/snippet}
{#if tab === 'generales'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Razón social *</span><input class={inputCls} bind:value={form.name} required /></label>
@@ -56,10 +89,10 @@
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cobertura</span><select class={inputCls} bind:value={form.coverage}><option value={undefined}>—</option>{#each crmCatalogs.options('cobertura') as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda de cotización</span><select class={inputCls} bind:value={form.quote_currency}><option value={undefined}>—</option>{#each crmCatalogs.options('moneda') as m (m.value)}<option value={m.value}>{m.value} {m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Países donde opera (separados por coma)</span><input class={inputCls} bind:value={countriesStr} placeholder="México, Estados Unidos" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puertos donde opera</span><input class={inputCls} bind:value={portsStr} placeholder="Veracruz, Manzanillo" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Aeropuertos donde opera</span><input class={inputCls} bind:value={airportsStr} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Aduanas donde opera</span><input class={inputCls} bind:value={customsStr} placeholder="Nuevo Laredo, Colombia" /></label>
{@render catCsv('Países donde opera', 'pais', countriesStr, (v) => (countriesStr = v), 'México, Estados Unidos')}
{@render catCsv('Puertos donde opera', 'puerto', portsStr, (v) => (portsStr = v), 'Veracruz, Manzanillo')}
{@render catCsv('Aeropuertos donde opera', 'aeropuerto', airportsStr, (v) => (airportsStr = v), 'MEX, GDL')}
{@render catCsv('Aduanas donde opera', 'aduana', customsStr, (v) => (customsStr = v), 'Nuevo Laredo, Colombia')}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Horario de atención</span><input class={inputCls} bind:value={form.business_hours} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tiempo prom. de respuesta</span><input class={inputCls} bind:value={form.avg_response_time} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Email</span><input type="email" class={inputCls} bind:value={form.email} /></label>

View File

@@ -4,11 +4,12 @@
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { contactsAPI, accountsAPI, type Contact, type ContactInput, type Account } from '$lib/api/crm';
import { contactsAPI, accountsAPI, suppliersAPI, type Contact, type ContactInput, type Account, type Supplier } from '$lib/api/crm';
import { toast } from 'svelte-sonner';
let items = $state<Contact[]>([]);
let accounts = $state<Account[]>([]);
let suppliers = $state<Supplier[]>([]);
let loading = $state(false);
let search = $state('');
let modalOpen = $state(false);
@@ -18,8 +19,18 @@
const companyId = $derived(companyStore.activeCompany?.id ?? null);
function accountName(id: number | null): string {
return accounts.find((a) => a.id === id)?.name ?? '—';
// A quién pertenece el contacto: cliente/prospecto (cuenta) o proveedor
function ownerLabel(c: Contact): { kind: string; name: string } | null {
if (c.account_id) {
const a = accounts.find((x) => x.id === c.account_id);
const kind = a?.record_type === 'prospecto' ? 'Prospecto' : 'Cliente';
return { kind, name: a?.name ?? `#${c.account_id}` };
}
if (c.supplier_id) {
const s = suppliers.find((x) => x.id === c.supplier_id);
return { kind: 'Proveedor', name: s?.name ?? `#${c.supplier_id}` };
}
return null;
}
const filtered = $derived(
@@ -41,7 +52,7 @@
async function load(cid: number) {
loading = true;
try {
[items, accounts] = await Promise.all([contactsAPI.list(cid), accountsAPI.list(cid)]);
[items, accounts, suppliers] = await Promise.all([contactsAPI.list(cid), accountsAPI.list(cid), suppliersAPI.list(cid)]);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los contactos');
} finally {
@@ -136,7 +147,7 @@
<Table.Header>
<Table.Row>
<Table.Head>Nombre</Table.Head>
<Table.Head>Cuenta</Table.Head>
<Table.Head>Pertenece a</Table.Head>
<Table.Head>Puesto</Table.Head>
<Table.Head>Email</Table.Head>
<Table.Head>Teléfono</Table.Head>
@@ -150,7 +161,7 @@
{c.first_name} {c.last_name ?? ''}
{#if c.is_primary}<span class="ml-1 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">Principal</span>{/if}
</Table.Cell>
<Table.Cell>{accountName(c.account_id)}</Table.Cell>
<Table.Cell>{#if ownerLabel(c)}{@const o = ownerLabel(c)}<span class="rounded-full bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">{o?.kind}</span> <span>{o?.name}</span>{:else}{/if}</Table.Cell>
<Table.Cell>{c.job_title ?? '—'}</Table.Cell>
<Table.Cell>{c.email ?? '—'}</Table.Cell>
<Table.Cell>{c.phone ?? c.mobile ?? '—'}</Table.Cell>

View File

@@ -12,6 +12,7 @@
} from '$lib/api/crm';
import { shipmentsAPI } from '$lib/api/ops';
import { QUOTE_STATUS, QUOTE_CONCEPTS, labelOf, formatMoney } from '$lib/components/crm/format';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { toast } from 'svelte-sonner';
const quoteId = $derived(Number(page.params.id));
@@ -39,6 +40,7 @@
async function load(cid: number, id: number) {
loading = true;
void crmCatalogs.preload(['moneda']);
try {
[quote, items, accounts, requests, suppliers] = await Promise.all([
quotesAPI.get(id, cid),
@@ -294,7 +296,7 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Solicitud (RFQ)</span><select class={inputCls} bind:value={form.service_request_id}><option value={undefined}>—</option>{#each requests as r (r.id)}<option value={r.id}>{r.reference ?? `#${r.id}`}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><select class={inputCls} bind:value={form.currency}>{#each crmCatalogs.options('moneda') as m (m.value)}<option value={m.value}>{m.value} {m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha de la cotización</span><input type="date" class={inputCls} bind:value={form.issue_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vigencia</span><input type="date" class={inputCls} bind:value={form.valid_until} /></label>
{#if quote.load_type}<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Variante</span><input class="{inputCls} bg-muted/40" value={quote.load_type} readonly /></label>{/if}

View File

@@ -5,6 +5,7 @@
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { quotesAPI, accountsAPI, serviceRequestsAPI, type QuoteInput, type Account, type ServiceRequest } from '$lib/api/crm';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { toast } from 'svelte-sonner';
let form = $state<QuoteInput>({ currency: 'USD', issue_date: new Date().toISOString().slice(0, 10) });
@@ -17,6 +18,7 @@
$effect(() => {
const cid = companyId;
if (!cid) return;
void crmCatalogs.preload(['moneda']);
void (async () => {
[accounts, requests] = await Promise.all([accountsAPI.list(cid), serviceRequestsAPI.list(cid)]);
})();
@@ -49,7 +51,7 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} placeholder="COT-0001" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Solicitud (RFQ)</span><select class={inputCls} bind:value={form.service_request_id}><option value={undefined}>—</option>{#each requests as r (r.id)}<option value={r.id}>{r.reference ?? `#${r.id}`}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><select class={inputCls} bind:value={form.currency}>{#each crmCatalogs.options('moneda') as m (m.value)}<option value={m.value}>{m.value} {m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha de la cotización</span><input type="date" class={inputCls} bind:value={form.issue_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vigencia</span><input type="date" class={inputCls} bind:value={form.valid_until} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Notas</span><textarea rows="2" class={inputCls} bind:value={form.notes}></textarea></label>

View File

@@ -22,6 +22,18 @@
let options = $state<CostOption[]>([]);
let calculated = $state(false);
let working = $state(false);
// Orígenes/destinos alineados a las rutas de los tarifarios activos del modo
let locs = $state<{ origins: string[]; destinations: string[] }>({ origins: [], destinations: [] });
$effect(() => {
const cid = companyId;
const mode = f.mode;
if (!cid) return;
void (async () => {
try { locs = await rateSheetsAPI.locations(cid, mode); }
catch { locs = { origins: [], destinations: [] }; }
})();
});
const isFcl = $derived(f.mode === 'maritimo_fcl' || f.mode === 'terrestre');
const isAir = $derived(f.mode === 'aereo');
@@ -70,8 +82,20 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Modo *</span>
<select class={inputCls} bind:value={f.mode}>{#each crmCatalogs.options('modo_tarifario') 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">Origen</span><input class={inputCls} bind:value={f.origin} placeholder="NLU / MXZLO" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Destino *</span><input class={inputCls} bind:value={f.destination} placeholder="FRA / CNSHA" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Origen</span>
{#if locs.origins.length}
<select class={inputCls} bind:value={f.origin}><option value=""></option>{#each locs.origins as o (o)}<option value={o}>{o}</option>{/each}</select>
{:else}
<input class={inputCls} bind:value={f.origin} placeholder="NLU / MXZLO" />
{/if}
</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Destino *</span>
{#if locs.destinations.length}
<select class={inputCls} bind:value={f.destination}><option value=""></option>{#each locs.destinations as d (d)}<option value={d}>{d}</option>{/each}</select>
{:else}
<input class={inputCls} bind:value={f.destination} placeholder="FRA / CNSHA" />
{/if}
</label>
{#if isFcl}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de equipo</span>

View File

@@ -17,6 +17,7 @@
type Account
} from '$lib/api/crm';
import { formatMoney, OPERATION_TYPES, TRANSPORT_MODES } from '$lib/components/crm/format';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { toast } from 'svelte-sonner';
let pipelines = $state<Pipeline[]>([]);
@@ -38,6 +39,12 @@
let convertOpp = $state<Opportunity | null>(null);
let convertForm = $state<{ operation_type: string; transport_mode?: string; incoterm?: string; origin?: string; destination?: string; notes?: string }>({ operation_type: 'exportacion' });
// Cierre de oportunidad (ganada/perdida) con fecha y, si se pierde, motivo
let closeOpen = $state(false);
let closing = $state(false);
let closeCtx = $state<{ opp: Opportunity; stageId: number; isWon: boolean } | null>(null);
let closeForm = $state<{ date: string; reason: string }>({ date: '', reason: '' });
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const currentStages = $derived(
@@ -62,6 +69,7 @@
$effect(() => {
const cid = companyId;
if (!cid) return;
void crmCatalogs.preload(['incoterm']);
void load(cid);
});
@@ -102,8 +110,6 @@
companyId
);
const defs: [string, number, boolean, boolean][] = [
['Prospecto', 10, false, false],
['Contactado', 25, false, false],
['Propuesta', 50, false, false],
['Negociación', 75, false, false],
['Ganada', 100, true, false],
@@ -204,6 +210,14 @@
if (!id || !companyId) return;
const opp = opps.find((o) => o.id === id);
if (!opp || opp.stage_id === stageId) return;
// Al mover a Ganada/Perdida, pedir fecha (y motivo si se pierde) antes de cerrar
const stage = currentStages.find((s) => s.id === stageId);
if (stage && (stage.is_won || stage.is_lost)) {
closeCtx = { opp, stageId, isWon: !!stage.is_won };
closeForm = { date: new Date().toISOString().slice(0, 10), reason: '' };
closeOpen = true;
return;
}
try {
const updated = await opportunitiesAPI.move(id, stageId, companyId);
opps = opps.map((o) => (o.id === id ? updated : o));
@@ -211,6 +225,24 @@
toast.error(e instanceof Error ? e.message : 'No se pudo mover la oportunidad');
}
}
async function confirmClose() {
if (!companyId || !closeCtx) return;
closing = true;
try {
await opportunitiesAPI.move(closeCtx.opp.id, closeCtx.stageId, companyId);
const patch = closeCtx.isWon
? { won_date: closeForm.date || undefined }
: { lost_date: closeForm.date || undefined, lost_reason: closeForm.reason || undefined };
const updated = await opportunitiesAPI.update(closeCtx.opp.id, patch, companyId);
opps = opps.map((o) => (o.id === closeCtx!.opp.id ? updated : o));
closeOpen = false;
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cerrar la oportunidad');
} finally {
closing = false;
}
}
</script>
<div class="space-y-6">
@@ -292,6 +324,11 @@
<span class="text-[10px] text-muted-foreground">{opp.probability}%</span>
{/if}
</div>
{#if opp.won_date}
<p class="mt-1 text-[10px] text-emerald-600 dark:text-emerald-400">Ganada: {opp.won_date}</p>
{:else if opp.lost_date}
<p class="mt-1 text-[10px] text-red-600 dark:text-red-400">Perdida: {opp.lost_date}{#if opp.lost_reason}{opp.lost_reason}{/if}</p>
{/if}
<button type="button" class="mt-2 inline-flex items-center gap-1 text-[11px] text-primary hover:underline" onclick={() => openConvert(opp)}>
<FileOutput class="h-3 w-3" /> Convertir a solicitud
</button>
@@ -371,7 +408,7 @@
<option value={undefined}>—</option>{#each 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">Incoterm</span><input class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" maxlength="10" bind:value={convertForm.incoterm} placeholder="FOB, CIF…" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Incoterm</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={convertForm.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">Origen</span><input class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={convertForm.origin} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Destino</span><input class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={convertForm.destination} /></label>
</div>
@@ -384,3 +421,28 @@
</div>
</div>
{/if}
{#if closeOpen && closeCtx}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" role="presentation" onclick={() => (closeOpen = false)}>
<div class="w-full max-w-md rounded-lg border bg-card p-6 shadow-lg" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h2 class="mb-1 text-lg font-semibold">{closeCtx.isWon ? 'Marcar como ganada' : 'Marcar como perdida'}</h2>
<p class="mb-4 text-sm text-muted-foreground">{closeCtx.opp.name}</p>
<div class="grid gap-4">
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">{closeCtx.isWon ? 'Fecha de ganada' : 'Fecha de perdida'}</span>
<input type="date" class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={closeForm.date} />
</label>
{#if !closeCtx.isWon}
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Motivo de la pérdida</span>
<textarea rows="3" class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={closeForm.reason} placeholder="¿Por qué se perdió esta oportunidad?"></textarea>
</label>
{/if}
</div>
<div class="mt-5 flex justify-end gap-2 border-t pt-4">
<Button variant="outline" onclick={() => (closeOpen = false)}>Cancelar</Button>
<Button onclick={confirmClose} disabled={closing}>{closing ? 'Guardando…' : (closeCtx.isWon ? 'Marcar ganada' : 'Marcar perdida')}</Button>
</div>
</div>
</div>
{/if}

View File

@@ -6,6 +6,7 @@
import { companyStore } from '$lib/stores/company.svelte';
import { invoicesAPI, type InvoiceInput } from '$lib/api/fin';
import { accountsAPI, type Account } from '$lib/api/crm';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { toast } from 'svelte-sonner';
let form = $state<InvoiceInput>({ currency: 'MXN', tax_rate: 16 });
@@ -16,6 +17,7 @@
$effect(() => {
const cid = companyId;
if (!cid) return;
void crmCatalogs.preload(['moneda']);
void (async () => { accounts = await accountsAPI.list(cid); })();
});
@@ -44,9 +46,9 @@
<Card.Root>
<Card.Content class="pt-6">
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} placeholder="F-0001" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class="{inputCls} bg-muted/40" bind:value={form.reference} readonly placeholder="Se genera automáticamente (F2026-08-001)" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><select class={inputCls} bind:value={form.currency}>{#each crmCatalogs.options('moneda') as m (m.value)}<option value={m.value}>{m.value} {m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">% Impuesto (IVA)</span><input type="number" min="0" max="100" step="0.01" class={inputCls} bind:value={form.tax_rate} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vencimiento</span><input type="date" class={inputCls} bind:value={form.due_date} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Datos bancarios</span><textarea rows="2" class={inputCls} bind:value={form.bank_info}></textarea></label>