fix(auditoria general): 25 errores encontrados en la revision modulo por modulo
SEGURIDAD (agente IA): - Conversaciones: GET /ai/conversations/:id y el reanudar chat verifican propiedad (antes cualquier usuario leia/continuaba la conversacion de otro). - /ai/documents pasa a solo-admin (antes cualquien podia inyectar o borrar documentos de la base de conocimiento). DATOS / CORRUPCION: - Cotizador: elegir un cliente del maestro en el buscador ya no crea un lead fantasma "Registro previo (CRM anterior)" en el embudo. - Afluencia con oportunidadId inexistente: 404 en vez de crear sombra fantasma. - Cobranza: los 103 planes importados quedaron con vendedor asignado (7 con su asesor real segun el Excel, el resto al admin) y el motor de recordatorios cae al admin cuando un plan no tiene vendedor: antes los 411 recordatorios se registraban 'sin-destinatario' y no los veia nadie. - Backups: script reescrito para el host real (sqlite .backup + uploads + env en /root/backups) y cron instalado (antes: script que asumia docker y sin cron programado = cero respaldos). REPORTES / METRICAS: - Mi tablero: el embudo ya excluye cancelaciones reales (Cancelo/Cancelacion Apartado) y cuenta como vendidos Firma Cliente/Escriturado/Titulado (antes 41 "apartados" donde habia 13 activos y vendidos buscaba una etapa que no existe); tasa de descarte ya compara el mismo periodo. - Scoring: etapa real 'Agendo Cita' (antes 'AGENDO' nunca aplicaba) y los descartados/perdidos ya no reciben score. - ver_cobranza (IA): ignora planes cancelados y no cuenta como vencido el pago que vence hoy. UI / FLUJOS: - mi-dia: typo CANCO -> CANCELO (los cancelados reaparecen en Escrituracion); confirmar cita ya no retrocede la etapa de Cotizador/Aparto (boton y kanban). - alta-clientes: la firma ya no se borra al navegar fuera del paso de firmas; toast de exito solo cuando el API confirma; prefill de apartado trae direccion, estado civil y fecha de nacimiento desde el alta. - Auditoria: el filtro por usuario funciona (el front enviaba 'usuario' y el API esperaba 'usuarioId') y 'hasta' incluye todo el dia. - Contactos: paginacion aplicada despues de fusionar fuentes (antes saltaban o se repetian contactos entre paginas y 'Siguiente' se deshabilitaba antes de tiempo). - Buscador global: leads -> /hoy/, contactos -> /; el cotizador entiende ?cotizacion=<id> para retomar desde la liga. - Export mi-dia: columnas Desarrollo/Origen rellenas y sin duplicados. - Recibo de cobranza: "Recibio" es FRASA (el cliente paga, no recibe). - Alta de plan de cobranza: proyecto libre con sugerencias del catalogo real (antes limitado a 3 proyectos que no cubrian la cartera). Verificado en vivo: autorizacion IA (3x 403), filtro de auditoria, 404 de afluencia. Suites: 717 API + 124 alta-clientes + 115 cotizador + 92 cobranza + 89 landing + 32 mi-dia en verde. Backup de hoy generado en /root/backups.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { useRef } from 'react'
|
||||
import SignatureCanvas from 'react-signature-canvas'
|
||||
|
||||
interface Props {
|
||||
@@ -9,11 +9,10 @@ interface Props {
|
||||
export default function SignaturePad({ onFirma }: Props) {
|
||||
const ref = useRef<SignatureCanvas | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Notifica firma vacía al montar (estado inicial)
|
||||
onFirma('')
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
// NOTA: no se notifica '' al montar. El pad se desmonta/remonta cada vez
|
||||
// que el asesor sale y vuelve al paso de firmas, y ese reset borraba la
|
||||
// firma ya capturada (los PDFs salían sin firma). El estado vacío inicial
|
||||
// ya lo maneja el formulario padre.
|
||||
|
||||
const limpiar = () => {
|
||||
ref.current?.clear()
|
||||
|
||||
@@ -337,8 +337,8 @@ export default function AltaClientePage() {
|
||||
const res = datos.progresoId
|
||||
? await completarProgreso(datos.progresoId, alta)
|
||||
: await crearAltaCliente(alta)
|
||||
toast.success("Alta de cliente completada")
|
||||
if (res.ok) {
|
||||
toast.success('Alta de cliente completada')
|
||||
const { fallidos } = await subirDocumentos(res.id, alta)
|
||||
setRegistro(res.id)
|
||||
setSinConexion(false)
|
||||
|
||||
@@ -92,7 +92,8 @@ export function crearDocumento(
|
||||
doc.setFontSize(9)
|
||||
doc.text('_______________________________', 105, y, { align: 'center' })
|
||||
y += 5
|
||||
doc.text(`Recibió — ${plan.clienteNombre}`, 105, y, { align: 'center' })
|
||||
// Quien recibe el pago es FRASA (el cliente es quien paga)
|
||||
doc.text('Recibió — FRASA DESARROLLOS S.A.P.I. DE C.V.', 105, y, { align: 'center' })
|
||||
|
||||
return { doc, nombreArchivo: `recibo_${plan.unidadCodigo}_${folio}.pdf` }
|
||||
}
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import toast from "react-hot-toast"
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { crearPlanAsync } from '../services/repository'
|
||||
import { crearPlanAsync, listarProyectosAsync } from '../services/repository'
|
||||
import { generarCalendario } from '../lib/cobranza'
|
||||
import { fechaISOHoy, formatoFechaCorta, formatoMoneda } from '../lib/formatters'
|
||||
|
||||
const PROYECTOS = ['RESIDENCIAL MONTEBELLO', 'SENDA', 'DIAGONAL'] as const
|
||||
// Proyecto libre (con sugerencias del datalist): la cartera real incluye
|
||||
// desarrollos que un enum hardcodeado no cubría (ZAFIRO, UMBRAL, SOLE,
|
||||
// DIAGONAL 40, SOLANA I/II...), así que ya no se limita a una lista fija.
|
||||
|
||||
const esquema = z.object({
|
||||
clienteNombre: z.string().trim().min(3, 'Nombre del cliente requerido (mín. 3 caracteres)'),
|
||||
clienteTelefono: z.string().trim().optional(),
|
||||
unidadCodigo: z.string().trim().min(1, 'Código de unidad requerido'),
|
||||
proyecto: z.enum(PROYECTOS),
|
||||
proyecto: z.string().trim().min(1, 'Proyecto requerido'),
|
||||
ventaRef: z.string().trim(),
|
||||
fechaInicio: z.string().min(1, 'Fecha de inicio requerida'),
|
||||
engancheTotal: z
|
||||
@@ -46,6 +48,11 @@ export default function NuevoPlanPage() {
|
||||
const navigate = useNavigate()
|
||||
const [guardando, setGuardando] = useState(false)
|
||||
const [errorGuardado, setErrorGuardado] = useState<string | null>(null)
|
||||
// Proyectos existentes en la cartera: alimentan el datalist de sugerencias
|
||||
const [proyectosSugeridos, setProyectosSugeridos] = useState<string[]>([])
|
||||
useEffect(() => {
|
||||
void listarProyectosAsync().then(setProyectosSugeridos)
|
||||
}, [])
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -56,7 +63,7 @@ export default function NuevoPlanPage() {
|
||||
defaultValues: {
|
||||
clienteNombre: '',
|
||||
unidadCodigo: '',
|
||||
proyecto: 'SENDA',
|
||||
proyecto: '',
|
||||
ventaRef: '',
|
||||
fechaInicio: fechaISOHoy(),
|
||||
numPagos: 6,
|
||||
@@ -122,13 +129,19 @@ export default function NuevoPlanPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="proyecto" className={claseLabel}>Proyecto *</label>
|
||||
<select id="proyecto" className={claseInput} {...register('proyecto')}>
|
||||
{PROYECTOS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
<input
|
||||
id="proyecto"
|
||||
list="proyectos-cobranza"
|
||||
className={claseInput}
|
||||
placeholder="Ej. SENDA, ZAFIRO, DIAGONAL 40…"
|
||||
{...register('proyecto')}
|
||||
/>
|
||||
<datalist id="proyectos-cobranza">
|
||||
{proyectosSugeridos.map((p) => (
|
||||
<option key={p} value={p} />
|
||||
))}
|
||||
</select>
|
||||
</datalist>
|
||||
{errors.proyecto && <p className={claseError}>{errors.proyecto.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ventaRef" className={claseLabel}>Referencia de venta</label>
|
||||
|
||||
@@ -365,3 +365,13 @@ export async function actualizarPlanAsync(
|
||||
export function sinPlanesAsignados(resultado: ResultadoPlanes, mios: boolean): boolean {
|
||||
return mios && resultado.origen === 'api' && resultado.planes.length === 0
|
||||
}
|
||||
|
||||
/** GET /planes — proyectos distintos de la cartera (datalist del alta de plan) */
|
||||
export async function listarProyectosAsync(): Promise<string[]> {
|
||||
try {
|
||||
const planes = await request<PlanApi[]>('/planes')
|
||||
return [...new Set(planes.map((p) => p.proyecto).filter((p) => typeof p === 'string' && p.trim() !== ''))].sort()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { indiceMenorTotal, totalPagado, ENGANCHE_MAXIMO_PCT } from '../lib/calcu
|
||||
import { desarrolloEfectivo, idsCotizables, propuestaVigente } from '../lib/reglas'
|
||||
import {
|
||||
getMisCotizaciones,
|
||||
getCotizacion,
|
||||
guardarCotizacion,
|
||||
urlPublicaCotizacion,
|
||||
ErrorApiCotizacion,
|
||||
@@ -103,6 +104,9 @@ export default function CotizadorPage() {
|
||||
// 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)
|
||||
// Retomar una cotización llegada por URL (/quote/?cotizacion=<id>, desde la
|
||||
// búsqueda global): se aplica cuando termina de cargar inventario+reglas
|
||||
const [cotizacionPendiente, setCotizacionPendiente] = useState<string | null>(null)
|
||||
|
||||
// Si el desarrollo activo deja de ofrecerse (lo desactivó el admin), se cae al
|
||||
// primero disponible para no dejar el cotizador en un desarrollo muerto.
|
||||
@@ -189,6 +193,8 @@ export default function CotizadorPage() {
|
||||
}
|
||||
if (unidadParam) setUnidadPendiente(unidadParam)
|
||||
if (opId) setOportunidadId(opId)
|
||||
const cotizacionParam = qs.get('cotizacion')
|
||||
if (cotizacionParam) setCotizacionPendiente(cotizacionParam)
|
||||
// Solo al montar: es el punto de entrada desde Mi día
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
@@ -221,6 +227,20 @@ export default function CotizadorPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [unidades, cargando, unidadPendiente])
|
||||
|
||||
// Retoma la cotización pedida por URL cuando el inventario ya cargó
|
||||
useEffect(() => {
|
||||
if (cotizacionPendiente === null || cargando) return
|
||||
const id = cotizacionPendiente
|
||||
setCotizacionPendiente(null)
|
||||
void getCotizacion(id)
|
||||
.then((c) => {
|
||||
if (c) handleRetomar(c)
|
||||
else setAviso(`No se encontró la cotización entre las tuyas (${id}).`)
|
||||
})
|
||||
.catch(() => setAviso('No se pudo cargar la cotización de la liga.'))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cotizacionPendiente, cargando, unidades])
|
||||
|
||||
// La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo
|
||||
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
|
||||
// Total pagado por el cliente con la propuesta elegida; null si no se puede conocer
|
||||
|
||||
@@ -176,6 +176,15 @@ export async function getMisCotizaciones(): Promise<CotizacionGuardada[]> {
|
||||
return (await resp.json()) as CotizacionGuardada[]
|
||||
}
|
||||
|
||||
/** GET /cotizaciones/:id — una cotización del vendedor (retomar desde URL) */
|
||||
export async function getCotizacion(id: string): Promise<CotizacionGuardada | null> {
|
||||
const resp = await fetch(`${API_URL}/cotizaciones/${encodeURIComponent(id)}`, conAuth())
|
||||
manejar401(resp)
|
||||
if (resp.status === 404) return null
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
return (await resp.json()) as CotizacionGuardada
|
||||
}
|
||||
|
||||
// ---------- Búsqueda de clientes para el prellenado del cotizador ----------
|
||||
|
||||
export interface LeadResumen {
|
||||
|
||||
@@ -188,7 +188,7 @@ export async function getAuditoria(filtros?: {
|
||||
}): Promise<AuditoriaItem[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (filtros?.entidad) params.set('entidad', filtros.entidad)
|
||||
if (filtros?.usuario) params.set('usuario', filtros.usuario)
|
||||
if (filtros?.usuario) params.set('usuarioId', filtros.usuario)
|
||||
if (filtros?.desde) params.set('desde', filtros.desde)
|
||||
if (filtros?.hasta) params.set('hasta', filtros.hasta)
|
||||
const qs = params.toString()
|
||||
|
||||
@@ -209,7 +209,16 @@ function FilaOportunidad({
|
||||
<button
|
||||
type="button"
|
||||
disabled={enviando}
|
||||
onClick={() => (conCita ? onEtapa('Agendo Cita', fechaCita || undefined) : setConCita(true))}
|
||||
onClick={() =>
|
||||
conCita
|
||||
? // Misma regla que el modal: si la op ya pasó por Agendo Cita
|
||||
// (Cotizador/Aparto) NO se retrocede la etapa al confirmar.
|
||||
onEtapa(
|
||||
op.etapa === 'Cotizador' || op.etapa === 'Aparto' ? op.etapa : 'Agendo Cita',
|
||||
fechaCita || undefined,
|
||||
)
|
||||
: setConCita(true)
|
||||
}
|
||||
className={CLASE_BOTON_ACCION}
|
||||
>
|
||||
{conCita ? 'Confirmar cita' : ETIQUETA_FASE_OPORTUNIDAD['Agendo Cita']}
|
||||
@@ -1144,7 +1153,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
// marcarlo como Escriturado o Titulado
|
||||
e.includes('ESCRITURADO') ||
|
||||
e.includes('TITULADO') ||
|
||||
e.includes('CANCO')
|
||||
e.includes('CANCELO')
|
||||
)
|
||||
})
|
||||
: []
|
||||
@@ -1357,7 +1366,14 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
if (op) setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })
|
||||
return
|
||||
}
|
||||
void ejecutar(() => cambiarEtapaOportunidad(id, estado), 'No se pudo actualizar')
|
||||
// No retroceder etapa al soltar en "Agendo Cita" una
|
||||
// op que ya está en Cotizador/Aparto
|
||||
const op = resumen.misOportunidades.items.find((o) => o.id === id)
|
||||
const destino =
|
||||
estado === 'Agendo Cita' && op && (op.etapa === 'Cotizador' || op.etapa === 'Aparto')
|
||||
? op.etapa
|
||||
: estado
|
||||
void ejecutar(() => cambiarEtapaOportunidad(id, destino), 'No se pudo actualizar')
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -27,7 +27,7 @@ export function exportarLeads(leads: LeadMiDia[], nombreArchivo?: string) {
|
||||
Email: l.email,
|
||||
Estado: l.estado,
|
||||
Origen: l.origen,
|
||||
Desarrollo: '',
|
||||
Desarrollo: l.desarrollo,
|
||||
'Último cambio': l.ultimoCambio ? `${l.ultimoCambio.detalle} · ${l.ultimoCambio.usuario ?? ''}` : '',
|
||||
Fuente: l.fuente,
|
||||
}))
|
||||
@@ -43,12 +43,10 @@ export function exportarOportunidades(ops: OportunidadMiDia[], nombreArchivo?: s
|
||||
Nombre: o.nombre,
|
||||
Teléfono: o.telefono,
|
||||
Email: o.email,
|
||||
Estado: o.etapa || 'Sin etapa',
|
||||
Origen: '',
|
||||
Etapa: o.etapa || 'Sin etapa',
|
||||
Origen: o.origen,
|
||||
Desarrollo: o.desarrollo,
|
||||
'Último cambio': o.ultimoCambio ? `${o.ultimoCambio.detalle} · ${o.ultimoCambio.usuario ?? ''}` : '',
|
||||
Fuente: '',
|
||||
Etapa: o.etapa,
|
||||
}))
|
||||
const ws = XLSX.utils.json_to_sheet(rows)
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Oportunidades')
|
||||
|
||||
Reference in New Issue
Block a user