feat(mi-dia): tabs Mis oportunidades/Mis apartados con fases y cotizador vinculado
- Resumen Mi día: misOportunidades.items (lista de trabajo con perfil y último cambio) y misApartados (Vendedor = yo, estados no finales) - Nuevos endpoints: POST /oportunidades/:id/etapa (Agendo Cita, Afluencia, Cotizador, Aparto, No Asistio, Abandono; con fecha de cita) y POST /apartados/:id/estado (pipeline Enkontrol: Pago Apartado → Firma Cliente (Venta) / Cancelacion Apartado); ambos mueven el item al grupo Monday correspondiente - Mi día: tabs Mis leads / Mis oportunidades / Mis apartados; botón Cotizar abre /quote con el cliente prellenado y la oportunidad ligada - Cotizador: prefill desde query params (cliente, teléfono, email, desarrollo, unidad) y envío de oportunidadId; al guardar, el API actualiza ESE item (Precio final, Tipo propuesta, etapa Cotizador) en vez de buscar por teléfono o crear duplicado - grupos-estado: 'Cancelacion Apartado' → Historico - Estados finales de apartado: agrega Firma Cliente (Venta) y Cancelacion Apartado - Tests: 455 API + 14 mi-dia + 88 cotizador, todo en verde
This commit is contained in:
@@ -4,10 +4,12 @@ import {
|
||||
esRolConEquipo,
|
||||
formatoFechaCorta,
|
||||
hoyLocal,
|
||||
urlCotizadorParaOportunidad,
|
||||
DESARROLLOS_LEAD,
|
||||
MOTIVOS_NO_COMPRA,
|
||||
ORIGENES_LEAD,
|
||||
textoDiasRestantes,
|
||||
type OportunidadMiDia,
|
||||
} from './mi-dia'
|
||||
|
||||
describe('catálogos', () => {
|
||||
@@ -67,3 +69,41 @@ describe('hoyLocal', () => {
|
||||
expect(hoyLocal()).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('urlCotizadorParaOportunidad', () => {
|
||||
const opBase: OportunidadMiDia = {
|
||||
id: 'opp-1',
|
||||
nombre: 'María Pérez',
|
||||
telefono: '4771234567',
|
||||
email: 'maria@correo.com',
|
||||
etapa: 'Afluencia',
|
||||
desarrollo: 'SENDA',
|
||||
modeloInteres: 'SOHO',
|
||||
unidadCodigo: 'U-9',
|
||||
updatedAt: '2026-09-04T10:00:00Z',
|
||||
ultimoCambio: null,
|
||||
}
|
||||
|
||||
it('prellena cliente, contacto, desarrollo y unidad, y vincula la oportunidad', () => {
|
||||
const url = urlCotizadorParaOportunidad(opBase)
|
||||
const params = new URLSearchParams(url.replace('/quote/?', ''))
|
||||
expect(url.startsWith('/quote/?')).toBe(true)
|
||||
expect(params.get('cliente')).toBe('María Pérez')
|
||||
expect(params.get('telefono')).toBe('4771234567')
|
||||
expect(params.get('email')).toBe('maria@correo.com')
|
||||
expect(params.get('desarrollo')).toBe('SENDA')
|
||||
expect(params.get('unidad')).toBe('U-9')
|
||||
expect(params.get('oportunidadId')).toBe('opp-1')
|
||||
})
|
||||
|
||||
it('omite los campos vacíos', () => {
|
||||
const url = urlCotizadorParaOportunidad({ ...opBase, telefono: '', email: '', desarrollo: '', unidadCodigo: '' })
|
||||
const params = new URLSearchParams(url.replace('/quote/?', ''))
|
||||
expect(params.get('cliente')).toBe('María Pérez')
|
||||
expect(params.get('oportunidadId')).toBe('opp-1')
|
||||
expect(params.has('telefono')).toBe(false)
|
||||
expect(params.has('email')).toBe(false)
|
||||
expect(params.has('desarrollo')).toBe(false)
|
||||
expect(params.has('unidad')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,6 +51,30 @@ export interface ClienteMiDia {
|
||||
estadoApartado: string | null
|
||||
}
|
||||
|
||||
/** Oportunidad asignada al asesor (tab "Mis oportunidades") */
|
||||
export interface OportunidadMiDia {
|
||||
id: string
|
||||
nombre: string
|
||||
telefono: string
|
||||
email: string
|
||||
etapa: string
|
||||
desarrollo: string
|
||||
modeloInteres: string
|
||||
unidadCodigo: string
|
||||
updatedAt: string
|
||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
||||
}
|
||||
|
||||
/** Apartado asignado al vendedor (tab "Mis apartados") */
|
||||
export interface ApartadoMiDia {
|
||||
id: string
|
||||
nombre: string
|
||||
unidadCodigo: string
|
||||
estado: string
|
||||
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
||||
}
|
||||
|
||||
export interface ResumenMiDia {
|
||||
generadoEn: string
|
||||
sinMapeoMonday: boolean
|
||||
@@ -59,7 +83,8 @@ export interface ResumenMiDia {
|
||||
misClientes: ClienteMiDia[]
|
||||
misActividadesHoy: ActividadMiDia[]
|
||||
apartadosPorVencer: ApartadoPorVencer[]
|
||||
misOportunidades: { porEtapa: Record<string, number> } // conteo de MIS oportunidades por etapa
|
||||
misOportunidades: { porEtapa: Record<string, number>; items: OportunidadMiDia[] } // conteo + lista de trabajo
|
||||
misApartados: ApartadoMiDia[]
|
||||
conteos: {
|
||||
leadsNuevosSemana: number
|
||||
actividadesAbiertas: number
|
||||
@@ -94,6 +119,59 @@ export const MOTIVOS_NO_COMPRA = [
|
||||
|
||||
export type MotivoNoCompra = (typeof MOTIVOS_NO_COMPRA)[number]
|
||||
|
||||
// Fases del funnel de Oportunidades (labels REALES del board; el API las valida tal cual)
|
||||
export const FASES_OPORTUNIDAD = ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'] as const
|
||||
export type FaseOportunidad = (typeof FASES_OPORTUNIDAD)[number]
|
||||
|
||||
// Fases del pipeline Enkontrol del tablero Apartado (labels REALES del board)
|
||||
export const FASES_APARTADO = [
|
||||
'Pago Apartado',
|
||||
'Alta Enkontrol',
|
||||
'Entrego Documentacion',
|
||||
'Cita Clientes',
|
||||
'Contrato Enkontrol',
|
||||
'Firma Cliente (Venta)',
|
||||
'Cancelacion Apartado',
|
||||
] as const
|
||||
export type FaseApartado = (typeof FASES_APARTADO)[number]
|
||||
|
||||
// Texto bonito de los botones (el valor que se manda al API es el label real)
|
||||
export const ETIQUETA_FASE_APARTADO: Record<FaseApartado, string> = {
|
||||
'Pago Apartado': 'Pago apartado',
|
||||
'Alta Enkontrol': 'Alta Enkontrol',
|
||||
'Entrego Documentacion': 'Entregó documentación',
|
||||
'Cita Clientes': 'Cita cliente',
|
||||
'Contrato Enkontrol': 'Contrato Enkontrol',
|
||||
'Firma Cliente (Venta)': 'Firma cliente (venta)',
|
||||
'Cancelacion Apartado': 'Cancelación',
|
||||
}
|
||||
|
||||
// Texto bonito de los botones de oportunidad
|
||||
export const ETIQUETA_FASE_OPORTUNIDAD: Record<FaseOportunidad, string> = {
|
||||
'Agendo Cita': 'Agendó cita',
|
||||
Afluencia: 'Afluencia',
|
||||
Cotizador: 'Cotizador',
|
||||
Aparto: 'Apartó',
|
||||
'No Asistio': 'No asistió',
|
||||
Abandono: 'Abandono',
|
||||
}
|
||||
|
||||
/**
|
||||
* URL del cotizador (/quote/) con los datos del cliente prellenados y el id de
|
||||
* la oportunidad: al guardar, la cotización queda vinculada a ese cliente en
|
||||
* Monday (el API actualiza ese item en vez de crear uno nuevo).
|
||||
*/
|
||||
export function urlCotizadorParaOportunidad(op: OportunidadMiDia): string {
|
||||
const params = new URLSearchParams()
|
||||
params.set('cliente', op.nombre)
|
||||
if (op.telefono !== '') params.set('telefono', op.telefono)
|
||||
if (op.email !== '') params.set('email', op.email)
|
||||
if (op.desarrollo !== '') params.set('desarrollo', op.desarrollo)
|
||||
if (op.unidadCodigo !== '') params.set('unidad', op.unidadCodigo)
|
||||
params.set('oportunidadId', op.id)
|
||||
return `/quote/?${params.toString()}`
|
||||
}
|
||||
|
||||
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
|
||||
|
||||
/** ¿El rol ve la sección de equipo? (misma regla que el API) */
|
||||
|
||||
@@ -7,14 +7,28 @@ import {
|
||||
formatoFechaCorta,
|
||||
hoyLocal,
|
||||
DESARROLLOS_LEAD,
|
||||
ETIQUETA_FASE_APARTADO,
|
||||
ETIQUETA_FASE_OPORTUNIDAD,
|
||||
FASES_APARTADO,
|
||||
MOTIVOS_NO_COMPRA,
|
||||
ORIGENES_LEAD,
|
||||
textoDiasRestantes,
|
||||
urlCotizadorParaOportunidad,
|
||||
type ApartadoMiDia,
|
||||
type FaseApartado,
|
||||
type LeadMiDia,
|
||||
type MotivoNoCompra,
|
||||
type OportunidadMiDia,
|
||||
type ResumenMiDia,
|
||||
} from '../lib/mi-dia'
|
||||
import { cambiarEstadoLead, crearLead, descartarLead, fetchResumenMiDia } from '../services/mi-dia'
|
||||
import {
|
||||
cambiarEstadoApartado,
|
||||
cambiarEstadoLead,
|
||||
cambiarEtapaOportunidad,
|
||||
crearLead,
|
||||
descartarLead,
|
||||
fetchResumenMiDia,
|
||||
} from '../services/mi-dia'
|
||||
import { getSesion } from '../services/auth'
|
||||
|
||||
function TituloSeccion({ children }: { children: string }) {
|
||||
@@ -44,7 +58,7 @@ function ModalDescartar({
|
||||
onConfirmar,
|
||||
enviando,
|
||||
}: {
|
||||
lead: LeadMiDia
|
||||
lead: { id: string; nombre: string }
|
||||
onCancelar: () => void
|
||||
onConfirmar: (motivo: MotivoNoCompra, comentario: string) => void
|
||||
enviando: boolean
|
||||
@@ -115,6 +129,201 @@ function ModalDescartar({
|
||||
)
|
||||
}
|
||||
|
||||
/** Fila de oportunidad con botones de fase del funnel (Agendo Cita lleva fecha) */
|
||||
function FilaOportunidad({
|
||||
op,
|
||||
onEtapa,
|
||||
onCotizar,
|
||||
onDescartar,
|
||||
enviando,
|
||||
}: {
|
||||
op: OportunidadMiDia
|
||||
onEtapa: (etapa: string, fecha?: string) => void
|
||||
onCotizar: () => void
|
||||
onDescartar: () => void
|
||||
enviando: boolean
|
||||
}) {
|
||||
const [conCita, setConCita] = useState(false)
|
||||
const [fechaCita, setFechaCita] = useState('')
|
||||
const fases = ['Agendo Cita', 'Afluencia', 'Aparto', 'No Asistio', 'Abandono'] as const
|
||||
return (
|
||||
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{op.nombre}</p>
|
||||
<p className="mt-1 text-xs text-[#64748B]">
|
||||
{op.telefono || 'Sin teléfono'}
|
||||
{op.desarrollo ? ` · ${op.desarrollo}` : ''}
|
||||
{op.modeloInteres ? ` · ${op.modeloInteres}` : ''}
|
||||
{op.unidadCodigo ? ` · ${op.unidadCodigo}` : ''}
|
||||
</p>
|
||||
{op.ultimoCambio && (
|
||||
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">
|
||||
Último cambio: {op.ultimoCambio.detalle} · {op.ultimoCambio.usuario ?? '—'} ·{' '}
|
||||
{formatoFechaCorta(op.ultimoCambio.fechaISO.slice(0, 10))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
||||
{op.etapa || 'Sin etapa'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{/* Cotizar: cambia la etapa y abre el cotizador con los datos prellenados */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={enviando}
|
||||
onClick={onCotizar}
|
||||
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
Cotizar →
|
||||
</button>
|
||||
{fases.map((f) =>
|
||||
f === 'Agendo Cita' ? (
|
||||
<span key={f} className="contents">
|
||||
<button
|
||||
type="button"
|
||||
disabled={enviando}
|
||||
onClick={() => (conCita ? onEtapa(f, fechaCita || undefined) : setConCita(true))}
|
||||
className={CLASE_BOTON_ACCION}
|
||||
>
|
||||
{conCita ? 'Confirmar cita' : ETIQUETA_FASE_OPORTUNIDAD[f]}
|
||||
</button>
|
||||
{conCita && (
|
||||
<input
|
||||
type="date"
|
||||
aria-label="Fecha de la cita"
|
||||
value={fechaCita}
|
||||
onChange={(e) => setFechaCita(e.target.value)}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
disabled={enviando}
|
||||
onClick={() => onEtapa(f)}
|
||||
className={CLASE_BOTON_ACCION}
|
||||
>
|
||||
{ETIQUETA_FASE_OPORTUNIDAD[f]}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={enviando}
|
||||
onClick={onDescartar}
|
||||
className="rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 shadow-sm transition-all duration-100 ease-out hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
Descartar
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** Fila de apartado con botones de fase del pipeline Enkontrol */
|
||||
function FilaApartado({
|
||||
apartado,
|
||||
onFase,
|
||||
enviando,
|
||||
}: {
|
||||
apartado: ApartadoMiDia
|
||||
onFase: (fase: FaseApartado) => void
|
||||
enviando: boolean
|
||||
}) {
|
||||
return (
|
||||
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{apartado.nombre}
|
||||
{apartado.unidadCodigo ? (
|
||||
<span className="ml-2 text-xs font-normal text-[#94A3B8]">{apartado.unidadCodigo}</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#64748B]">
|
||||
{apartado.fechaLimite !== '' ? `Fecha límite: ${formatoFechaCorta(apartado.fechaLimite)}` : 'Sin fecha límite'}
|
||||
</p>
|
||||
{apartado.ultimoCambio && (
|
||||
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">
|
||||
Último cambio: {apartado.ultimoCambio.detalle} · {apartado.ultimoCambio.usuario ?? '—'} ·{' '}
|
||||
{formatoFechaCorta(apartado.ultimoCambio.fechaISO.slice(0, 10))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
||||
{apartado.estado || 'Sin estado'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{FASES_APARTADO.map((fase) => (
|
||||
<button
|
||||
key={fase}
|
||||
type="button"
|
||||
disabled={enviando}
|
||||
onClick={() => onFase(fase)}
|
||||
className={
|
||||
fase === 'Cancelacion Apartado'
|
||||
? 'rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 shadow-sm transition-all duration-100 ease-out hover:bg-red-50 disabled:opacity-50'
|
||||
: fase === apartado.estado
|
||||
? 'rounded-lg border border-accent bg-accent/5 px-3 py-1.5 text-xs font-medium text-accent shadow-sm disabled:opacity-50'
|
||||
: CLASE_BOTON_ACCION
|
||||
}
|
||||
>
|
||||
{ETIQUETA_FASE_APARTADO[fase]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados */
|
||||
type TabCrm = 'leads' | 'oportunidades' | 'apartados'
|
||||
|
||||
function TabsCrm({
|
||||
activa,
|
||||
conteos,
|
||||
onCambiar,
|
||||
}: {
|
||||
activa: TabCrm
|
||||
conteos: Record<TabCrm, number>
|
||||
onCambiar: (tab: TabCrm) => void
|
||||
}) {
|
||||
const tabs: { id: TabCrm; titulo: string }[] = [
|
||||
{ id: 'leads', titulo: 'Mis leads' },
|
||||
{ id: 'oportunidades', titulo: 'Mis oportunidades' },
|
||||
{ id: 'apartados', titulo: 'Mis apartados' },
|
||||
]
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap gap-1 rounded-xl border border-[#E5E7EB] bg-white p-1 shadow-sm" role="tablist">
|
||||
{tabs.map((tab) => {
|
||||
const activo = tab.id === activa
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activo}
|
||||
onClick={() => onCambiar(tab.id)}
|
||||
className={`rounded-lg px-4 py-2 text-xs font-semibold uppercase tracking-wider transition-colors duration-100 ${
|
||||
activo ? 'bg-accent text-white' : 'text-[#64748B] hover:bg-[#F9FAFB] hover:text-accent'
|
||||
}`}
|
||||
>
|
||||
{tab.titulo}
|
||||
<span className={`ml-1.5 font-normal ${activo ? 'text-blue-100' : 'text-[#94A3B8]'}`}>
|
||||
{conteos[tab.id]}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Fila de lead con acciones rápidas (contactado / intento / cita / descartar) */
|
||||
function FilaLead({
|
||||
lead,
|
||||
@@ -206,7 +415,12 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
const [cargando, setCargando] = useState(true)
|
||||
const [enviando, setEnviando] = useState(false)
|
||||
const [aviso, setAviso] = useState<string | null>(null)
|
||||
const [descartando, setDescartando] = useState<LeadMiDia | null>(null)
|
||||
const [descartando, setDescartando] = useState<{
|
||||
id: string
|
||||
nombre: string
|
||||
fuente: 'lead' | 'oportunidad'
|
||||
} | null>(null)
|
||||
const [tabCrm, setTabCrm] = useState<TabCrm>('leads')
|
||||
|
||||
// Captura rápida
|
||||
const [capNombre, setCapNombre] = useState('')
|
||||
@@ -248,6 +462,23 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Botón "Cotizar": pasa la oportunidad a etapa Cotizador y abre el cotizador
|
||||
* con los datos del cliente prellenados y la oportunidad vinculada (al
|
||||
* guardar, la cotización actualiza ESE item en Monday). Si el cambio de etapa
|
||||
* falla, el cotizador se abre igual: el guardado reintenta la actualización.
|
||||
*/
|
||||
async function cotizarOportunidad(op: OportunidadMiDia) {
|
||||
setEnviando(true)
|
||||
setAviso(null)
|
||||
try {
|
||||
await cambiarEtapaOportunidad(op.id, 'Cotizador')
|
||||
} catch {
|
||||
setAviso('No se pudo actualizar la etapa en Monday; el cotizador se abrirá de todos modos.')
|
||||
}
|
||||
window.location.href = urlCotizadorParaOportunidad(op)
|
||||
}
|
||||
|
||||
async function enviarCaptura(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setCapEnviando(true)
|
||||
@@ -382,23 +613,71 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
|
||||
<div className="mt-6 flex flex-col gap-6 lg:grid lg:grid-cols-3 lg:gap-6">
|
||||
<div className="order-2 flex flex-col gap-6 lg:order-1 lg:col-span-2">
|
||||
<section aria-label="Mis leads">
|
||||
<TituloSeccion>Mis leads</TituloSeccion>
|
||||
{resumen.misLeads.length === 0 ? (
|
||||
<EstadoVacio mensaje="Sin leads asignados" />
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{resumen.misLeads.map((lead) => (
|
||||
<FilaLead
|
||||
key={`${lead.fuente}-${lead.id}`}
|
||||
lead={lead}
|
||||
enviando={enviando}
|
||||
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
|
||||
onDescartar={() => setDescartando(lead)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<section aria-label="CRM personal">
|
||||
<TabsCrm
|
||||
activa={tabCrm}
|
||||
conteos={{
|
||||
leads: resumen.misLeads.filter((l) => l.fuente === 'lead').length,
|
||||
oportunidades: resumen.misOportunidades.items.length,
|
||||
apartados: resumen.misApartados.length,
|
||||
}}
|
||||
onCambiar={setTabCrm}
|
||||
/>
|
||||
|
||||
{tabCrm === 'leads' &&
|
||||
(resumen.misLeads.filter((l) => l.fuente === 'lead').length === 0 ? (
|
||||
<EstadoVacio mensaje="Sin leads asignados" />
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{resumen.misLeads
|
||||
.filter((l) => l.fuente === 'lead')
|
||||
.map((lead) => (
|
||||
<FilaLead
|
||||
key={`${lead.fuente}-${lead.id}`}
|
||||
lead={lead}
|
||||
enviando={enviando}
|
||||
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
|
||||
onDescartar={() => setDescartando(lead)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
|
||||
{tabCrm === 'oportunidades' &&
|
||||
(resumen.misOportunidades.items.length === 0 ? (
|
||||
<EstadoVacio mensaje="Sin oportunidades asignadas" />
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{resumen.misOportunidades.items.map((op) => (
|
||||
<FilaOportunidad
|
||||
key={op.id}
|
||||
op={op}
|
||||
enviando={enviando}
|
||||
onEtapa={(etapa, fecha) =>
|
||||
void ejecutar(() => cambiarEtapaOportunidad(op.id, etapa, fecha), 'No se pudo actualizar la oportunidad')
|
||||
}
|
||||
onCotizar={() => void cotizarOportunidad(op)}
|
||||
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
|
||||
{tabCrm === 'apartados' &&
|
||||
(resumen.misApartados.length === 0 ? (
|
||||
<EstadoVacio mensaje="Sin apartados asignados" />
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{resumen.misApartados.map((a) => (
|
||||
<FilaApartado
|
||||
key={a.id}
|
||||
apartado={a}
|
||||
enviando={enviando}
|
||||
onFase={(fase) => void ejecutar(() => cambiarEstadoApartado(a.id, fase), 'No se pudo actualizar el apartado')}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section aria-label="Mis capturas de afluencia">
|
||||
|
||||
@@ -64,3 +64,24 @@ export function descartarLead(itemId: string, motivo: string, comentario?: strin
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /oportunidades/:itemId/etapa — botones de fase de "Mis oportunidades" */
|
||||
export function cambiarEtapaOportunidad(itemId: string, etapa: string, fecha?: string): Promise<unknown> {
|
||||
return fetchJson(`/oportunidades/${itemId}/etapa`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
etapa,
|
||||
...(fecha !== undefined && fecha !== '' ? { fecha } : {}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /apartados/:itemId/estado — botones de fase de "Mis apartados" */
|
||||
export function cambiarEstadoApartado(itemId: string, estado: string): Promise<unknown> {
|
||||
return fetchJson(`/apartados/${itemId}/estado`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ estado }),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user