feat: docs en apartados, enganche seleccionable y prefill de clientes
- Mis apartados (Mi día): botón Documentos con precarga del expediente — captura del cotizador y captura de pago apartado (obligatorios) más documentos generales; los archivos se guardan en el cliente ligado al apartado (se crea la ficha mínima si no existe). Endpoints GET/POST /apartados/:id/documentos (multipart, 10 MB, pdf/jpg/png) - Cotizador: se oculta el editor de descuento (usan los % del Excel); enganche seleccionable con mínimo 2.5% del valor de la propiedad, solo en esquemas con crédito (bancario/Infonavit); mensualidades se recalculan con el enganche elegido y nunca quedan en negativo - Cotizador: buscador de cliente en Monday (lead u oportunidad) que prellena nombre/contacto/desarrollo y vincula la cotización a la oportunidad al guardar - Alta Afluencia: buscador que jala nombre, contacto, origen, desarrollo y modelo desde leads y oportunidades existentes - buscarLeads devuelve desarrollo y modelo de interés - Landing: el módulo Clientes vuelve a llamarse Cobranza - Tests: 456 API + 94 cotizador + 14 mi-dia + 116 alta-clientes, verde
This commit is contained in:
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
cambiarEstadoApartado,
|
cambiarEstadoApartado,
|
||||||
FASES_APARTADO_PERMITIDAS,
|
FASES_APARTADO_PERMITIDAS,
|
||||||
|
TIPOS_DOCUMENTO_APARTADO,
|
||||||
} from './apartados.controller.js'
|
} from './apartados.controller.js'
|
||||||
|
|
||||||
/** Monday mockeado: registra cambios de columna y movimientos de grupo */
|
/** Monday mockeado: registra cambios de columna y movimientos de grupo */
|
||||||
@@ -65,4 +66,8 @@ describe('cambiarEstadoApartado (POST /api/apartados/:id/estado)', () => {
|
|||||||
'Cancelacion Apartado',
|
'Cancelacion Apartado',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('los tipos de documento de la precarga son capturas obligatorias + generales', () => {
|
||||||
|
expect(TIPOS_DOCUMENTO_APARTADO).toEqual(['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import type { Request, Response } from 'express'
|
import type { Request, Response } from 'express'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import multer from 'multer'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { config } from '../config/index.js'
|
import { config } from '../config/index.js'
|
||||||
import { mondayService, type MondayService } from '../services/monday.service.js'
|
import { mondayService, type MondayService } from '../services/monday.service.js'
|
||||||
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
|
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
|
||||||
|
import { prisma } from '../services/prisma.js'
|
||||||
|
import { checkboxParaTipoDocumento } from './clientes.controller.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pipeline Enkontrol del tablero Apartado: las fases que el vendedor puede
|
* Pipeline Enkontrol del tablero Apartado: las fases que el vendedor puede
|
||||||
@@ -51,3 +57,124 @@ export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response)
|
|||||||
await cambiarEstadoApartado(mondayService, itemId, body.estado)
|
await cambiarEstadoApartado(mondayService, itemId, body.estado)
|
||||||
res.json({ itemId, estado: body.estado })
|
res.json({ itemId, estado: body.estado })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Precarga de documentos del expediente (desde "Mis apartados") ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos de documento de la precarga de apartados. Los dos primeros son los
|
||||||
|
* obligatorios del proceso; OTRO cubre los documentos generales (INE, CURP,
|
||||||
|
* comprobantes, etc.). El tipo viaja en el campo `tipo` del multipart.
|
||||||
|
*/
|
||||||
|
export const TIPOS_DOCUMENTO_APARTADO = ['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO'] as const
|
||||||
|
|
||||||
|
const MIME_PERMITIDOS_APARTADO = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'])
|
||||||
|
const EXT_POR_MIME_APARTADO: Record<string, string> = {
|
||||||
|
'application/pdf': 'pdf',
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/jpg': 'jpg',
|
||||||
|
'image/png': 'png',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage propio: archiva por item de Monday (uploads/apartados/<itemId>/)
|
||||||
|
const storageApartados = multer.diskStorage({
|
||||||
|
destination: (req, _file, cb) => {
|
||||||
|
const dir = path.join(config.uploadsDir, 'apartados', String(req.params['itemId']))
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
cb(null, dir)
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase()
|
||||||
|
const ext = EXT_POR_MIME_APARTADO[file.mimetype] ?? 'bin'
|
||||||
|
cb(null, `${tipo}_${randomUUID()}.${ext}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const uploadDocumentosApartado = multer({
|
||||||
|
storage: storageApartados,
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024, files: 10 }, // 10 MB por archivo, máx 10
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
if (!MIME_PERMITIDOS_APARTADO.has(file.mimetype)) {
|
||||||
|
cb(new Error(`Tipo de archivo no permitido: ${file.mimetype}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cb(null, true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cliente local ligado al apartado de Monday; si aún no existe (apartado
|
||||||
|
* creado sin pasar por el alta progresiva), se crea la ficha mínima para que
|
||||||
|
* los documentos tengan dónde vivir.
|
||||||
|
*/
|
||||||
|
async function clienteDeApartado(itemId: string, capturadoPorId?: string) {
|
||||||
|
const existente = await prisma.cliente.findFirst({ where: { mondayApartadoId: itemId } })
|
||||||
|
if (existente) return existente
|
||||||
|
const nombre = await mondayService.obtenerNombreItem(itemId)
|
||||||
|
return prisma.cliente.create({
|
||||||
|
data: {
|
||||||
|
nombreCompleto: nombre,
|
||||||
|
curp: '',
|
||||||
|
rfc: '',
|
||||||
|
proyecto: '',
|
||||||
|
payloadJson: '{}',
|
||||||
|
mondayApartadoId: itemId,
|
||||||
|
capturadoPorId: capturadoPorId ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/apartados/:itemId/documentos — multipart, campo `archivos` (hasta
|
||||||
|
* 10) + campo `tipo`. Los documentos se guardan en el CLIENTE ligado al
|
||||||
|
* apartado (Documento.clienteId); si el tipo tiene checkbox en el tablero
|
||||||
|
* Apartado (CURP/RFC/Comprobante), se prende en Monday.
|
||||||
|
*/
|
||||||
|
export async function subirDocumentosApartado(req: Request, res: Response): Promise<void> {
|
||||||
|
const itemId = String(req.params['itemId'])
|
||||||
|
const cliente = await clienteDeApartado(itemId, req.usuarioToken?.sub)
|
||||||
|
|
||||||
|
const archivos = (req.files ?? []) as Express.Multer.File[]
|
||||||
|
const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase()
|
||||||
|
const documentos = await Promise.all(
|
||||||
|
archivos.map((f) =>
|
||||||
|
prisma.documento.create({
|
||||||
|
data: {
|
||||||
|
clienteId: cliente.id,
|
||||||
|
tipo,
|
||||||
|
nombreArchivo: f.originalname,
|
||||||
|
ruta: path.relative(config.uploadsDir, f.path),
|
||||||
|
mime: f.mimetype,
|
||||||
|
tamano: f.size,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Prende el checkbox del documento en el item de Apartado (si el tipo tiene
|
||||||
|
// checkbox); un fallo de Monday no rompe la subida.
|
||||||
|
const checkbox = checkboxParaTipoDocumento(tipo)
|
||||||
|
if (checkbox !== null) {
|
||||||
|
try {
|
||||||
|
await mondayService.cambiarValorColumna(config.monday.boards.apartado, itemId, checkbox, true)
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${itemId}:`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.status(201).json(documentos)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/apartados/:itemId/documentos — documentos del cliente ligado (vacío si aún no hay) */
|
||||||
|
export async function listarDocumentosApartado(req: Request, res: Response): Promise<void> {
|
||||||
|
const itemId = String(req.params['itemId'])
|
||||||
|
const cliente = await prisma.cliente.findFirst({ where: { mondayApartadoId: itemId } })
|
||||||
|
if (!cliente) {
|
||||||
|
res.json([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const documentos = await prisma.documento.findMany({
|
||||||
|
where: { clienteId: cliente.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
res.json(documentos)
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ const crearCotizacionSchema = z.object({
|
|||||||
clienteTelefono: z.string().min(7).optional(),
|
clienteTelefono: z.string().min(7).optional(),
|
||||||
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'COFINAVIT', 'CONTADO', 'SEMI_CONTADO']),
|
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'COFINAVIT', 'CONTADO', 'SEMI_CONTADO']),
|
||||||
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%)
|
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%)
|
||||||
|
// Enganche elegido por el asesor (fracción, mínimo 2.5%); solo aplica a
|
||||||
|
// esquemas con crédito (bancario/Infonavit)
|
||||||
|
enganchePct: z.number().min(0.025, 'El enganche mínimo es 2.5%').max(1).optional(),
|
||||||
// Item del tablero Oportunidades al que queda vinculada la cotización
|
// Item del tablero Oportunidades al que queda vinculada la cotización
|
||||||
// (botón "Cotizar" de Mi día manda el id; sin él se busca por teléfono)
|
// (botón "Cotizar" de Mi día manda el id; sin él se busca por teléfono)
|
||||||
oportunidadId: z.string().trim().min(1).optional(),
|
oportunidadId: z.string().trim().min(1).optional(),
|
||||||
@@ -51,7 +54,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
|||||||
if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`)
|
if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`)
|
||||||
|
|
||||||
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
|
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
|
||||||
const propuesta = calcularPropuesta(unidad, body.tipoPropuesta, body.acabados, apartado, body.descuento)
|
const propuesta = calcularPropuesta(unidad, body.tipoPropuesta, body.acabados, apartado, body.descuento, body.enganchePct)
|
||||||
const precioLista = obtenerPrecioLista(unidad, body.acabados)
|
const precioLista = obtenerPrecioLista(unidad, body.acabados)
|
||||||
|
|
||||||
const cotizacion = await prisma.cotizacion.create({
|
const cotizacion = await prisma.cotizacion.create({
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ export interface ResultadoBusquedaLead {
|
|||||||
estado: string
|
estado: string
|
||||||
origen: string
|
origen: string
|
||||||
fuente: FuenteLead
|
fuente: FuenteLead
|
||||||
|
/** Desarrollo de interés ('' si no tiene); prellena afluencia/cotizador */
|
||||||
|
desarrollo: string
|
||||||
|
/** Modelo de interés ('' si no tiene) */
|
||||||
|
modeloInteres: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,6 +135,8 @@ export function buscarLeads(leads: LeadMonday[], oportunidades: OportunidadMonda
|
|||||||
email: l.email,
|
email: l.email,
|
||||||
estado: l.estado,
|
estado: l.estado,
|
||||||
origen: l.origen,
|
origen: l.origen,
|
||||||
|
desarrollo: l.desarrollo ?? '',
|
||||||
|
modeloInteres: l.modeloInteres ?? '',
|
||||||
updatedAt: l.updatedAt,
|
updatedAt: l.updatedAt,
|
||||||
fuente: 'lead' as const,
|
fuente: 'lead' as const,
|
||||||
})),
|
})),
|
||||||
@@ -141,6 +147,8 @@ export function buscarLeads(leads: LeadMonday[], oportunidades: OportunidadMonda
|
|||||||
email: o.email,
|
email: o.email,
|
||||||
estado: o.etapa,
|
estado: o.etapa,
|
||||||
origen: o.origen,
|
origen: o.origen,
|
||||||
|
desarrollo: o.desarrollo ?? '',
|
||||||
|
modeloInteres: o.modeloInteres ?? '',
|
||||||
updatedAt: o.updatedAt,
|
updatedAt: o.updatedAt,
|
||||||
fuente: 'oportunidad' as const,
|
fuente: 'oportunidad' as const,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import {
|
|||||||
cambiarEtapaOportunidadEndpoint,
|
cambiarEtapaOportunidadEndpoint,
|
||||||
perderOportunidadEndpoint,
|
perderOportunidadEndpoint,
|
||||||
} from '../controllers/oportunidades.controller.js'
|
} from '../controllers/oportunidades.controller.js'
|
||||||
import { cambiarEstadoApartadoEndpoint } from '../controllers/apartados.controller.js'
|
import { cambiarEstadoApartadoEndpoint, listarDocumentosApartado, subirDocumentosApartado, uploadDocumentosApartado } from '../controllers/apartados.controller.js'
|
||||||
import { correrRecordatorios, listarRecordatorios } from '../controllers/recordatorios.controller.js'
|
import { correrRecordatorios, listarRecordatorios } from '../controllers/recordatorios.controller.js'
|
||||||
import {
|
import {
|
||||||
cambiarEstadoLeadEndpoint,
|
cambiarEstadoLeadEndpoint,
|
||||||
@@ -142,6 +142,10 @@ apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
|
|||||||
apiRouter.post('/oportunidades/:itemId/etapa', cambiarEtapaOportunidadEndpoint)
|
apiRouter.post('/oportunidades/:itemId/etapa', cambiarEtapaOportunidadEndpoint)
|
||||||
// Cambio de fase del pipeline Enkontrol desde "Mis apartados" (Mi día)
|
// Cambio de fase del pipeline Enkontrol desde "Mis apartados" (Mi día)
|
||||||
apiRouter.post('/apartados/:itemId/estado', cambiarEstadoApartadoEndpoint)
|
apiRouter.post('/apartados/:itemId/estado', cambiarEstadoApartadoEndpoint)
|
||||||
|
// Precarga de documentos del expediente desde "Mis apartados": se guardan en
|
||||||
|
// el cliente ligado al apartado (se crea la ficha mínima si aún no existe)
|
||||||
|
apiRouter.get('/apartados/:itemId/documentos', listarDocumentosApartado)
|
||||||
|
apiRouter.post('/apartados/:itemId/documentos', uploadDocumentosApartado.array('archivos', 10), subirDocumentosApartado)
|
||||||
|
|
||||||
apiRouter.get('/mi-dia/resumen', resumenMiDiaEndpoint)
|
apiRouter.get('/mi-dia/resumen', resumenMiDiaEndpoint)
|
||||||
|
|
||||||
|
|||||||
@@ -203,14 +203,25 @@ function calcularPropuestaConfig(
|
|||||||
apartado: number,
|
apartado: number,
|
||||||
descuento: number,
|
descuento: number,
|
||||||
n: number | null,
|
n: number | null,
|
||||||
|
enganchePct: number | null = null,
|
||||||
): PropuestaResultado {
|
): PropuestaResultado {
|
||||||
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
|
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
|
||||||
|
|
||||||
|
// % de enganche efectivo: el override del asesor (mínimo 2.5%) solo aplica a
|
||||||
|
// esquemas con crédito (bancario/Infonavit: 2.5–5%); Contado (90%) y
|
||||||
|
// Semi Contado (50%) son estructuras fijas del Excel
|
||||||
|
const aplicaOverride =
|
||||||
|
enganchePct !== null &&
|
||||||
|
cfg.modoMensualidades !== 'ninguna' &&
|
||||||
|
cfg.pctEnganche > 0 &&
|
||||||
|
cfg.pctEnganche <= 0.05
|
||||||
|
const pctEnganche = aplicaOverride ? Math.max(0.025, enganchePct) : cfg.pctEnganche
|
||||||
|
|
||||||
const baseEnganche = cfg.engancheBase === 'lista' ? precioLista : precioFinal
|
const baseEnganche = cfg.engancheBase === 'lista' ? precioLista : precioFinal
|
||||||
const enganche =
|
const enganche =
|
||||||
cfg.engancheCeroSinApartado && cfg.pctEnganche === 0
|
cfg.engancheCeroSinApartado && pctEnganche === 0
|
||||||
? 0
|
? 0
|
||||||
: cfg.pctEnganche * baseEnganche - apartado
|
: pctEnganche * baseEnganche - apartado
|
||||||
|
|
||||||
const basePagoFinal = cfg.pagoFinalBase === 'lista' ? precioLista : precioFinal
|
const basePagoFinal = cfg.pagoFinalBase === 'lista' ? precioLista : precioFinal
|
||||||
const pagoFinal = cfg.pctPagoFinal * basePagoFinal
|
const pagoFinal = cfg.pctPagoFinal * basePagoFinal
|
||||||
@@ -231,9 +242,11 @@ function calcularPropuestaConfig(
|
|||||||
mensualidadesTotal = precioLista - pagoFinal - apartado - enganche
|
mensualidadesTotal = precioLista - pagoFinal - apartado - enganche
|
||||||
break
|
break
|
||||||
case 'porcentaje':
|
case 'porcentaje':
|
||||||
mensualidadesTotal = precioFinal * (1 - cfg.pctEnganche - cfg.pctPagoFinal)
|
mensualidadesTotal = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
// Con enganche alto el remanente puede quedar en negativo: se acota a 0
|
||||||
|
mensualidadesTotal = Math.max(0, mensualidadesTotal)
|
||||||
mensualidad = mensualidadesTotal / n
|
mensualidad = mensualidadesTotal / n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,6 +319,8 @@ export type TipoPropuestaApi = keyof typeof TIPO_PROPUESTA_API
|
|||||||
* Calcula UNA propuesta (la elegida por el cliente) para persistir/cotizar,
|
* Calcula UNA propuesta (la elegida por el cliente) para persistir/cotizar,
|
||||||
* con los parámetros del desarrollo de la unidad.
|
* con los parámetros del desarrollo de la unidad.
|
||||||
* `descuento` opcional (fracción); si no viene, usa el default de la propuesta.
|
* `descuento` opcional (fracción); si no viene, usa el default de la propuesta.
|
||||||
|
* `enganchePct` opcional (fracción, mínimo 0.025); solo aplica a esquemas con
|
||||||
|
* crédito (bancario/Infonavit); Contado y Semi Contado usan su % fijo.
|
||||||
*/
|
*/
|
||||||
export function calcularPropuesta(
|
export function calcularPropuesta(
|
||||||
unidad: UnidadCotizable,
|
unidad: UnidadCotizable,
|
||||||
@@ -313,6 +328,7 @@ export function calcularPropuesta(
|
|||||||
acabados: boolean,
|
acabados: boolean,
|
||||||
apartado: number = APARTADO_DEFAULT,
|
apartado: number = APARTADO_DEFAULT,
|
||||||
descuento?: number,
|
descuento?: number,
|
||||||
|
enganchePct?: number,
|
||||||
): PropuestaResultado {
|
): PropuestaResultado {
|
||||||
const params = obtenerDesarrollo(unidad.desarrollo)
|
const params = obtenerDesarrollo(unidad.desarrollo)
|
||||||
const tipoInterno = TIPO_PROPUESTA_API[tipo]
|
const tipoInterno = TIPO_PROPUESTA_API[tipo]
|
||||||
@@ -323,5 +339,5 @@ export function calcularPropuesta(
|
|||||||
const precioLista = obtenerPrecioLista(unidad, acabados)
|
const precioLista = obtenerPrecioLista(unidad, acabados)
|
||||||
const n = resolverNumMensualidades(unidad, params)
|
const n = resolverNumMensualidades(unidad, params)
|
||||||
const d = descuento ?? cfg.descuentoDefault
|
const d = descuento ?? cfg.descuentoDefault
|
||||||
return calcularPropuestaConfig(cfg, precioLista, apartado, d, n)
|
return calcularPropuestaConfig(cfg, precioLista, apartado, d, n, enganchePct ?? null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, type FormEvent } from 'react'
|
import { useState, type FormEvent } from 'react'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { crearAfluencia, type LeadResumen } from '../services/api'
|
import { crearAfluencia, type LeadResumen } from '../services/api'
|
||||||
|
import BusquedaLead from './BusquedaLead'
|
||||||
import {
|
import {
|
||||||
DESARROLLOS_LEAD,
|
DESARROLLOS_LEAD,
|
||||||
ESTADOS_CIVILES,
|
ESTADOS_CIVILES,
|
||||||
@@ -78,6 +79,36 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
|
|||||||
const modelosDelDesarrollo =
|
const modelosDelDesarrollo =
|
||||||
MODELOS_POR_PROYECTO[form.desarrollo as keyof typeof MODELOS_POR_PROYECTO] ?? []
|
MODELOS_POR_PROYECTO[form.desarrollo as keyof typeof MODELOS_POR_PROYECTO] ?? []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jala los datos de un lead u oportunidad ya capturados en Monday:
|
||||||
|
* nombre/contacto siempre; origen, desarrollo y modelo solo si el valor del
|
||||||
|
* lead existe entre las opciones del formulario.
|
||||||
|
*/
|
||||||
|
const prellenarDesdeLead = (lead: LeadResumen) => {
|
||||||
|
setForm((prev) => {
|
||||||
|
const desarrollo =
|
||||||
|
lead.desarrollo !== undefined && (DESARROLLOS_LEAD as readonly string[]).includes(lead.desarrollo)
|
||||||
|
? lead.desarrollo
|
||||||
|
: prev.desarrollo
|
||||||
|
const modelos = MODELOS_POR_PROYECTO[desarrollo as keyof typeof MODELOS_POR_PROYECTO] ?? []
|
||||||
|
const modelo =
|
||||||
|
lead.modeloInteres !== undefined && (modelos as readonly string[]).includes(lead.modeloInteres)
|
||||||
|
? lead.modeloInteres
|
||||||
|
: ''
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
nombre: lead.nombre,
|
||||||
|
telefono: lead.telefono,
|
||||||
|
email: lead.email,
|
||||||
|
origen: (ORIGENES_LEAD as readonly string[]).includes(lead.origen) ? lead.origen : prev.origen,
|
||||||
|
desarrollo,
|
||||||
|
modelo,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setErrores({})
|
||||||
|
setAviso('')
|
||||||
|
}
|
||||||
|
|
||||||
const validar = (): boolean => {
|
const validar = (): boolean => {
|
||||||
const nuevos: Errores = {}
|
const nuevos: Errores = {}
|
||||||
if (!form.nombre.trim()) nuevos.nombre = 'El nombre es obligatorio'
|
if (!form.nombre.trim()) nuevos.nombre = 'El nombre es obligatorio'
|
||||||
@@ -180,6 +211,12 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => void enviar(e)} noValidate>
|
<form onSubmit={(e) => void enviar(e)} noValidate>
|
||||||
|
<div className="mb-6 border-b border-[#E5E7EB] pb-6">
|
||||||
|
<BusquedaLead onSeleccion={prellenarDesdeLead} />
|
||||||
|
<p className="mt-2 text-xs text-[#64748B]">
|
||||||
|
Si el interesado ya está en Monday (lead u oportunidad), sus datos se prellenan solos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="grid gap-6 sm:grid-cols-2">
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
<label htmlFor="afluencia-nombre" className="block sm:col-span-2">
|
<label htmlFor="afluencia-nombre" className="block sm:col-span-2">
|
||||||
<span className={labelClase}>
|
<span className={labelClase}>
|
||||||
|
|||||||
@@ -227,6 +227,10 @@ export interface LeadResumen {
|
|||||||
origen: string
|
origen: string
|
||||||
/** 'lead' = archivo histórico (tablero Leads); 'oportunidad' = modelo nuevo (tablero Oportunidades) */
|
/** 'lead' = archivo histórico (tablero Leads); 'oportunidad' = modelo nuevo (tablero Oportunidades) */
|
||||||
fuente: 'lead' | 'oportunidad'
|
fuente: 'lead' | 'oportunidad'
|
||||||
|
/** Desarrollo de interés ('' si no tiene); prellena la afluencia */
|
||||||
|
desarrollo?: string
|
||||||
|
/** Modelo de interés ('' si no tiene) */
|
||||||
|
modeloInteres?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /leads/buscar?q= — autocompletado para prellenar el alta (busca en AMBOS tableros) */
|
/** GET /leads/buscar?q= — autocompletado para prellenar el alta (busca en AMBOS tableros) */
|
||||||
|
|||||||
105
apps/cotizador/src/components/BuscadorCliente.tsx
Normal file
105
apps/cotizador/src/components/BuscadorCliente.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { buscarLeads, type LeadResumen } from '../services/api'
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 300
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSeleccion: (lead: LeadResumen) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscador con debounce de leads/oportunidades de Monday (GET /leads/buscar):
|
||||||
|
* jala nombre, teléfono y email del cliente al cotizador; si el resultado es
|
||||||
|
* una oportunidad, la cotización queda vinculada a ella al guardar.
|
||||||
|
*/
|
||||||
|
export function BuscadorCliente({ onSeleccion }: Props) {
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [resultados, setResultados] = useState<LeadResumen[] | null>(null)
|
||||||
|
const [buscando, setBuscando] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const texto = q.trim()
|
||||||
|
if (texto.length < 2) {
|
||||||
|
setResultados(null)
|
||||||
|
setError('')
|
||||||
|
setBuscando(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBuscando(true)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
buscarLeads(texto)
|
||||||
|
.then((lista) => {
|
||||||
|
setResultados(lista)
|
||||||
|
setError('')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setResultados(null)
|
||||||
|
setError('No se pudo buscar en Monday. Intenta de nuevo.')
|
||||||
|
})
|
||||||
|
.finally(() => setBuscando(false))
|
||||||
|
}, DEBOUNCE_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [q])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="busqueda-cliente" className="mb-2 block text-xs font-medium text-[#4B5563]">
|
||||||
|
Buscar cliente en Monday (lead u oportunidad)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="busqueda-cliente"
|
||||||
|
type="search"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Nombre, teléfono o email…"
|
||||||
|
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{buscando && <p className="mt-3 text-xs text-[#64748B]">Buscando…</p>}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mt-3 text-xs text-red-700">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!buscando && !error && resultados !== null && resultados.length === 0 && (
|
||||||
|
<p className="mt-3 text-xs text-[#64748B]">Sin coincidencias.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resultados !== null && resultados.length > 0 && (
|
||||||
|
<ul className="mt-3 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||||
|
{resultados.map((lead) => (
|
||||||
|
<li key={`${lead.fuente}-${lead.id}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onSeleccion(lead)
|
||||||
|
setQ('')
|
||||||
|
setResultados(null)
|
||||||
|
}}
|
||||||
|
className="flex w-full flex-wrap items-baseline justify-between gap-2 px-4 py-3 text-left transition-colors duration-100 hover:bg-[#F8FAFC]"
|
||||||
|
>
|
||||||
|
<span className="flex items-baseline gap-2 text-sm text-[#111827]">
|
||||||
|
{lead.nombre}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
lead.fuente === 'oportunidad'
|
||||||
|
? 'rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-medium text-blue-800'
|
||||||
|
: 'rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{lead.fuente === 'oportunidad' ? 'Oportunidad' : 'Lead'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[#64748B]">
|
||||||
|
{lead.telefono || 'sin teléfono'}
|
||||||
|
{lead.estado ? ` · ${lead.estado}` : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ interface Props {
|
|||||||
numero: number
|
numero: number
|
||||||
titulo: string
|
titulo: string
|
||||||
propuesta: PropuestaResultado
|
propuesta: PropuestaResultado
|
||||||
onDescuentoChange: (fraccion: number) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Renglon({ etiqueta, valor, nota }: { etiqueta: string; valor: string; nota?: string }) {
|
function Renglon({ etiqueta, valor, nota }: { etiqueta: string; valor: string; nota?: string }) {
|
||||||
@@ -24,10 +23,8 @@ function Renglon({ etiqueta, valor, nota }: { etiqueta: string; valor: string; n
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tarjeta de una propuesta comercial con descuento editable */
|
/** Tarjeta de una propuesta comercial (el descuento ya no es editable: usa el del Excel) */
|
||||||
export function ProposalCard({ numero, titulo, propuesta, onDescuentoChange }: Props) {
|
export function ProposalCard({ numero, titulo, propuesta }: Props) {
|
||||||
const porcentaje = propuesta.descuento * 100
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="flex flex-col rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm transition-all duration-200 hover:shadow-md">
|
<article className="flex flex-col rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm transition-all duration-200 hover:shadow-md">
|
||||||
<header className="mb-6 border-b border-[#E5E7EB] pb-4">
|
<header className="mb-6 border-b border-[#E5E7EB] pb-4">
|
||||||
@@ -37,30 +34,10 @@ export function ProposalCard({ numero, titulo, propuesta, onDescuentoChange }: P
|
|||||||
<h2 className="mt-1 text-xl font-semibold text-[#111827]">{titulo}</h2>
|
<h2 className="mt-1 text-xl font-semibold text-[#111827]">{titulo}</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<label
|
|
||||||
htmlFor={`descuento-${propuesta.tipo}`}
|
|
||||||
className="mb-2 block text-xs font-medium text-[#4B5563]"
|
|
||||||
>
|
|
||||||
Descuento (%)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id={`descuento-${propuesta.tipo}`}
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={0.5}
|
|
||||||
value={porcentaje}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = Number(e.target.value)
|
|
||||||
onDescuentoChange(Number.isFinite(v) ? Math.min(100, Math.max(0, v)) / 100 : 0)
|
|
||||||
}}
|
|
||||||
className="w-28 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="divide-y divide-[#E5E7EB]">
|
<div className="divide-y divide-[#E5E7EB]">
|
||||||
|
{propuesta.descuento > 0 && (
|
||||||
<Renglon etiqueta="Monto de descuento" valor={formatoMoneda(propuesta.montoDescuento)} />
|
<Renglon etiqueta="Monto de descuento" valor={formatoMoneda(propuesta.montoDescuento)} />
|
||||||
|
)}
|
||||||
<Renglon etiqueta="Precio final c/desc." valor={formatoMoneda(propuesta.precioFinal)} />
|
<Renglon etiqueta="Precio final c/desc." valor={formatoMoneda(propuesta.precioFinal)} />
|
||||||
<Renglon
|
<Renglon
|
||||||
etiqueta="Enganche"
|
etiqueta="Enganche"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
obtenerPrecioLista,
|
obtenerPrecioLista,
|
||||||
resolverNumMensualidades,
|
resolverNumMensualidades,
|
||||||
validarApartado,
|
validarApartado,
|
||||||
|
validarEnganchePct,
|
||||||
type PropuestaResultado,
|
type PropuestaResultado,
|
||||||
} from '../lib/calculations'
|
} from '../lib/calculations'
|
||||||
import { DESARROLLO_DEFAULT, descuentosDefault, obtenerDesarrollo } from '../data/desarrollos'
|
import { DESARROLLO_DEFAULT, descuentosDefault, obtenerDesarrollo } from '../data/desarrollos'
|
||||||
@@ -26,6 +27,11 @@ interface UseCotizacionResult {
|
|||||||
apartadoEfectivo: number
|
apartadoEfectivo: number
|
||||||
/** Mensaje de error de la captura del apartado; null si es válida */
|
/** Mensaje de error de la captura del apartado; null si es válida */
|
||||||
errorApartado: string | null
|
errorApartado: string | null
|
||||||
|
/** Enganche capturado en PORCENTAJE (10 = 10%); null = % del Excel de cada propuesta */
|
||||||
|
enganchePct: number | null
|
||||||
|
setEnganchePct: (v: number | null) => void
|
||||||
|
/** Mensaje de error del enganche capturado; null si es válido */
|
||||||
|
errorEnganche: string | null
|
||||||
descuentos: DescuentosPropuestas
|
descuentos: DescuentosPropuestas
|
||||||
setDescuento: (clave: keyof DescuentosPropuestas, valor: number) => void
|
setDescuento: (clave: keyof DescuentosPropuestas, valor: number) => void
|
||||||
/** Índice de la propuesta elegida (para guardar/compartir) */
|
/** Índice de la propuesta elegida (para guardar/compartir) */
|
||||||
@@ -57,6 +63,7 @@ export function useCotizacion(): UseCotizacionResult {
|
|||||||
const [unidad, setUnidad] = useState<Unidad | null>(null)
|
const [unidad, setUnidad] = useState<Unidad | null>(null)
|
||||||
const [acabados, setAcabados] = useState(false)
|
const [acabados, setAcabados] = useState(false)
|
||||||
const [apartado, setApartado] = useState<number>(APARTADO_DEFAULT)
|
const [apartado, setApartado] = useState<number>(APARTADO_DEFAULT)
|
||||||
|
const [enganchePct, setEnganchePct] = useState<number | null>(null)
|
||||||
const [descuentos, setDescuentos] = useState<DescuentosPropuestas>(descuentosDefault(DESARROLLO_DEFAULT))
|
const [descuentos, setDescuentos] = useState<DescuentosPropuestas>(descuentosDefault(DESARROLLO_DEFAULT))
|
||||||
const [propuestaIndex, setPropuestaIndex] = useState(0)
|
const [propuestaIndex, setPropuestaIndex] = useState(0)
|
||||||
|
|
||||||
@@ -93,6 +100,7 @@ export function useCotizacion(): UseCotizacionResult {
|
|||||||
setUnidad(null)
|
setUnidad(null)
|
||||||
setAcabados(false)
|
setAcabados(false)
|
||||||
setApartado(APARTADO_DEFAULT)
|
setApartado(APARTADO_DEFAULT)
|
||||||
|
setEnganchePct(null)
|
||||||
setDescuentos(descuentosDefault(desarrollo))
|
setDescuentos(descuentosDefault(desarrollo))
|
||||||
setPropuestaIndex(0)
|
setPropuestaIndex(0)
|
||||||
}
|
}
|
||||||
@@ -111,10 +119,14 @@ export function useCotizacion(): UseCotizacionResult {
|
|||||||
const errorApartado = validarApartado(apartado)
|
const errorApartado = validarApartado(apartado)
|
||||||
const apartadoEfectivo = Math.max(apartado, APARTADO_MINIMO)
|
const apartadoEfectivo = Math.max(apartado, APARTADO_MINIMO)
|
||||||
|
|
||||||
|
// Enganche personalizado: null = default del Excel; inválido no entra al cálculo
|
||||||
|
const errorEnganche = validarEnganchePct(enganchePct)
|
||||||
|
const engancheEfectivo = errorEnganche === null && enganchePct !== null ? enganchePct / 100 : null
|
||||||
|
|
||||||
const propuestas = useMemo(() => {
|
const propuestas = useMemo(() => {
|
||||||
if (!unidad) return []
|
if (!unidad) return []
|
||||||
return calcularPropuestas({ unidad, acabados, apartado: apartadoEfectivo, descuentos, desarrollo })
|
return calcularPropuestas({ unidad, acabados, apartado: apartadoEfectivo, descuentos, desarrollo, enganchePct: engancheEfectivo })
|
||||||
}, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo])
|
}, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo, engancheEfectivo])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
desarrollo,
|
desarrollo,
|
||||||
@@ -128,6 +140,9 @@ export function useCotizacion(): UseCotizacionResult {
|
|||||||
setApartado,
|
setApartado,
|
||||||
apartadoEfectivo,
|
apartadoEfectivo,
|
||||||
errorApartado,
|
errorApartado,
|
||||||
|
enganchePct,
|
||||||
|
setEnganchePct,
|
||||||
|
errorEnganche,
|
||||||
descuentos,
|
descuentos,
|
||||||
setDescuento,
|
setDescuento,
|
||||||
propuestaIndex,
|
propuestaIndex,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
totalPagado,
|
totalPagado,
|
||||||
indiceMenorTotal,
|
indiceMenorTotal,
|
||||||
validarApartado,
|
validarApartado,
|
||||||
|
validarEnganchePct,
|
||||||
APARTADO_DEFAULT,
|
APARTADO_DEFAULT,
|
||||||
APARTADO_MINIMO,
|
APARTADO_MINIMO,
|
||||||
} from './calculations'
|
} from './calculations'
|
||||||
@@ -192,3 +193,88 @@ describe('indiceMenorTotal', () => {
|
|||||||
expect(indiceMenorTotal([])).toBe(-1)
|
expect(indiceMenorTotal([])).toBe(-1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('enganche personalizado (mínimo 2.5% del valor de la propiedad)', () => {
|
||||||
|
const PRECIO_LISTA = 4579000 // U-101 con acabados
|
||||||
|
|
||||||
|
it('override 10%: solo bancario/Infonavit cambian; Contado y Semi Contado quedan fijos', () => {
|
||||||
|
const [bancario, infonavit, contado, semi] = calcularPropuestas({
|
||||||
|
unidad: U101,
|
||||||
|
acabados: true,
|
||||||
|
apartado: APARTADO_DEFAULT,
|
||||||
|
descuentos: { creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 },
|
||||||
|
numMeses: N,
|
||||||
|
enganchePct: 0.1,
|
||||||
|
})
|
||||||
|
// Bancario DIAGONAL: 10% sobre precio de LISTA, neto del apartado
|
||||||
|
expect(bancario.enganche).toBeCloseTo(0.1 * PRECIO_LISTA - APARTADO_DEFAULT, 2)
|
||||||
|
// Infonavit igual (misma base 'lista'); sus mensualidades se reducen (acotadas a 0:
|
||||||
|
// 10% de enganche + 95% de pago final ya cubren más que el precio)
|
||||||
|
expect(infonavit.enganche).toBeCloseTo(0.1 * PRECIO_LISTA - APARTADO_DEFAULT, 2)
|
||||||
|
expect(infonavit.mensualidadesTotal).toBe(0)
|
||||||
|
// Contado (90%) y Semi Contado (50%): estructuras fijas del Excel, intactas
|
||||||
|
expect(contado.enganche).toBeCloseTo(4018678, 2)
|
||||||
|
expect(semi.enganche).toBeCloseTo(0.5 * semi.precioFinal - APARTADO_DEFAULT, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('override 4%: las mensualidades de Infonavit se recalculan con el enganche elegido', () => {
|
||||||
|
const [, infonavit] = calcularPropuestas({
|
||||||
|
unidad: U101,
|
||||||
|
acabados: true,
|
||||||
|
apartado: APARTADO_DEFAULT,
|
||||||
|
descuentos: { creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 },
|
||||||
|
numMeses: N,
|
||||||
|
enganchePct: 0.04,
|
||||||
|
})
|
||||||
|
const engancheEsperado = 0.04 * PRECIO_LISTA - APARTADO_DEFAULT
|
||||||
|
expect(infonavit.enganche).toBeCloseTo(engancheEsperado, 2)
|
||||||
|
expect(infonavit.mensualidadesTotal).toBeCloseTo(PRECIO_LISTA - infonavit.pagoFinal - APARTADO_DEFAULT - engancheEsperado, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('se acota al mínimo 2.5% aunque se pida menos', () => {
|
||||||
|
const [bancario] = calcularPropuestas({
|
||||||
|
unidad: U101,
|
||||||
|
acabados: true,
|
||||||
|
apartado: APARTADO_DEFAULT,
|
||||||
|
descuentos: { creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 },
|
||||||
|
numMeses: N,
|
||||||
|
enganchePct: 0.01, // por debajo del mínimo
|
||||||
|
})
|
||||||
|
expect(bancario.enganche).toBeCloseTo(0.025 * PRECIO_LISTA - APARTADO_DEFAULT, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('las mensualidades nunca quedan en negativo (enganche muy alto)', () => {
|
||||||
|
const [, infonavit] = calcularPropuestas({
|
||||||
|
unidad: U101,
|
||||||
|
acabados: true,
|
||||||
|
apartado: APARTADO_DEFAULT,
|
||||||
|
descuentos: { creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 },
|
||||||
|
numMeses: N,
|
||||||
|
enganchePct: 0.5, // 50% + pago final 95% > 100%: remanente negativo → 0
|
||||||
|
})
|
||||||
|
expect(infonavit.mensualidadesTotal).toBe(0)
|
||||||
|
expect(infonavit.mensualidad).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('null = comportamiento del Excel (sin override)', () => {
|
||||||
|
const [bancario] = calcularPropuestas({
|
||||||
|
unidad: U101,
|
||||||
|
acabados: true,
|
||||||
|
apartado: APARTADO_DEFAULT,
|
||||||
|
descuentos: { creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 },
|
||||||
|
numMeses: N,
|
||||||
|
enganchePct: null,
|
||||||
|
})
|
||||||
|
expect(bancario.enganche).toBeCloseTo(94475, 2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validarEnganchePct', () => {
|
||||||
|
it('null es válido (sin override); mínimo 2.5%; máximo 100%', () => {
|
||||||
|
expect(validarEnganchePct(null)).toBeNull()
|
||||||
|
expect(validarEnganchePct(2.5)).toBeNull()
|
||||||
|
expect(validarEnganchePct(50)).toBeNull()
|
||||||
|
expect(validarEnganchePct(2.4)).toBe('El enganche mínimo es 2.5% del valor de la propiedad')
|
||||||
|
expect(validarEnganchePct(101)).toBe('El enganche no puede ser mayor al 100%')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -23,6 +23,31 @@ export const APARTADO_DEFAULT = 20000
|
|||||||
/** Monto mínimo de apartado aceptado por el cotizador */
|
/** Monto mínimo de apartado aceptado por el cotizador */
|
||||||
export const APARTADO_MINIMO = 20000
|
export const APARTADO_MINIMO = 20000
|
||||||
|
|
||||||
|
/** Enganche mínimo seleccionable: 2.5% del valor de la propiedad (fracción) */
|
||||||
|
export const ENGANCHE_MINIMO_PCT = 0.025
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mensaje de error si el enganche capturado (en PORCENTAJE, 2.5 = 2.5%) es
|
||||||
|
* inválido; null si es válido. null de entrada = sin override (default Excel).
|
||||||
|
*/
|
||||||
|
export function validarEnganchePct(pct: number | null): string | null {
|
||||||
|
if (pct === null) return null
|
||||||
|
if (!Number.isFinite(pct) || pct < 2.5) {
|
||||||
|
return 'El enganche mínimo es 2.5% del valor de la propiedad'
|
||||||
|
}
|
||||||
|
if (pct > 100) return 'El enganche no puede ser mayor al 100%'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ¿La propuesta acepta enganche personalizado? Solo los esquemas con crédito
|
||||||
|
* (bancario/Infonavit-Cofinavit: enganche financiado de 2.5–5%). Contado (90%)
|
||||||
|
* y Semi Contado (50%) son estructuras fijas del Excel: no se tocan.
|
||||||
|
*/
|
||||||
|
export function aplicaEnganchePersonalizado(cfg: PropuestaConfig): boolean {
|
||||||
|
return cfg.modoMensualidades !== 'ninguna' && cfg.pctEnganche > 0 && cfg.pctEnganche <= 0.05
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mensaje de error si el apartado capturado es inválido; null si es válido.
|
* Mensaje de error si el apartado capturado es inválido; null si es válido.
|
||||||
* La UI acota el valor al mínimo para los cálculos y muestra este mensaje.
|
* La UI acota el valor al mínimo para los cálculos y muestra este mensaje.
|
||||||
@@ -68,6 +93,12 @@ export interface CotizacionInput {
|
|||||||
desarrollo?: string
|
desarrollo?: string
|
||||||
/** Permite forzar N en tests; por defecto se resuelve según la fuenteDeN del desarrollo */
|
/** Permite forzar N en tests; por defecto se resuelve según la fuenteDeN del desarrollo */
|
||||||
numMeses?: number
|
numMeses?: number
|
||||||
|
/**
|
||||||
|
* Enganche elegido por el asesor (fracción, mínimo ENGANCHE_MINIMO_PCT);
|
||||||
|
* null = el % del Excel de cada propuesta. Solo aplica a esquemas con
|
||||||
|
* crédito (ver aplicaEnganchePersonalizado).
|
||||||
|
*/
|
||||||
|
enganchePct?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const MS_POR_MES = 30.4 * 86400000
|
const MS_POR_MES = 30.4 * 86400000
|
||||||
@@ -112,15 +143,23 @@ function calcularPropuestaConfig(
|
|||||||
apartado: number,
|
apartado: number,
|
||||||
descuento: number,
|
descuento: number,
|
||||||
n: number | null,
|
n: number | null,
|
||||||
|
enganchePct: number | null = null,
|
||||||
): PropuestaResultado {
|
): PropuestaResultado {
|
||||||
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
|
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
|
||||||
|
|
||||||
|
// % de enganche efectivo: el override del asesor (mínimo 2.5%) solo aplica a
|
||||||
|
// esquemas con crédito; el resto usa el % fijo del Excel
|
||||||
|
const pctEnganche =
|
||||||
|
enganchePct !== null && aplicaEnganchePersonalizado(cfg)
|
||||||
|
? Math.max(ENGANCHE_MINIMO_PCT, enganchePct)
|
||||||
|
: cfg.pctEnganche
|
||||||
|
|
||||||
// Enganche: pct sobre su base, neto del apartado (salvo la guarda de SOLANA)
|
// Enganche: pct sobre su base, neto del apartado (salvo la guarda de SOLANA)
|
||||||
const baseEnganche = cfg.engancheBase === 'lista' ? precioLista : precioFinal
|
const baseEnganche = cfg.engancheBase === 'lista' ? precioLista : precioFinal
|
||||||
const enganche =
|
const enganche =
|
||||||
cfg.engancheCeroSinApartado && cfg.pctEnganche === 0
|
cfg.engancheCeroSinApartado && pctEnganche === 0
|
||||||
? 0
|
? 0
|
||||||
: cfg.pctEnganche * baseEnganche - apartado
|
: pctEnganche * baseEnganche - apartado
|
||||||
|
|
||||||
const basePagoFinal = cfg.pagoFinalBase === 'lista' ? precioLista : precioFinal
|
const basePagoFinal = cfg.pagoFinalBase === 'lista' ? precioLista : precioFinal
|
||||||
const pagoFinal = cfg.pctPagoFinal * basePagoFinal
|
const pagoFinal = cfg.pctPagoFinal * basePagoFinal
|
||||||
@@ -142,9 +181,12 @@ function calcularPropuestaConfig(
|
|||||||
mensualidadesTotal = precioLista - pagoFinal - apartado - enganche
|
mensualidadesTotal = precioLista - pagoFinal - apartado - enganche
|
||||||
break
|
break
|
||||||
case 'porcentaje':
|
case 'porcentaje':
|
||||||
mensualidadesTotal = precioFinal * (1 - cfg.pctEnganche - cfg.pctPagoFinal)
|
mensualidadesTotal = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
// Con enganche alto el remanente puede quedar en negativo: se acota a 0
|
||||||
|
// (ya no hay nada que diferir; el cliente paga de más al inicio)
|
||||||
|
mensualidadesTotal = Math.max(0, mensualidadesTotal)
|
||||||
mensualidad = mensualidadesTotal / n
|
mensualidad = mensualidadesTotal / n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,6 +243,6 @@ export function calcularPropuestas(input: CotizacionInput): PropuestaResultado[]
|
|||||||
const precioLista = obtenerPrecioLista(unidad, acabados)
|
const precioLista = obtenerPrecioLista(unidad, acabados)
|
||||||
const n = input.numMeses ?? resolverNumMensualidades(unidad, params)
|
const n = input.numMeses ?? resolverNumMensualidades(unidad, params)
|
||||||
return params.propuestas.map((cfg) =>
|
return params.propuestas.map((cfg) =>
|
||||||
calcularPropuestaConfig(cfg, precioLista, apartado, descuentos[cfg.claveDescuento], n),
|
calcularPropuestaConfig(cfg, precioLista, apartado, descuentos[cfg.claveDescuento], n, input.enganchePct ?? null),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ComparadorPropuestas } from '../components/ComparadorPropuestas'
|
|||||||
import { ComparadorUnidades } from '../components/ComparadorUnidades'
|
import { ComparadorUnidades } from '../components/ComparadorUnidades'
|
||||||
import { MisCotizaciones } from '../components/MisCotizaciones'
|
import { MisCotizaciones } from '../components/MisCotizaciones'
|
||||||
import { ModalCliente } from '../components/ModalCliente'
|
import { ModalCliente } from '../components/ModalCliente'
|
||||||
|
import { BuscadorCliente } from '../components/BuscadorCliente'
|
||||||
import { generarPdf } from '../lib/pdf'
|
import { generarPdf } from '../lib/pdf'
|
||||||
import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
|
import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
|
||||||
import { calcularVigencia } from '../lib/vigencia'
|
import { calcularVigencia } from '../lib/vigencia'
|
||||||
@@ -60,7 +61,9 @@ export default function CotizadorPage() {
|
|||||||
setApartado,
|
setApartado,
|
||||||
apartadoEfectivo,
|
apartadoEfectivo,
|
||||||
errorApartado,
|
errorApartado,
|
||||||
setDescuento,
|
enganchePct,
|
||||||
|
setEnganchePct,
|
||||||
|
errorEnganche,
|
||||||
propuestaIndex,
|
propuestaIndex,
|
||||||
setPropuestaIndex,
|
setPropuestaIndex,
|
||||||
precioLista,
|
precioLista,
|
||||||
@@ -154,7 +157,7 @@ export default function CotizadorPage() {
|
|||||||
|
|
||||||
// La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo
|
// 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 propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
|
||||||
const puedeGenerarPdf = Boolean(unidad) && !errorApartado && !guardando
|
const puedeGenerarPdf = Boolean(unidad) && !errorApartado && !errorEnganche && !guardando
|
||||||
|
|
||||||
/** Persiste la cotización en el API; null si no hay conexión (el PDF sale igual, sin folio/QR) */
|
/** Persiste la cotización en el API; null si no hay conexión (el PDF sale igual, sin folio/QR) */
|
||||||
const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => {
|
const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => {
|
||||||
@@ -169,6 +172,7 @@ export default function CotizadorPage() {
|
|||||||
acabados,
|
acabados,
|
||||||
apartado: apartadoEfectivo,
|
apartado: apartadoEfectivo,
|
||||||
descuento: propuestaActiva.descuento,
|
descuento: propuestaActiva.descuento,
|
||||||
|
...(enganchePct !== null && errorEnganche === null ? { enganchePct: enganchePct / 100 } : {}),
|
||||||
...(oportunidadId !== null ? { oportunidadId } : {}),
|
...(oportunidadId !== null ? { oportunidadId } : {}),
|
||||||
})
|
})
|
||||||
const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) }
|
const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) }
|
||||||
@@ -419,6 +423,54 @@ export default function CotizadorPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="enganche" className={etiquetaCampo}>
|
||||||
|
Enganche (%) — opcional
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="enganche"
|
||||||
|
type="number"
|
||||||
|
min={2.5}
|
||||||
|
max={100}
|
||||||
|
step={0.5}
|
||||||
|
placeholder="Según propuesta"
|
||||||
|
value={enganchePct ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const crudo = e.target.value
|
||||||
|
if (crudo === '') {
|
||||||
|
setEnganchePct(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const v = Number(crudo)
|
||||||
|
setEnganchePct(Number.isFinite(v) ? v : null)
|
||||||
|
}}
|
||||||
|
className={errorEnganche ? campoInputError : campoInput}
|
||||||
|
/>
|
||||||
|
{errorEnganche ? (
|
||||||
|
<p className="mt-1 text-xs text-red-700">{errorEnganche}</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-xs text-[#64748B]">
|
||||||
|
Aplica a crédito bancario e Infonavit/Cofinavit; mínimo 2.5%
|
||||||
|
{unidad ? ` (${formatoMoneda((precioLista * Math.max(enganchePct ?? 2.5, 2.5)) / 100)})` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BuscadorCliente
|
||||||
|
onSeleccion={(lead) => {
|
||||||
|
setValue('nombre', lead.nombre, { shouldValidate: true })
|
||||||
|
if (lead.telefono) setValue('telefono', lead.telefono, { shouldValidate: true })
|
||||||
|
if (lead.email) setValue('email', lead.email, { shouldValidate: true })
|
||||||
|
if (lead.fuente === 'oportunidad') setOportunidadId(lead.id)
|
||||||
|
if (lead.desarrollo && LISTA_DESARROLLOS.includes(lead.desarrollo)) setDesarrollo(lead.desarrollo)
|
||||||
|
setAviso(
|
||||||
|
`Datos de ${lead.nombre} cargados${
|
||||||
|
lead.fuente === 'oportunidad' ? ' — la cotización quedará vinculada a su oportunidad' : ''
|
||||||
|
}.`,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="nombre" className={etiquetaCampo}>
|
<label htmlFor="nombre" className={etiquetaCampo}>
|
||||||
Nombre del cliente *
|
Nombre del cliente *
|
||||||
@@ -502,7 +554,6 @@ export default function CotizadorPage() {
|
|||||||
numero={i + 1}
|
numero={i + 1}
|
||||||
titulo={p.titulo}
|
titulo={p.titulo}
|
||||||
propuesta={p}
|
propuesta={p}
|
||||||
onDescuentoChange={(v) => setDescuento(params.propuestas[i].claveDescuento, v)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,3 +108,28 @@ export async function getMisCotizaciones(): Promise<CotizacionGuardada[]> {
|
|||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
return (await resp.json()) as CotizacionGuardada[]
|
return (await resp.json()) as CotizacionGuardada[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Búsqueda de clientes en Monday (prellenado del cotizador) ----------
|
||||||
|
|
||||||
|
export interface LeadResumen {
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
telefono: string
|
||||||
|
email: string
|
||||||
|
estado: string
|
||||||
|
origen: string
|
||||||
|
/** 'lead' = tablero Leads; 'oportunidad' = tablero Oportunidades (se vincula al guardar) */
|
||||||
|
fuente: 'lead' | 'oportunidad'
|
||||||
|
/** Desarrollo de interés ('' si no tiene) */
|
||||||
|
desarrollo?: string
|
||||||
|
/** Modelo de interés ('' si no tiene) */
|
||||||
|
modeloInteres?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /leads/buscar?q= — busca en AMBOS tableros (Leads + Oportunidades) */
|
||||||
|
export async function buscarLeads(q: string): Promise<LeadResumen[]> {
|
||||||
|
const res = await fetch(`${API_URL}/leads/buscar?q=${encodeURIComponent(q)}`, conAuth())
|
||||||
|
manejar401(res)
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return (await res.json()) as LeadResumen[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const MODULOS: Modulo[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'clientes',
|
id: 'clientes',
|
||||||
nombre: 'Clientes',
|
nombre: 'Cobranza',
|
||||||
descripcion: 'Planes de enganche, vencidos y estados de cuenta',
|
descripcion: 'Planes de enganche, vencidos y estados de cuenta',
|
||||||
claves: ['cobranza'],
|
claves: ['cobranza'],
|
||||||
url: '/pay/',
|
url: '/pay/',
|
||||||
|
|||||||
@@ -156,6 +156,16 @@ export const ETIQUETA_FASE_OPORTUNIDAD: Record<FaseOportunidad, string> = {
|
|||||||
Abandono: 'Abandono',
|
Abandono: 'Abandono',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
export const SLOTS_DOCS_APARTADO = [
|
||||||
|
{ tipo: 'CAPTURA_COTIZADOR', etiqueta: 'Captura del cotizador', obligatorio: true },
|
||||||
|
{ tipo: 'CAPTURA_PAGO_APARTADO', etiqueta: 'Captura de pago apartado', obligatorio: true },
|
||||||
|
{ tipo: 'OTRO', etiqueta: 'Documentos generales (INE, CURP, comprobantes…)', obligatorio: false },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type SlotDocApartado = (typeof SLOTS_DOCS_APARTADO)[number]['tipo']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL del cotizador (/quote/) con los datos del cliente prellenados y el id de
|
* 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
|
* la oportunidad: al guardar, la cotización queda vinculada a ese cliente en
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
FASES_APARTADO,
|
FASES_APARTADO,
|
||||||
MOTIVOS_NO_COMPRA,
|
MOTIVOS_NO_COMPRA,
|
||||||
ORIGENES_LEAD,
|
ORIGENES_LEAD,
|
||||||
|
SLOTS_DOCS_APARTADO,
|
||||||
textoDiasRestantes,
|
textoDiasRestantes,
|
||||||
urlCotizadorParaOportunidad,
|
urlCotizadorParaOportunidad,
|
||||||
type ApartadoMiDia,
|
type ApartadoMiDia,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
type MotivoNoCompra,
|
type MotivoNoCompra,
|
||||||
type OportunidadMiDia,
|
type OportunidadMiDia,
|
||||||
type ResumenMiDia,
|
type ResumenMiDia,
|
||||||
|
type SlotDocApartado,
|
||||||
} from '../lib/mi-dia'
|
} from '../lib/mi-dia'
|
||||||
import {
|
import {
|
||||||
cambiarEstadoApartado,
|
cambiarEstadoApartado,
|
||||||
@@ -28,6 +30,9 @@ import {
|
|||||||
crearLead,
|
crearLead,
|
||||||
descartarLead,
|
descartarLead,
|
||||||
fetchResumenMiDia,
|
fetchResumenMiDia,
|
||||||
|
listarDocumentosApartado,
|
||||||
|
subirDocumentoApartado,
|
||||||
|
type DocumentoApartado,
|
||||||
} from '../services/mi-dia'
|
} from '../services/mi-dia'
|
||||||
import { getSesion } from '../services/auth'
|
import { getSesion } from '../services/auth'
|
||||||
|
|
||||||
@@ -234,6 +239,7 @@ function FilaApartado({
|
|||||||
onFase: (fase: FaseApartado) => void
|
onFase: (fase: FaseApartado) => void
|
||||||
enviando: boolean
|
enviando: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const [docsAbiertos, setDocsAbiertos] = useState(false)
|
||||||
return (
|
return (
|
||||||
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
|
<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 className="flex flex-wrap items-center justify-between gap-3">
|
||||||
@@ -276,11 +282,119 @@ function FilaApartado({
|
|||||||
{ETIQUETA_FASE_APARTADO[fase]}
|
{ETIQUETA_FASE_APARTADO[fase]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDocsAbiertos((v) => !v)}
|
||||||
|
aria-expanded={docsAbiertos}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||||
|
>
|
||||||
|
{docsAbiertos ? 'Cerrar documentos' : 'Documentos'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{docsAbiertos && <PanelDocumentosApartado itemId={apartado.id} />}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Precarga de documentos del expediente desde "Mis apartados": captura del
|
||||||
|
* cotizador, captura de pago apartado (ambos obligatorios) y documentos
|
||||||
|
* generales. Los archivos se guardan en el cliente ligado al apartado.
|
||||||
|
*/
|
||||||
|
function PanelDocumentosApartado({ itemId }: { itemId: string }) {
|
||||||
|
const [documentos, setDocumentos] = useState<DocumentoApartado[] | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [subiendo, setSubiendo] = useState<SlotDocApartado | null>(null)
|
||||||
|
|
||||||
|
const cargar = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setDocumentos(await listarDocumentosApartado(itemId))
|
||||||
|
setError('')
|
||||||
|
} catch {
|
||||||
|
setError('No se pudieron cargar los documentos.')
|
||||||
|
}
|
||||||
|
}, [itemId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void cargar()
|
||||||
|
}, [cargar])
|
||||||
|
|
||||||
|
const subir = async (tipo: SlotDocApartado, archivo: File | undefined) => {
|
||||||
|
if (!archivo) return
|
||||||
|
setSubiendo(tipo)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await subirDocumentoApartado(itemId, tipo, archivo)
|
||||||
|
await cargar()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'No se pudo subir el archivo.')
|
||||||
|
} finally {
|
||||||
|
setSubiendo(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded-lg border border-dashed border-[#D1D5DB] bg-[#F8FAFC] p-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||||
|
Expediente del cliente
|
||||||
|
</p>
|
||||||
|
{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">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ul className="mt-3 space-y-3">
|
||||||
|
{SLOTS_DOCS_APARTADO.map((slot) => {
|
||||||
|
const delSlot = (documentos ?? []).filter((d) => d.tipo === slot.tipo)
|
||||||
|
return (
|
||||||
|
<li key={slot.tipo}>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="text-xs font-medium text-[#374151]">
|
||||||
|
{slot.etiqueta}
|
||||||
|
{slot.obligatorio && <span className="ml-1 text-red-600">*</span>}
|
||||||
|
</p>
|
||||||
|
<label
|
||||||
|
className={`cursor-pointer rounded-lg border border-accent bg-white px-3 py-1.5 text-xs font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white ${
|
||||||
|
subiendo !== null ? 'pointer-events-none opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{subiendo === slot.tipo ? 'Subiendo…' : 'Subir archivo'}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png"
|
||||||
|
className="sr-only"
|
||||||
|
disabled={subiendo !== null}
|
||||||
|
onChange={(e) => {
|
||||||
|
void subir(slot.tipo, e.target.files?.[0])
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{documentos === null ? (
|
||||||
|
<p className="mt-1 text-[11px] text-[#94A3B8]">Cargando…</p>
|
||||||
|
) : delSlot.length === 0 ? (
|
||||||
|
<p className="mt-1 text-[11px] text-[#94A3B8]">Sin archivos</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-1 space-y-0.5">
|
||||||
|
{delSlot.map((d) => (
|
||||||
|
<li key={d.id} className="text-[11px] text-[#64748B]">
|
||||||
|
{d.nombreArchivo} · {formatoFechaCorta(d.createdAt.slice(0, 10))}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<p className="mt-3 text-[11px] text-[#94A3B8]">
|
||||||
|
* Obligatorios para el proceso de venta. PDF, JPG o PNG, máximo 10 MB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados */
|
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados */
|
||||||
type TabCrm = 'leads' | 'oportunidades' | 'apartados'
|
type TabCrm = 'leads' | 'oportunidades' | 'apartados'
|
||||||
|
|
||||||
|
|||||||
@@ -85,3 +85,30 @@ export function cambiarEstadoApartado(itemId: string, estado: string): Promise<u
|
|||||||
body: JSON.stringify({ estado }),
|
body: JSON.stringify({ estado }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Precarga de documentos del expediente (Mis apartados) ----------
|
||||||
|
|
||||||
|
export interface DocumentoApartado {
|
||||||
|
id: string
|
||||||
|
tipo: string // CAPTURA_COTIZADOR | CAPTURA_PAGO_APARTADO | OTRO | INE | ...
|
||||||
|
nombreArchivo: string
|
||||||
|
createdAt: string // ISO
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /apartados/:itemId/documentos — documentos ya cargados del cliente ligado */
|
||||||
|
export function listarDocumentosApartado(itemId: string): Promise<DocumentoApartado[]> {
|
||||||
|
return fetchJson<DocumentoApartado[]>(`/apartados/${itemId}/documentos`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /apartados/:itemId/documentos — multipart; el archivo se guarda en el cliente */
|
||||||
|
export async function subirDocumentoApartado(itemId: string, tipo: string, archivo: File): Promise<void> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('tipo', tipo)
|
||||||
|
form.append('archivos', archivo)
|
||||||
|
const res = await fetch(`${API_URL}/apartados/${itemId}/documentos`, conAuth({ method: 'POST', body: form }))
|
||||||
|
manejar401(res)
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = (await res.json().catch(() => null)) as { error?: string } | null
|
||||||
|
throw new Error(body?.error ?? `Error ${res.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user