fix(6-mejoras): subcategoría+apartado libre+PDF+coacreditado+docs+dedup
1. Subcategoría en modal descarte (catálogo completo + select) 2. Apartado sin monto mínimo (eliminada validación de 0,000) 3. PDF solo con propuesta elegida (no las 4) 4. Co-acreditado: checkbox 'no tiene' valida sin campos obligatorios 5. Documentos apartado: previsualizar + descargar (endpoint + frontend) 6. Oportunidades: sin duplicados, orden Confirmar→Afluencia→Cotizar→Aparto(verde)→Descartar
This commit is contained in:
@@ -154,3 +154,39 @@ export async function listarDocumentosApartado(req: Request, res: Response): Pro
|
|||||||
})
|
})
|
||||||
res.json(documentos)
|
res.json(documentos)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/apartados/:itemId/documentos/:docId/archivo — sirve el archivo del
|
||||||
|
* documento (PDF/imagen) para previsualizarlo o descargarlo desde "Mis
|
||||||
|
* apartados". El documento debe pertenecer al cliente ligado al apartado.
|
||||||
|
*/
|
||||||
|
export async function descargarDocumentoApartado(req: Request, res: Response): Promise<void> {
|
||||||
|
const itemId = String(req.params['itemId'])
|
||||||
|
const docId = String(req.params['docId'])
|
||||||
|
const cliente = await prisma.cliente.findFirst({ where: { mondayApartadoId: itemId } })
|
||||||
|
if (!cliente) {
|
||||||
|
res.status(404).json({ error: 'Apartado sin cliente ligado' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const documento = await prisma.documento.findFirst({ where: { id: docId, clienteId: cliente.id } })
|
||||||
|
if (!documento) {
|
||||||
|
res.status(404).json({ error: 'Documento no encontrado' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Guarda anti path-traversal: la ruta guardada vive dentro de uploadsDir
|
||||||
|
const rutaAbsoluta = path.resolve(config.uploadsDir, documento.ruta)
|
||||||
|
if (!rutaAbsoluta.startsWith(config.uploadsDir + path.sep)) {
|
||||||
|
res.status(400).json({ error: 'Ruta de archivo inválida' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(rutaAbsoluta)) {
|
||||||
|
res.status(404).json({ error: 'Archivo no encontrado en disco' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', documento.mime || 'application/octet-stream')
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`inline; filename="${documento.nombreArchivo.replace(/[^\w.\- ]+/g, '_')}"`,
|
||||||
|
)
|
||||||
|
res.sendFile(rutaAbsoluta)
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import { folioCotizacion, vigenciaCotizacion } from '../utils/folio.js'
|
|||||||
const crearCotizacionSchema = z.object({
|
const crearCotizacionSchema = z.object({
|
||||||
unidadCodigo: z.string().min(1),
|
unidadCodigo: z.string().min(1),
|
||||||
acabados: z.boolean(),
|
acabados: z.boolean(),
|
||||||
apartado: z.number().positive().optional(),
|
// Sin mínimo: el vendedor puede apartar con cualquier monto, incluso $0
|
||||||
|
apartado: z.number().min(0, 'El apartado no puede ser negativo').optional(),
|
||||||
clienteNombre: z.string().min(1),
|
clienteNombre: z.string().min(1),
|
||||||
clienteEmail: z.string().email().optional(),
|
clienteEmail: z.string().email().optional(),
|
||||||
clienteTelefono: z.string().min(7).optional(),
|
clienteTelefono: z.string().min(7).optional(),
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import {
|
|||||||
cambiarEtapaOportunidadEndpoint,
|
cambiarEtapaOportunidadEndpoint,
|
||||||
perderOportunidadEndpoint,
|
perderOportunidadEndpoint,
|
||||||
} from '../controllers/oportunidades.controller.js'
|
} from '../controllers/oportunidades.controller.js'
|
||||||
import { cambiarEstadoApartadoEndpoint, listarDocumentosApartado, subirDocumentosApartado, uploadDocumentosApartado } from '../controllers/apartados.controller.js'
|
import { cambiarEstadoApartadoEndpoint, listarDocumentosApartado, subirDocumentosApartado, descargarDocumentoApartado, 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,
|
||||||
@@ -214,6 +214,8 @@ apiRouter.post('/apartados/:itemId/estado', cambiarEstadoApartadoEndpoint)
|
|||||||
// el cliente ligado al apartado (se crea la ficha mínima si aún no existe)
|
// el cliente ligado al apartado (se crea la ficha mínima si aún no existe)
|
||||||
apiRouter.get('/apartados/:itemId/documentos', listarDocumentosApartado)
|
apiRouter.get('/apartados/:itemId/documentos', listarDocumentosApartado)
|
||||||
apiRouter.post('/apartados/:itemId/documentos', uploadDocumentosApartado.array('archivos', 10), subirDocumentosApartado)
|
apiRouter.post('/apartados/:itemId/documentos', uploadDocumentosApartado.array('archivos', 10), subirDocumentosApartado)
|
||||||
|
// Archivo de un documento (previsualizar / descargar desde "Mis apartados")
|
||||||
|
apiRouter.get('/apartados/:itemId/documentos/:docId/archivo', descargarDocumentoApartado)
|
||||||
// Notas del apartado
|
// Notas del apartado
|
||||||
apiRouter.post('/apartados/:id/notas', crearNotaEndpoint)
|
apiRouter.post('/apartados/:id/notas', crearNotaEndpoint)
|
||||||
apiRouter.get('/apartados/:id/notas', listarNotasEndpoint)
|
apiRouter.get('/apartados/:id/notas', listarNotasEndpoint)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const generalesValidos: DatosGenerales = {
|
|||||||
tipoVivienda: 'PROPIA',
|
tipoVivienda: 'PROPIA',
|
||||||
tipoCompra: 'VIVIR_AHI',
|
tipoCompra: 'VIVIR_AHI',
|
||||||
actividadOcupacion: 'AGENTE DEL MINISTERIO PUBLICO FEDERAL',
|
actividadOcupacion: 'AGENTE DEL MINISTERIO PUBLICO FEDERAL',
|
||||||
|
usuarioEnkontrol: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('regex de identificadores', () => {
|
describe('regex de identificadores', () => {
|
||||||
@@ -128,6 +129,16 @@ describe('coacreditadoSchema', () => {
|
|||||||
expect(r.success).toBe(true)
|
expect(r.success).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('acepta datos ocultos a medias cuando el toggle está apagado', () => {
|
||||||
|
const conBasura = {
|
||||||
|
...base,
|
||||||
|
tieneCoacreditado: false as const,
|
||||||
|
generales: { ...base.generales, fecha: '', email: 'no-es-correo', codigoPostal: 'abc' },
|
||||||
|
}
|
||||||
|
const r = coacreditadoSchema.safeParse(conBasura)
|
||||||
|
expect(r.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('exige el nombre del co-acreditado cuando el toggle está activo', () => {
|
it('exige el nombre del co-acreditado cuando el toggle está activo', () => {
|
||||||
const r = coacreditadoSchema.safeParse({ ...base, tieneCoacreditado: true })
|
const r = coacreditadoSchema.safeParse({ ...base, tieneCoacreditado: true })
|
||||||
expect(r.success).toBe(false)
|
expect(r.success).toBe(false)
|
||||||
@@ -141,6 +152,19 @@ describe('coacreditadoSchema', () => {
|
|||||||
})
|
})
|
||||||
expect(r.success).toBe(true)
|
expect(r.success).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('no exige usuarioEnkontrol del co-acreditado (no se captura en el bloque compacto)', () => {
|
||||||
|
const sinUsuarioEnkontrol = JSON.parse(
|
||||||
|
JSON.stringify({
|
||||||
|
...base,
|
||||||
|
tieneCoacreditado: true,
|
||||||
|
generales: { ...base.generales, nombreCompleto: 'MARIA LOPEZ PEREZ' },
|
||||||
|
}),
|
||||||
|
) as typeof base & { tieneCoacreditado: true }
|
||||||
|
delete (sinUsuarioEnkontrol.generales as Record<string, unknown>).usuarioEnkontrol
|
||||||
|
const r = coacreditadoSchema.safeParse(sinUsuarioEnkontrol)
|
||||||
|
expect(r.success).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('referenciasSchema', () => {
|
describe('referenciasSchema', () => {
|
||||||
|
|||||||
@@ -112,33 +112,47 @@ export const economicosSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Datos generales del co-acreditado: el nombre se exige vía superRefine solo
|
// Datos generales del co-acreditado: el nombre se exige vía superRefine solo
|
||||||
// cuando el toggle está activo; el resto se valida solo si se captura
|
// cuando el toggle está activo; el resto se valida solo si se captura. La
|
||||||
|
// fecha y usuarioEnkontrol no se capturan en el bloque compacto y no bloquean.
|
||||||
const coacreditadoGeneralesSchema = datosGeneralesSchema.innerType().extend({
|
const coacreditadoGeneralesSchema = datosGeneralesSchema.innerType().extend({
|
||||||
nombreCompleto: z.string().trim().max(200),
|
nombreCompleto: z.string().trim().max(200),
|
||||||
curp: curpOpcional,
|
curp: curpOpcional,
|
||||||
rfc: rfcOpcional,
|
rfc: rfcOpcional,
|
||||||
email: emailOpcional,
|
email: emailOpcional,
|
||||||
|
fecha: textoOpcional,
|
||||||
fechaNacimiento: textoOpcional,
|
fechaNacimiento: textoOpcional,
|
||||||
celular: textoOpcional,
|
celular: textoOpcional,
|
||||||
|
usuarioEnkontrol: textoOpcional.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const coacreditadoSchema = z
|
export const coacreditadoSchema = z.union([
|
||||||
.object({
|
// Checkbox "El cliente no tiene co-acreditado" MARCADO: los bloques están
|
||||||
tieneCoacreditado: z.boolean(),
|
// ocultos y pueden quedar a medias (o faltar); NO se valida nada.
|
||||||
generales: coacreditadoGeneralesSchema,
|
z.object({
|
||||||
empleo: empleoSchema,
|
tieneCoacreditado: z.literal(false),
|
||||||
economicos: economicosSchema,
|
generales: z.any(),
|
||||||
})
|
empleo: z.any(),
|
||||||
.superRefine((datos, ctx) => {
|
economicos: z.any(),
|
||||||
// Los datos del co-acreditado solo se exigen si el toggle está activo
|
}),
|
||||||
if (datos.tieneCoacreditado && datos.generales.nombreCompleto.trim().length < 3) {
|
// Checkbox SIN marcar (hay co-acreditado): se validan los bloques; el único
|
||||||
ctx.addIssue({
|
// campo obligatorio es el nombre (el resto es opcional).
|
||||||
code: z.ZodIssueCode.custom,
|
z
|
||||||
path: ['generales', 'nombreCompleto'],
|
.object({
|
||||||
message: 'El nombre del co-acreditado es requerido',
|
tieneCoacreditado: z.literal(true),
|
||||||
})
|
generales: coacreditadoGeneralesSchema,
|
||||||
}
|
empleo: empleoSchema,
|
||||||
})
|
economicos: economicosSchema,
|
||||||
|
})
|
||||||
|
.superRefine((datos, ctx) => {
|
||||||
|
if (datos.generales.nombreCompleto.trim().length < 3) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['generales', 'nombreCompleto'],
|
||||||
|
message: 'El nombre del co-acreditado es requerido',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
const referenciaSchema = z.object({
|
const referenciaSchema = z.object({
|
||||||
nombre: textoOpcional,
|
nombre: textoOpcional,
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ import { useMemo, useState } from 'react'
|
|||||||
import type { Unidad, DescuentosPropuestas, ParametrosDesarrollo } from '../types'
|
import type { Unidad, DescuentosPropuestas, ParametrosDesarrollo } from '../types'
|
||||||
import {
|
import {
|
||||||
APARTADO_DEFAULT,
|
APARTADO_DEFAULT,
|
||||||
APARTADO_MINIMO,
|
|
||||||
calcularPropuestas,
|
calcularPropuestas,
|
||||||
obtenerPrecioLista,
|
obtenerPrecioLista,
|
||||||
resolverNumMensualidades,
|
resolverNumMensualidades,
|
||||||
validarApartado,
|
|
||||||
validarEnganchePct,
|
validarEnganchePct,
|
||||||
type PropuestaResultado,
|
type PropuestaResultado,
|
||||||
} from '../lib/calculations'
|
} from '../lib/calculations'
|
||||||
@@ -23,10 +21,8 @@ interface UseCotizacionResult {
|
|||||||
setAcabados: (v: boolean) => void
|
setAcabados: (v: boolean) => void
|
||||||
apartado: number
|
apartado: number
|
||||||
setApartado: (v: number) => void
|
setApartado: (v: number) => void
|
||||||
/** Apartado aplicado a los cálculos (acotado al mínimo cuando la captura es inválida) */
|
/** Apartado aplicado a los cálculos (acotado a >= 0; el vendedor puede apartar con cualquier monto, incluso $0) */
|
||||||
apartadoEfectivo: number
|
apartadoEfectivo: number
|
||||||
/** Mensaje de error de la captura del apartado; null si es válida */
|
|
||||||
errorApartado: string | null
|
|
||||||
/** Enganche capturado en PORCENTAJE (10 = 10%); null = % del Excel de cada propuesta */
|
/** Enganche capturado en PORCENTAJE (10 = 10%); null = % del Excel de cada propuesta */
|
||||||
enganchePct: number | null
|
enganchePct: number | null
|
||||||
setEnganchePct: (v: number | null) => void
|
setEnganchePct: (v: number | null) => void
|
||||||
@@ -125,9 +121,9 @@ export function useCotizacion(): UseCotizacionResult {
|
|||||||
[unidad, acabados],
|
[unidad, acabados],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Capturas por debajo del mínimo no se usan: los cálculos se acotan
|
// Sin mínimo de apartado: el vendedor puede apartar con cualquier monto,
|
||||||
const errorApartado = validarApartado(apartado)
|
// incluso $0; solo se acota a >= 0 como guarda contra capturas raras
|
||||||
const apartadoEfectivo = Math.max(apartado, APARTADO_MINIMO)
|
const apartadoEfectivo = Math.max(0, apartado)
|
||||||
|
|
||||||
// Enganche personalizado: null = default del Excel; inválido no entra al cálculo
|
// Enganche personalizado: null = default del Excel; inválido no entra al cálculo
|
||||||
const errorEnganche = validarEnganchePct(enganchePct)
|
const errorEnganche = validarEnganchePct(enganchePct)
|
||||||
@@ -149,7 +145,6 @@ export function useCotizacion(): UseCotizacionResult {
|
|||||||
apartado,
|
apartado,
|
||||||
setApartado,
|
setApartado,
|
||||||
apartadoEfectivo,
|
apartadoEfectivo,
|
||||||
errorApartado,
|
|
||||||
enganchePct,
|
enganchePct,
|
||||||
setEnganchePct,
|
setEnganchePct,
|
||||||
errorEnganche,
|
errorEnganche,
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import {
|
|||||||
obtenerPrecioLista,
|
obtenerPrecioLista,
|
||||||
totalPagado,
|
totalPagado,
|
||||||
indiceMenorTotal,
|
indiceMenorTotal,
|
||||||
validarApartado,
|
|
||||||
validarEnganchePct,
|
validarEnganchePct,
|
||||||
APARTADO_DEFAULT,
|
APARTADO_DEFAULT,
|
||||||
APARTADO_MINIMO,
|
|
||||||
} from './calculations'
|
} from './calculations'
|
||||||
import { UNIDADES_SEED } from '../data/unidades'
|
import { UNIDADES_SEED } from '../data/unidades'
|
||||||
import type { Unidad } from '../types'
|
import type { Unidad } from '../types'
|
||||||
@@ -124,19 +122,12 @@ describe('apartado editable', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('validarApartado', () => {
|
describe('apartado sin mínimo', () => {
|
||||||
it('acepta el mínimo y montos superiores', () => {
|
it('acepta cualquier monto, incluso $0', () => {
|
||||||
expect(validarApartado(APARTADO_MINIMO)).toBeNull()
|
const conCero = calcular({ creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 }, 0)
|
||||||
expect(validarApartado(50000)).toBeNull()
|
expect(conCero.length).toBeGreaterThan(0)
|
||||||
})
|
// El enganche ya no se descuenta del apartado (no hay apartado que descontar)
|
||||||
|
expect(conCero[0].enganche).toBeCloseTo(0.025 * 4579000, 2)
|
||||||
it('rechaza montos por debajo del mínimo', () => {
|
|
||||||
expect(validarApartado(19999)).toBe('El apartado mínimo es $20,000')
|
|
||||||
expect(validarApartado(0)).toBe('El apartado mínimo es $20,000')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rechaza valores no finitos', () => {
|
|
||||||
expect(validarApartado(NaN)).toBe('El apartado mínimo es $20,000')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ import { DESARROLLO_DEFAULT, obtenerDesarrollo } from '../data/desarrollos'
|
|||||||
|
|
||||||
export const APARTADO_DEFAULT = 20000
|
export const APARTADO_DEFAULT = 20000
|
||||||
|
|
||||||
/** Monto mínimo de apartado aceptado por el cotizador */
|
|
||||||
export const APARTADO_MINIMO = 20000
|
|
||||||
|
|
||||||
/** Enganche mínimo seleccionable: 2.5% del valor de la propiedad (fracción) */
|
/** Enganche mínimo seleccionable: 2.5% del valor de la propiedad (fracción) */
|
||||||
export const ENGANCHE_MINIMO_PCT = 0.025
|
export const ENGANCHE_MINIMO_PCT = 0.025
|
||||||
|
|
||||||
@@ -48,17 +45,6 @@ export function aplicaEnganchePersonalizado(cfg: PropuestaConfig): boolean {
|
|||||||
return cfg.modoMensualidades !== 'ninguna' && cfg.pctEnganche > 0 && cfg.pctEnganche <= 0.05
|
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.
|
|
||||||
* La UI acota el valor al mínimo para los cálculos y muestra este mensaje.
|
|
||||||
*/
|
|
||||||
export function validarApartado(apartado: number): string | null {
|
|
||||||
if (!Number.isFinite(apartado) || apartado < APARTADO_MINIMO) {
|
|
||||||
return 'El apartado mínimo es $20,000'
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Descuentos por defecto de DIAGONAL (fracción, no porcentaje).
|
// Descuentos por defecto de DIAGONAL (fracción, no porcentaje).
|
||||||
// Para otros desarrollos usar descuentosDefault(desarrollo) de data/desarrollos.
|
// Para otros desarrollos usar descuentosDefault(desarrollo) de data/desarrollos.
|
||||||
export const DESCUENTOS_DEFAULT: DescuentosPropuestas = {
|
export const DESCUENTOS_DEFAULT: DescuentosPropuestas = {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface PdfInput {
|
|||||||
acabados: boolean
|
acabados: boolean
|
||||||
apartado: number
|
apartado: number
|
||||||
precioLista: number
|
precioLista: number
|
||||||
|
/** Propuestas a imprimir; desde la app llega SOLO la propuesta elegida */
|
||||||
propuestas: PropuestaResultado[]
|
propuestas: PropuestaResultado[]
|
||||||
cliente: Cliente
|
cliente: Cliente
|
||||||
/** Parámetros del desarrollo; por defecto DIAGONAL */
|
/** Parámetros del desarrollo; por defecto DIAGONAL */
|
||||||
@@ -143,12 +144,18 @@ export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, pro
|
|||||||
}
|
}
|
||||||
: { pagos: 'N/A', total: 'N/A' }
|
: { pagos: 'N/A', total: 'N/A' }
|
||||||
|
|
||||||
|
// Con una sola propuesta (la elegida por el vendedor) no se numera
|
||||||
|
const encabezado =
|
||||||
|
propuestas.length === 1
|
||||||
|
? `PROPUESTA COMERCIAL — ${p.titulo.toUpperCase()}`
|
||||||
|
: `PROPUESTA COMERCIAL #${i + 1} — ${p.titulo.toUpperCase()}`
|
||||||
|
|
||||||
autoTable(doc, {
|
autoTable(doc, {
|
||||||
startY: y,
|
startY: y,
|
||||||
margin: { left: MARGEN, right: MARGEN },
|
margin: { left: MARGEN, right: MARGEN },
|
||||||
styles: { fontSize: 8 },
|
styles: { fontSize: 8 },
|
||||||
headStyles: { fillColor: [37, 99, 235] },
|
headStyles: { fillColor: [37, 99, 235] },
|
||||||
head: [[`PROPUESTA COMERCIAL #${i + 1} — ${p.titulo.toUpperCase()}`, '%', 'PAGOS MENSUALES', 'TOTAL']],
|
head: [[encabezado, '%', 'PAGOS MENSUALES', 'TOTAL']],
|
||||||
body: [
|
body: [
|
||||||
['DESCUENTO', formatoPorcentaje(p.descuento), '', ''],
|
['DESCUENTO', formatoPorcentaje(p.descuento), '', ''],
|
||||||
['MONTO DE DESCUENTO', '', '', formatoMoneda(p.montoDescuento)],
|
['MONTO DE DESCUENTO', '', '', formatoMoneda(p.montoDescuento)],
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export default function CotizadorPage() {
|
|||||||
apartado,
|
apartado,
|
||||||
setApartado,
|
setApartado,
|
||||||
apartadoEfectivo,
|
apartadoEfectivo,
|
||||||
errorApartado,
|
|
||||||
enganchePct,
|
enganchePct,
|
||||||
setEnganchePct,
|
setEnganchePct,
|
||||||
errorEnganche,
|
errorEnganche,
|
||||||
@@ -181,7 +180,7 @@ export default function CotizadorPage() {
|
|||||||
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
|
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
|
// Total pagado por el cliente con la propuesta elegida; null si no se puede conocer
|
||||||
const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null
|
const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null
|
||||||
const puedeGenerarPdf = Boolean(unidad) && !errorApartado && !errorEnganche && !guardando
|
const puedeGenerarPdf = Boolean(unidad) && !errorEnganche && !guardando
|
||||||
|
|
||||||
// ---------- Preselección por defecto: la propuesta de menor total pagado ----------
|
// ---------- Preselección por defecto: la propuesta de menor total pagado ----------
|
||||||
// Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta
|
// Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta
|
||||||
@@ -290,7 +289,8 @@ export default function CotizadorPage() {
|
|||||||
acabados,
|
acabados,
|
||||||
apartado: apartadoEfectivo,
|
apartado: apartadoEfectivo,
|
||||||
precioLista,
|
precioLista,
|
||||||
propuestas,
|
// El PDF imprime SOLO la propuesta elegida por el vendedor
|
||||||
|
propuestas: propuestaActiva ? [propuestaActiva] : [],
|
||||||
cliente,
|
cliente,
|
||||||
desarrollo: params,
|
desarrollo: params,
|
||||||
...(folio ? { folio } : {}),
|
...(folio ? { folio } : {}),
|
||||||
@@ -530,20 +530,15 @@ export default function CotizadorPage() {
|
|||||||
<input
|
<input
|
||||||
id="apartado"
|
id="apartado"
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
|
||||||
step={1000}
|
step={1000}
|
||||||
value={apartado}
|
value={apartado}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = Number(e.target.value)
|
const v = Number(e.target.value)
|
||||||
setApartado(Number.isFinite(v) && v >= 0 ? v : 0)
|
setApartado(Number.isFinite(v) && v >= 0 ? v : 0)
|
||||||
}}
|
}}
|
||||||
className={errorApartado ? campoInputError : campoInput}
|
className={campoInput}
|
||||||
/>
|
/>
|
||||||
{errorApartado ? (
|
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">{formatoMoneda(apartado)}</p>
|
||||||
<p className="mt-1 text-xs text-red-700">{errorApartado}</p>
|
|
||||||
) : (
|
|
||||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">{formatoMoneda(apartado)}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
SLOTS_DOCS_APARTADO,
|
SLOTS_DOCS_APARTADO,
|
||||||
componerTelefono,
|
componerTelefono,
|
||||||
LADAS,
|
LADAS,
|
||||||
|
SUBCATEGORIAS_NO_COMPRA,
|
||||||
textoDiasRestantes,
|
textoDiasRestantes,
|
||||||
urlAfluenciaParaOportunidad,
|
urlAfluenciaParaOportunidad,
|
||||||
urlCotizadorParaOportunidad,
|
urlCotizadorParaOportunidad,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
type ClaveLada,
|
type ClaveLada,
|
||||||
type FaseApartado,
|
type FaseApartado,
|
||||||
type LeadMiDia,
|
type LeadMiDia,
|
||||||
|
type MotivoNoCompra,
|
||||||
type OportunidadMiDia,
|
type OportunidadMiDia,
|
||||||
type ResumenMiDia,
|
type ResumenMiDia,
|
||||||
type SlotDocApartado,
|
type SlotDocApartado,
|
||||||
@@ -40,6 +42,7 @@ import {
|
|||||||
convertirLead,
|
convertirLead,
|
||||||
crearLead,
|
crearLead,
|
||||||
descartarLead,
|
descartarLead,
|
||||||
|
fetchArchivoDocumentoApartado,
|
||||||
fetchResumenMiDia,
|
fetchResumenMiDia,
|
||||||
listarDocumentosApartado,
|
listarDocumentosApartado,
|
||||||
subirDocumentoApartado,
|
subirDocumentoApartado,
|
||||||
@@ -140,7 +143,7 @@ function EnlacesContacto({ nombre, telefono }: { nombre: string; telefono: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Fila de oportunidad con botones de fase del funnel (Agendo Cita lleva fecha) */
|
/** Fila de oportunidad: acciones en orden (Agendo Cita lleva fecha opcional) */
|
||||||
function FilaOportunidad({
|
function FilaOportunidad({
|
||||||
op,
|
op,
|
||||||
onEtapa,
|
onEtapa,
|
||||||
@@ -162,7 +165,6 @@ function FilaOportunidad({
|
|||||||
}) {
|
}) {
|
||||||
const [conCita, setConCita] = useState(!!op.fechaCita)
|
const [conCita, setConCita] = useState(!!op.fechaCita)
|
||||||
const [fechaCita, setFechaCita] = useState(op.fechaCita ?? '')
|
const [fechaCita, setFechaCita] = useState(op.fechaCita ?? '')
|
||||||
const fases = ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto'] as const
|
|
||||||
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">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
@@ -200,16 +202,25 @@ function FilaOportunidad({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
{/* Cotizar: cambia la etapa y abre el cotizador con los datos prellenados */}
|
{/* 1. Confirmar cita (etapa Agendo Cita): al primer toque pide la fecha */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={enviando}
|
disabled={enviando}
|
||||||
onClick={onCotizar}
|
onClick={() => (conCita ? onEtapa('Agendo Cita', fechaCita || undefined) : setConCita(true))}
|
||||||
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
|
className={CLASE_BOTON_ACCION}
|
||||||
>
|
>
|
||||||
Cotizar →
|
{conCita ? 'Confirmar cita' : ETIQUETA_FASE_OPORTUNIDAD['Agendo Cita']}
|
||||||
</button>
|
</button>
|
||||||
{/* Afluencia: abre el formulario de alta afluencia prellenado */}
|
{conCita && (
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
aria-label="Fecha y hora de la cita"
|
||||||
|
value={fechaCita}
|
||||||
|
onChange={(e) => setFechaCita(e.target.value)}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-2 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* 2. Afluencia: abre el formulario de alta afluencia prellenado */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={enviando}
|
disabled={enviando}
|
||||||
@@ -218,39 +229,25 @@ function FilaOportunidad({
|
|||||||
>
|
>
|
||||||
Afluencia →
|
Afluencia →
|
||||||
</button>
|
</button>
|
||||||
{fases.map((f) =>
|
{/* 3. Cotizar: cambia la etapa y abre el cotizador con los datos prellenados */}
|
||||||
f === 'Agendo Cita' ? (
|
<button
|
||||||
<span key={f} className="contents">
|
type="button"
|
||||||
<button
|
disabled={enviando}
|
||||||
type="button"
|
onClick={onCotizar}
|
||||||
disabled={enviando}
|
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
|
||||||
onClick={() => (conCita ? onEtapa(f, fechaCita || undefined) : setConCita(true))}
|
>
|
||||||
className={CLASE_BOTON_ACCION}
|
Cotizar →
|
||||||
>
|
</button>
|
||||||
{conCita ? 'Confirmar cita' : ETIQUETA_FASE_OPORTUNIDAD[f]}
|
{/* 4. Aparto: marca la etapa (verde) */}
|
||||||
</button>
|
<button
|
||||||
{conCita && (
|
type="button"
|
||||||
<input
|
disabled={enviando}
|
||||||
type="datetime-local"
|
onClick={() => onEtapa('Aparto')}
|
||||||
aria-label="Fecha y hora de la cita"
|
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-green-700 disabled:opacity-50"
|
||||||
value={fechaCita}
|
>
|
||||||
onChange={(e) => setFechaCita(e.target.value)}
|
Aparto
|
||||||
className="rounded-lg border border-[#D1D5DB] bg-white px-2 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
</button>
|
||||||
/>
|
{/* 5. Descartar */}
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
key={f}
|
|
||||||
type="button"
|
|
||||||
disabled={enviando}
|
|
||||||
onClick={() => onEtapa(f)}
|
|
||||||
className={CLASE_BOTON_ACCION}
|
|
||||||
>
|
|
||||||
{ETIQUETA_FASE_OPORTUNIDAD[f]}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={enviando || (acciones !== undefined && !acciones.includes('descartar_lead'))}
|
disabled={enviando || (acciones !== undefined && !acciones.includes('descartar_lead'))}
|
||||||
@@ -279,8 +276,11 @@ function ModalDescarteGenerico({
|
|||||||
enviando: boolean
|
enviando: boolean
|
||||||
}) {
|
}) {
|
||||||
const [motivo, setMotivo] = useState<string>('Timing')
|
const [motivo, setMotivo] = useState<string>('Timing')
|
||||||
const [subcategoria] = useState('')
|
const [subcategoria, setSubcategoria] = useState('')
|
||||||
const [comentario, setComentario] = useState('')
|
const [comentario, setComentario] = useState('')
|
||||||
|
// Subcategorías del catálogo para el motivo elegido; se limpia al cambiar de motivo
|
||||||
|
const subcategorias =
|
||||||
|
motivo in SUBCATEGORIAS_NO_COMPRA ? SUBCATEGORIAS_NO_COMPRA[motivo as MotivoNoCompra] : []
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-10 flex items-center justify-center bg-black/40 px-4" role="dialog" aria-modal="true">
|
<div className="fixed inset-0 z-10 flex items-center justify-center bg-black/40 px-4" role="dialog" aria-modal="true">
|
||||||
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
|
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
|
||||||
@@ -298,7 +298,10 @@ function ModalDescarteGenerico({
|
|||||||
<select
|
<select
|
||||||
id="motivo-cancel"
|
id="motivo-cancel"
|
||||||
value={motivo}
|
value={motivo}
|
||||||
onChange={(e) => setMotivo(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setMotivo(e.target.value)
|
||||||
|
setSubcategoria('')
|
||||||
|
}}
|
||||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm"
|
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm"
|
||||||
>
|
>
|
||||||
{MOTIVOS_NO_COMPRA.map((m) => (
|
{MOTIVOS_NO_COMPRA.map((m) => (
|
||||||
@@ -306,6 +309,20 @@ function ModalDescarteGenerico({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="subcategoria-cancel" className="block mb-2 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Subcategoría</label>
|
||||||
|
<select
|
||||||
|
id="subcategoria-cancel"
|
||||||
|
value={subcategoria}
|
||||||
|
onChange={(e) => setSubcategoria(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Sin especificar</option>
|
||||||
|
{subcategorias.map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="comentario-cancel" className="block mb-2 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Comentario (opcional)</label>
|
<label htmlFor="comentario-cancel" className="block mb-2 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Comentario (opcional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -429,6 +446,7 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
|
|||||||
const [documentos, setDocumentos] = useState<DocumentoApartado[] | null>(null)
|
const [documentos, setDocumentos] = useState<DocumentoApartado[] | null>(null)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [subiendo, setSubiendo] = useState<SlotDocApartado | null>(null)
|
const [subiendo, setSubiendo] = useState<SlotDocApartado | null>(null)
|
||||||
|
const [obteniendo, setObteniendo] = useState<string | null>(null)
|
||||||
|
|
||||||
const cargar = useCallback(async () => {
|
const cargar = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -457,6 +475,49 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baja el archivo autenticado y opera sobre su blob URL. Para previsualizar,
|
||||||
|
* la pestaña se abre DENTRO del gesto del usuario (después del await el
|
||||||
|
* navegador bloquearía el window.open) y luego se navega al blob.
|
||||||
|
*/
|
||||||
|
const conArchivo = async (doc: DocumentoApartado, modo: 'previsualizar' | 'descargar') => {
|
||||||
|
setObteniendo(doc.id)
|
||||||
|
setError('')
|
||||||
|
const pestaña = modo === 'previsualizar' ? window.open('', '_blank') : null
|
||||||
|
try {
|
||||||
|
const blob = await fetchArchivoDocumentoApartado(itemId, doc.id)
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
if (pestaña) {
|
||||||
|
// Previsualizar: navega la pestaña ya abierta al archivo (PDF/imagen)
|
||||||
|
pestaña.location.href = url
|
||||||
|
} else {
|
||||||
|
// Descargar: link con atributo download (nombre original del archivo)
|
||||||
|
const enlace = document.createElement('a')
|
||||||
|
enlace.href = url
|
||||||
|
if (modo === 'descargar') enlace.download = doc.nombreArchivo
|
||||||
|
else enlace.target = '_blank'
|
||||||
|
enlace.rel = 'noopener'
|
||||||
|
document.body.appendChild(enlace)
|
||||||
|
enlace.click()
|
||||||
|
enlace.remove()
|
||||||
|
}
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||||
|
} catch (err) {
|
||||||
|
pestaña?.close()
|
||||||
|
setError(err instanceof Error ? err.message : 'No se pudo obtener el archivo.')
|
||||||
|
} finally {
|
||||||
|
setObteniendo(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const previsualizar = (doc: DocumentoApartado) => {
|
||||||
|
void conArchivo(doc, 'previsualizar')
|
||||||
|
}
|
||||||
|
|
||||||
|
const descargar = (doc: DocumentoApartado) => {
|
||||||
|
void conArchivo(doc, 'descargar')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-lg border border-dashed border-[#D1D5DB] bg-[#F8FAFC] dark:border-[#334155] dark:bg-[#0F172A] p-4">
|
<div className="mt-3 rounded-lg border border-dashed border-[#D1D5DB] bg-[#F8FAFC] dark:border-[#334155] dark:bg-[#0F172A] p-4">
|
||||||
<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]">
|
||||||
@@ -503,7 +564,29 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
|
|||||||
<ul className="mt-1 space-y-0.5">
|
<ul className="mt-1 space-y-0.5">
|
||||||
{delSlot.map((d) => (
|
{delSlot.map((d) => (
|
||||||
<li key={d.id} className="text-[11px] text-[#64748B] dark:text-[#94A3B8]">
|
<li key={d.id} className="text-[11px] text-[#64748B] dark:text-[#94A3B8]">
|
||||||
{d.nombreArchivo} · {formatoFechaCorta(d.createdAt.slice(0, 10))}
|
<div className="flex flex-wrap items-center justify-between gap-1">
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{d.nombreArchivo} · {formatoFechaCorta(d.createdAt.slice(0, 10))}
|
||||||
|
</span>
|
||||||
|
<span className="flex shrink-0 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={obteniendo !== null}
|
||||||
|
onClick={() => previsualizar(d)}
|
||||||
|
className="font-medium text-accent hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Previsualizar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={obteniendo !== null}
|
||||||
|
onClick={() => descargar(d)}
|
||||||
|
className="font-medium text-accent hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{obteniendo === d.id ? 'Descargando…' : 'Descargar'}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -121,6 +121,21 @@ export function listarDocumentosApartado(itemId: string): Promise<DocumentoApart
|
|||||||
return fetchJson<DocumentoApartado[]>(`/apartados/${itemId}/documentos`)
|
return fetchJson<DocumentoApartado[]>(`/apartados/${itemId}/documentos`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /apartados/:itemId/documentos/:docId/archivo — el archivo del documento
|
||||||
|
* como blob. Va por fetch autenticado porque window.open / <a download> no
|
||||||
|
* pueden mandar el header Authorization.
|
||||||
|
*/
|
||||||
|
export async function fetchArchivoDocumentoApartado(itemId: string, docId: string): Promise<Blob> {
|
||||||
|
const res = await fetch(`${API_URL}/apartados/${itemId}/documentos/${docId}/archivo`, conAuth({ cache: 'no-store' }))
|
||||||
|
manejar401(res)
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = (await res.json().catch(() => null)) as { error?: string } | null
|
||||||
|
throw new Error(body?.error ?? `Error ${res.status}`)
|
||||||
|
}
|
||||||
|
return res.blob()
|
||||||
|
}
|
||||||
|
|
||||||
/** POST /apartados/:itemId/documentos — multipart; el archivo se guarda en el cliente */
|
/** POST /apartados/:itemId/documentos — multipart; el archivo se guarda en el cliente */
|
||||||
export async function subirDocumentoApartado(itemId: string, tipo: string, archivo: File): Promise<void> {
|
export async function subirDocumentoApartado(itemId: string, tipo: string, archivo: File): Promise<void> {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
|
|||||||
Reference in New Issue
Block a user