fix(mi-dia): admin reasigna/mueve leads, capturista real en cards, datos en apartados, INE obligatoria y bitácora persistente
This commit is contained in:
@@ -115,8 +115,8 @@ describe('cambiarEstadoApartadoEndpoint (POST /api/apartados/:id/estado) — loc
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('los tipos de documento de la precarga son capturas obligatorias + generales', () => {
|
it('los tipos de documento de la precarga son capturas obligatorias + INE + generales', () => {
|
||||||
expect(TIPOS_DOCUMENTO_APARTADO).toEqual(['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO'])
|
expect(TIPOS_DOCUMENTO_APARTADO).toEqual(['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'INE', 'OTRO'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response)
|
|||||||
* obligatorios del proceso; OTRO cubre los documentos generales (INE, CURP,
|
* obligatorios del proceso; OTRO cubre los documentos generales (INE, CURP,
|
||||||
* comprobantes, etc.). El tipo viaja en el campo `tipo` del multipart.
|
* comprobantes, etc.). El tipo viaja en el campo `tipo` del multipart.
|
||||||
*/
|
*/
|
||||||
export const TIPOS_DOCUMENTO_APARTADO = ['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO'] as const
|
export const TIPOS_DOCUMENTO_APARTADO = ['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'INE', 'OTRO'] as const
|
||||||
|
|
||||||
const MIME_PERMITIDOS_APARTADO = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'])
|
const MIME_PERMITIDOS_APARTADO = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'])
|
||||||
const EXT_POR_MIME_APARTADO: Record<string, string> = {
|
const EXT_POR_MIME_APARTADO: Record<string, string> = {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
|
|||||||
import * as auditoria from '../services/auditoria.service.js'
|
import * as auditoria from '../services/auditoria.service.js'
|
||||||
import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js'
|
import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js'
|
||||||
import { AppError, NotFoundError } from '../utils/errors.js'
|
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||||
import { recordatorioVendedorService } from '../services/recordatorio-vendedor.service.js'
|
import { recordatorioVendedorService, calcularClienteClave } from '../services/recordatorio-vendedor.service.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Captura y acciones del CRM ligero, sobre DOS tableros locales (tabla
|
* Captura y acciones del CRM ligero, sobre DOS tableros locales (tabla
|
||||||
@@ -163,11 +163,14 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// La nota del alta se persiste como recordatorio en la bitácora del lead
|
// La nota del alta se persiste como recordatorio en la bitácora del lead;
|
||||||
|
// con clienteClave (nombre+teléfono) la nota sigue al lead en las fases
|
||||||
|
// posteriores (oportunidad → apartado → escrituración).
|
||||||
if (body.notas !== undefined && body.notas.trim() !== '') {
|
if (body.notas !== undefined && body.notas.trim() !== '') {
|
||||||
await recordatorioVendedorService.crear(usuarioSub ?? 'sistema', {
|
await recordatorioVendedorService.crear(usuarioSub ?? 'sistema', {
|
||||||
titulo: body.notas,
|
titulo: body.notas,
|
||||||
leadId: resultado.id,
|
leadId: resultado.id,
|
||||||
|
clienteClave: calcularClienteClave(body.nombre, body.telefono),
|
||||||
fechaRecordar: new Date().toISOString(),
|
fechaRecordar: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -205,6 +208,16 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
|
|||||||
res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true })
|
res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// La nota del alta de afluencia también vive en la bitácora (con clave de
|
||||||
|
// cliente para que siga al contacto en las fases siguientes).
|
||||||
|
if (body.notas !== undefined && body.notas.trim() !== '') {
|
||||||
|
await recordatorioVendedorService.crear(usuarioSub ?? 'sistema', {
|
||||||
|
titulo: body.notas,
|
||||||
|
leadId: resultado.id,
|
||||||
|
clienteClave: calcularClienteClave(body.nombre, body.telefono),
|
||||||
|
fechaRecordar: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
id: resultado.id,
|
id: resultado.id,
|
||||||
estado: 'Afluencia',
|
estado: 'Afluencia',
|
||||||
|
|||||||
@@ -80,15 +80,21 @@ describe('resumenMiDiaLocal', () => {
|
|||||||
expect(r.equipo).toBeUndefined()
|
expect(r.equipo).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ultimoCambio viene del updatedAt local y el quién es el capturista ("app" si no hay nombre)', () => {
|
it('ultimoCambio viene del updatedAt local y el quién es el capturista REAL de la fila (no el que ve)', () => {
|
||||||
const capturas = [fila({ leadId: 'loc_l1', board: 'LEADS', updatedAt: new Date('2026-09-05T08:00:00') })]
|
const capturas = [fila({ leadId: 'loc_l1', board: 'LEADS', updatedAt: new Date('2026-09-05T08:00:00') })]
|
||||||
const r = resumenMiDiaLocal(vendedor, capturas, [], AHORA)
|
// El capturista de la fila (u1) es otro usuario distinto del que consulta
|
||||||
|
const usuarios = [{ id: 'u1', nombre: 'Martín Zavala', rol: 'vendedor', activo: true }]
|
||||||
|
const r = resumenMiDiaLocal(vendedor, capturas, [], AHORA, [], usuarios)
|
||||||
expect(r.misLeads[0]?.ultimoCambio).toEqual({
|
expect(r.misLeads[0]?.ultimoCambio).toEqual({
|
||||||
fechaISO: '2026-09-05T08:00:00.000Z',
|
fechaISO: '2026-09-05T08:00:00.000Z',
|
||||||
usuario: 'Martin Zavala',
|
usuario: 'Martín Zavala',
|
||||||
detalle: 'Actualización',
|
detalle: 'Actualización',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Sin la lista de usuarios (o el capturista fuera de ella) → 'app'
|
||||||
|
const rSinUsuarios = resumenMiDiaLocal(vendedor, capturas, [], AHORA)
|
||||||
|
expect(rSinUsuarios.misLeads[0]?.ultimoCambio?.usuario).toBe('app')
|
||||||
|
|
||||||
const rSinNombre = resumenMiDiaLocal(sinNombre, capturas, [], AHORA)
|
const rSinNombre = resumenMiDiaLocal(sinNombre, capturas, [], AHORA)
|
||||||
expect(rSinNombre.misLeads[0]?.ultimoCambio?.usuario).toBe('app')
|
expect(rSinNombre.misLeads[0]?.ultimoCambio?.usuario).toBe('app')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -135,6 +135,10 @@ export interface OportunidadMiDia {
|
|||||||
export interface ApartadoMiDia {
|
export interface ApartadoMiDia {
|
||||||
id: string
|
id: string
|
||||||
nombre: string
|
nombre: string
|
||||||
|
telefono: string
|
||||||
|
email: string
|
||||||
|
origen: string
|
||||||
|
desarrollo: string
|
||||||
unidadCodigo: string
|
unidadCodigo: string
|
||||||
estado: string
|
estado: string
|
||||||
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
||||||
@@ -268,6 +272,9 @@ export function resumenMiDiaLocal(
|
|||||||
): ResumenMiDia {
|
): ResumenMiDia {
|
||||||
const hace7DiasMs = ahora.getTime() - 7 * MS_DIA
|
const hace7DiasMs = ahora.getTime() - 7 * MS_DIA
|
||||||
|
|
||||||
|
// Resolución del capturista real de cada fila (no el usuario que ve la pantalla)
|
||||||
|
const nombrePorUsuarioId = new Map(usuarios.map((u) => [u.id, u.nombre]))
|
||||||
|
|
||||||
const leadsLocales = capturas.filter((c) => c.board === 'LEADS')
|
const leadsLocales = capturas.filter((c) => c.board === 'LEADS')
|
||||||
const oportunidadesLocales = capturas.filter((c) => c.board === 'OPORTUNIDADES')
|
const oportunidadesLocales = capturas.filter((c) => c.board === 'OPORTUNIDADES')
|
||||||
const apartadosLocales = capturas.filter((c) => c.board === 'APARTADO')
|
const apartadosLocales = capturas.filter((c) => c.board === 'APARTADO')
|
||||||
@@ -285,7 +292,7 @@ export function resumenMiDiaLocal(
|
|||||||
desarrollo: c.desarrollo ?? '',
|
desarrollo: c.desarrollo ?? '',
|
||||||
updatedAt: c.updatedAt.toISOString(),
|
updatedAt: c.updatedAt.toISOString(),
|
||||||
fuente: 'lead' as const,
|
fuente: 'lead' as const,
|
||||||
ultimoCambio: ultimoCambioLocal(c, usuario.nombre),
|
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||||
pendienteSync: c.pendienteSync,
|
pendienteSync: c.pendienteSync,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||||
@@ -321,7 +328,7 @@ export function resumenMiDiaLocal(
|
|||||||
modeloInteres: c.modelo ?? '',
|
modeloInteres: c.modelo ?? '',
|
||||||
unidadCodigo,
|
unidadCodigo,
|
||||||
updatedAt: c.updatedAt.toISOString(),
|
updatedAt: c.updatedAt.toISOString(),
|
||||||
ultimoCambio: ultimoCambioLocal(c, usuario.nombre),
|
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||||
fechaCita,
|
fechaCita,
|
||||||
pendienteSync: c.pendienteSync,
|
pendienteSync: c.pendienteSync,
|
||||||
}
|
}
|
||||||
@@ -339,10 +346,14 @@ export function resumenMiDiaLocal(
|
|||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
id: c.leadId,
|
id: c.leadId,
|
||||||
nombre: c.nombreLead,
|
nombre: c.nombreLead,
|
||||||
|
telefono: c.telefono ?? '',
|
||||||
|
email: c.email ?? '',
|
||||||
|
origen: c.origen ?? '',
|
||||||
|
desarrollo: c.desarrollo ?? '',
|
||||||
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
||||||
estado: c.etapa,
|
estado: c.etapa,
|
||||||
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
||||||
ultimoCambio: ultimoCambioLocal(c, usuario.nombre),
|
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||||
pendienteSync: c.pendienteSync,
|
pendienteSync: c.pendienteSync,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -492,6 +503,10 @@ export function resumenMiDiaLocal(
|
|||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
id: c.leadId,
|
id: c.leadId,
|
||||||
nombre: c.nombreLead,
|
nombre: c.nombreLead,
|
||||||
|
telefono: c.telefono ?? '',
|
||||||
|
email: c.email ?? '',
|
||||||
|
origen: c.origen ?? '',
|
||||||
|
desarrollo: c.desarrollo ?? '',
|
||||||
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
||||||
estado: c.etapa,
|
estado: c.etapa,
|
||||||
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ function calcularClienteClave(nombre?: string | null, telefono?: string | null):
|
|||||||
}
|
}
|
||||||
|
|
||||||
function esLead(item: LeadItem): item is LeadMiDia {
|
function esLead(item: LeadItem): item is LeadMiDia {
|
||||||
return 'email' in item && 'origen' in item && !('modeloInteres' in item)
|
return 'fuente' in item && item.fuente === 'lead'
|
||||||
}
|
}
|
||||||
function esOportunidad(item: LeadItem): item is OportunidadMiDia {
|
function esOportunidad(item: LeadItem): item is OportunidadMiDia {
|
||||||
return 'modeloInteres' in item
|
return 'modeloInteres' in item
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ const sesionFake = { token: 'jwt-prueba', usuario: { nombre: 'Test', rol: 'vende
|
|||||||
const apartadoFake: ApartadoMiDia = {
|
const apartadoFake: ApartadoMiDia = {
|
||||||
id: 'apt-1',
|
id: 'apt-1',
|
||||||
nombre: 'Juan Pérez',
|
nombre: 'Juan Pérez',
|
||||||
|
telefono: '6641234567',
|
||||||
|
email: 'juan@example.com',
|
||||||
|
origen: 'PASE',
|
||||||
|
desarrollo: 'DIAGONAL',
|
||||||
unidadCodigo: 'D-101',
|
unidadCodigo: 'D-101',
|
||||||
estado: 'En proceso',
|
estado: 'En proceso',
|
||||||
fechaLimite: '2026-09-15',
|
fechaLimite: '2026-09-15',
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ export interface OportunidadMiDia {
|
|||||||
export interface ApartadoMiDia {
|
export interface ApartadoMiDia {
|
||||||
id: string
|
id: string
|
||||||
nombre: string
|
nombre: string
|
||||||
|
telefono: string
|
||||||
|
email: string
|
||||||
|
origen: string
|
||||||
|
desarrollo: string
|
||||||
unidadCodigo: string
|
unidadCodigo: string
|
||||||
estado: string
|
estado: string
|
||||||
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
||||||
@@ -236,11 +240,12 @@ export const ETIQUETA_FASE_OPORTUNIDAD: Record<FaseOportunidad, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Slots de la precarga de documentos en "Mis apartados"; los obligatorios son
|
// Slots de la precarga de documentos en "Mis apartados"; los obligatorios son
|
||||||
// los que pide el proceso de venta (captura del cotizador y de pago apartado)
|
// los que pide el proceso de venta (INE, captura del cotizador y de pago apartado)
|
||||||
export const SLOTS_DOCS_APARTADO = [
|
export const SLOTS_DOCS_APARTADO = [
|
||||||
{ tipo: 'CAPTURA_COTIZADOR', etiqueta: 'Captura del cotizador', obligatorio: true },
|
{ tipo: 'CAPTURA_COTIZADOR', etiqueta: 'Captura del cotizador', obligatorio: true },
|
||||||
{ tipo: 'CAPTURA_PAGO_APARTADO', etiqueta: 'Captura de pago apartado', obligatorio: true },
|
{ tipo: 'CAPTURA_PAGO_APARTADO', etiqueta: 'Captura de pago apartado', obligatorio: true },
|
||||||
{ tipo: 'OTRO', etiqueta: 'Documentos generales (INE, CURP, comprobantes…)', obligatorio: false },
|
{ tipo: 'INE', etiqueta: 'INE (identificación oficial)', obligatorio: true },
|
||||||
|
{ tipo: 'OTRO', etiqueta: 'Documentos generales (CURP, comprobantes…)', obligatorio: false },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type SlotDocApartado = (typeof SLOTS_DOCS_APARTADO)[number]['tipo']
|
export type SlotDocApartado = (typeof SLOTS_DOCS_APARTADO)[number]['tipo']
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ import {
|
|||||||
fetchArchivoDocumentoApartado,
|
fetchArchivoDocumentoApartado,
|
||||||
fetchResumenMiDia,
|
fetchResumenMiDia,
|
||||||
listarDocumentosApartado,
|
listarDocumentosApartado,
|
||||||
|
moverLead as moverLeadApi,
|
||||||
|
reasignarLead as reasignarLeadApi,
|
||||||
subirDocumentoApartado,
|
subirDocumentoApartado,
|
||||||
type DocumentoApartado,
|
type DocumentoApartado,
|
||||||
} from '../services/mi-dia'
|
} from '../services/mi-dia'
|
||||||
@@ -378,6 +380,12 @@ function FilaApartado({
|
|||||||
<span className="text-xs font-normal text-[#94A3B8]">{apartado.unidadCodigo}</span>
|
<span className="text-xs font-normal text-[#94A3B8]">{apartado.unidadCodigo}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
<EnlacesContacto nombre={apartado.nombre} telefono={apartado.telefono ?? ''} />
|
||||||
|
{apartado.desarrollo ? ` · ${apartado.desarrollo}` : ''}
|
||||||
|
{apartado.origen ? ` · ${apartado.origen}` : ''}
|
||||||
|
{apartado.email ? ` · ${apartado.email}` : ''}
|
||||||
|
</p>
|
||||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||||
{apartado.fechaLimite !== '' ? `Fecha límite: ${formatoFechaCorta(apartado.fechaLimite)}` : 'Sin fecha límite'}
|
{apartado.fechaLimite !== '' ? `Fecha límite: ${formatoFechaCorta(apartado.fechaLimite)}` : 'Sin fecha límite'}
|
||||||
</p>
|
</p>
|
||||||
@@ -523,6 +531,15 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
|
|||||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||||
Expediente del cliente
|
Expediente del cliente
|
||||||
</p>
|
</p>
|
||||||
|
{documentos !== null &&
|
||||||
|
SLOTS_DOCS_APARTADO.filter((s) => s.obligatorio && !(documentos ?? []).some((d) => d.tipo === s.tipo)).length > 0 && (
|
||||||
|
<p className="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
||||||
|
Documentos obligatorios pendientes:{' '}
|
||||||
|
{SLOTS_DOCS_APARTADO.filter((s) => s.obligatorio && !(documentos ?? []).some((d) => d.tipo === s.tipo))
|
||||||
|
.map((s) => s.etiqueta)
|
||||||
|
.join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
<p role="alert" className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||||
{error}
|
{error}
|
||||||
@@ -675,6 +692,10 @@ function FilaLead({
|
|||||||
onVerDetalle,
|
onVerDetalle,
|
||||||
enviando,
|
enviando,
|
||||||
acciones,
|
acciones,
|
||||||
|
esAdmin,
|
||||||
|
vendedores,
|
||||||
|
onReasignar,
|
||||||
|
onMover,
|
||||||
}: {
|
}: {
|
||||||
lead: LeadMiDia
|
lead: LeadMiDia
|
||||||
onAccion: (estado: string, fecha?: string) => void
|
onAccion: (estado: string, fecha?: string) => void
|
||||||
@@ -683,6 +704,11 @@ function FilaLead({
|
|||||||
onVerDetalle?: () => void
|
onVerDetalle?: () => void
|
||||||
enviando: boolean
|
enviando: boolean
|
||||||
acciones?: string[]
|
acciones?: string[]
|
||||||
|
/** Solo admin: reasignar a cualquier vendedor y mover entre tableros */
|
||||||
|
esAdmin?: boolean
|
||||||
|
vendedores?: { id: string; nombre: string }[]
|
||||||
|
onReasignar?: (usuarioId: string) => void
|
||||||
|
onMover?: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||||
@@ -752,6 +778,30 @@ function FilaLead({
|
|||||||
Descartar
|
Descartar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Admin: reasignar a cualquier vendedor y mover entre tableros */}
|
||||||
|
{esAdmin && (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2 border-t border-dashed border-[#E5E7EB] pt-2 dark:border-[#334155]">
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-wider text-[#94A3B8]">Admin</span>
|
||||||
|
<select
|
||||||
|
value=""
|
||||||
|
disabled={enviando}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value !== '' && onReasignar) onReasignar(e.target.value)
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1 text-xs text-[#374151] transition-colors focus:border-accent focus:outline-none dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#CBD5E1]"
|
||||||
|
aria-label="Reasignar lead a vendedor"
|
||||||
|
>
|
||||||
|
<option value="">Reasignar a…</option>
|
||||||
|
{(vendedores ?? []).map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>{v.nombre}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="button" disabled={enviando} onClick={onMover} className={CLASE_BOTON_ACCION}>
|
||||||
|
{lead.fuente === 'lead' ? '→ Oportunidad' : '→ Leads'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -994,6 +1044,23 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
window.open(urlCotizadorParaOportunidad(op), '_blank')
|
window.open(urlCotizadorParaOportunidad(op), '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reasignar lead a otro vendedor (solo admin) */
|
||||||
|
async function reasignar(lead: LeadMiDia, usuarioId: string) {
|
||||||
|
await ejecutar(async () => {
|
||||||
|
const nombre = await reasignarLeadApi(lead.id, usuarioId)
|
||||||
|
toast.success(`Lead reasignado a ${nombre || 'vendedor'}`)
|
||||||
|
}, 'No se pudo reasignar el lead')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mover lead entre tableros Leads ↔ Oportunidades (solo admin) */
|
||||||
|
async function mover(lead: LeadMiDia) {
|
||||||
|
const destino = lead.fuente === 'lead' ? 'OPORTUNIDADES' : 'LEADS'
|
||||||
|
await ejecutar(async () => {
|
||||||
|
await moverLeadApi(lead.id, destino)
|
||||||
|
toast.success(destino === 'OPORTUNIDADES' ? 'Lead movido a Oportunidades' : 'Lead movido a Leads')
|
||||||
|
}, 'No se pudo mover el lead')
|
||||||
|
}
|
||||||
|
|
||||||
async function enviarCaptura(e: FormEvent) {
|
async function enviarCaptura(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setCapEnviando(true)
|
setCapEnviando(true)
|
||||||
@@ -1246,6 +1313,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
onDescartar={() => setDescartando(lead)}
|
onDescartar={() => setDescartando(lead)}
|
||||||
onVerDetalle={() => setLeadSeleccionado(lead)}
|
onVerDetalle={() => setLeadSeleccionado(lead)}
|
||||||
acciones={acciones}
|
acciones={acciones}
|
||||||
|
esAdmin={rol === 'admin'}
|
||||||
|
vendedores={resumen.equipo?.porVendedor.map((v) => ({ id: v.usuarioId, nombre: v.nombre ?? '' }))}
|
||||||
|
onReasignar={(usuarioId) => void reasignar(lead, usuarioId)}
|
||||||
|
onMover={() => void mover(lead)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ const opFake: OportunidadMiDia = {
|
|||||||
const apartadoFake: ApartadoMiDia = {
|
const apartadoFake: ApartadoMiDia = {
|
||||||
id: '3',
|
id: '3',
|
||||||
nombre: 'Carlos Ruiz',
|
nombre: 'Carlos Ruiz',
|
||||||
|
telefono: '6641234567',
|
||||||
|
email: 'carlos@example.com',
|
||||||
|
origen: 'REFERIDO',
|
||||||
|
desarrollo: 'ESTANZA',
|
||||||
unidadCodigo: 'E-202',
|
unidadCodigo: 'E-202',
|
||||||
estado: 'En proceso',
|
estado: 'En proceso',
|
||||||
fechaLimite: '2026-09-15',
|
fechaLimite: '2026-09-15',
|
||||||
|
|||||||
@@ -107,6 +107,36 @@ export function cambiarEstadoApartado(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** POST /leads/:itemId/mover — mover lead a otro tablero (solo admin) */
|
||||||
|
export async function moverLead(itemId: string, board: 'LEADS' | 'OPORTUNIDADES'): Promise<void> {
|
||||||
|
const res = await fetch(`${API_URL}/leads/${itemId}/mover`, conAuth({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ board }),
|
||||||
|
}))
|
||||||
|
manejar401(res)
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = (await res.json().catch(() => null)) as { error?: string } | null
|
||||||
|
throw new Error(body?.error ?? `Error ${res.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH /leads/:itemId/asignar — reasignar el lead a otro vendedor (solo admin) */
|
||||||
|
export async function reasignarLead(itemId: string, usuarioId: string): Promise<string> {
|
||||||
|
const res = await fetch(`${API_URL}/leads/${itemId}/asignar`, conAuth({
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ usuarioId }),
|
||||||
|
}))
|
||||||
|
manejar401(res)
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = (await res.json().catch(() => null)) as { error?: string } | null
|
||||||
|
throw new Error(body?.error ?? `Error ${res.status}`)
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { nombre?: string }
|
||||||
|
return data.nombre ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Precarga de documentos del expediente (Mis apartados) ----------
|
// ---------- Precarga de documentos del expediente (Mis apartados) ----------
|
||||||
|
|
||||||
export interface DocumentoApartado {
|
export interface DocumentoApartado {
|
||||||
|
|||||||
Reference in New Issue
Block a user