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:
2026-09-04 21:54:44 +00:00
parent a555f69dcd
commit 1963c06d93
17 changed files with 953 additions and 35 deletions

View File

@@ -81,6 +81,10 @@ export default function CotizadorPage() {
const [modalClienteAbierto, setModalClienteAbierto] = useState(false)
const [guardando, setGuardando] = useState(false)
const [aviso, setAviso] = useState<string | null>(null)
// Prefill desde Mi día (botón "Cotizar" de una oportunidad): el id queda
// ligado a la cotización al guardar; la unidad se preselecciona al cargar
const [oportunidadId, setOportunidadId] = useState<string | null>(null)
const [unidadPendiente, setUnidadPendiente] = useState<string | null>(null)
useEffect(() => {
let vivo = true
@@ -113,6 +117,41 @@ export default function CotizadorPage() {
})
const nombreCliente = watch('nombre') ?? ''
// Prefill desde la URL (botón "Cotizar" de Mi día): cliente, contacto,
// desarrollo, unidad y el id de la oportunidad a vincular al guardar
useEffect(() => {
const qs = new URLSearchParams(window.location.search)
const cliente = qs.get('cliente')
const telefono = qs.get('telefono')
const email = qs.get('email')
const desarrolloParam = qs.get('desarrollo')
const unidadParam = qs.get('unidad')
const opId = qs.get('oportunidadId')
if (cliente) setValue('nombre', cliente, { shouldValidate: true })
if (telefono) setValue('telefono', telefono, { shouldValidate: true })
if (email) setValue('email', email, { shouldValidate: true })
if (desarrolloParam && LISTA_DESARROLLOS.includes(desarrolloParam)) setDesarrollo(desarrolloParam)
if (unidadParam) setUnidadPendiente(unidadParam)
if (opId) setOportunidadId(opId)
// Solo al montar: es el punto de entrada desde Mi día
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Preselecciona la unidad cuando el inventario terminó de cargar
useEffect(() => {
if (unidadPendiente === null || cargando) return
const encontrada = unidades.find((u) => u.codigo === unidadPendiente)
if (encontrada) {
const desarrolloUnidad = encontrada.desarrollo ?? 'DIAGONAL'
if (desarrolloUnidad !== desarrollo) setDesarrollo(desarrolloUnidad)
setUnidad(encontrada)
setAviso(`Unidad ${encontrada.codigo} preseleccionada desde Mi día.`)
}
setUnidadPendiente(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [unidades, cargando, unidadPendiente])
// La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
const puedeGenerarPdf = Boolean(unidad) && !errorApartado && !guardando
@@ -130,6 +169,7 @@ export default function CotizadorPage() {
acabados,
apartado: apartadoEfectivo,
descuento: propuestaActiva.descuento,
...(oportunidadId !== null ? { oportunidadId } : {}),
})
const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) }
setUltimaGuardada(conFolio)
@@ -250,6 +290,7 @@ export default function CotizadorPage() {
nueva()
reset({ nombre: '', email: '', telefono: '' })
setUltimaGuardada(null)
setOportunidadId(null) // la nueva cotización ya no va ligada a la oportunidad anterior
setAviso(null)
}
@@ -265,6 +306,15 @@ export default function CotizadorPage() {
</p>
</div>
{oportunidadId !== null && nombreCliente.trim() !== '' && (
<p
role="status"
className="mb-6 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm font-medium text-blue-800"
>
Cotizando para {nombreCliente} — al guardar, la cotización queda vinculada a su oportunidad en Monday.
</p>
)}
<div className="grid items-start gap-6 lg:grid-cols-[380px_minmax(0,1fr)] lg:gap-8">
{/* Panel de configuración */}
<aside className="space-y-6 lg:sticky lg:top-24">

View File

@@ -80,6 +80,8 @@ export interface GuardarCotizacionPayload {
acabados: boolean
apartado?: number
descuento?: number
/** Item del tablero Oportunidades a vincular (botón "Cotizar" de Mi día) */
oportunidadId?: string
}
/** URL pública de una cotización (destino del QR del PDF) */

View File

@@ -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)
})
})

View File

@@ -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) */

View File

@@ -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">

View File

@@ -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 }),
})
}