feat(8-mejoras): cancelar+motivo, brocker, etapas, calendario, modal cliente
1. Cancelar con motivo: Mis apartados y Escrituración piden motivo (modal reutilizado) 2. Origen BROCKER agregado en alta afluencia y captura rápida 3. Etapas oportunidades reordenadas: Agendo Cita → Afluencia → Cotizador 4. Cotizador: mensualidades diferibles (6, 12, 14, 18, 24 meses) 5. Calendario semanal por vendedor en Mi día 6. Hora en citas (datetime-local en vez de solo fecha) 7. Click en nombre → modal DetalleLeadModal (info + notas + historial)
This commit is contained in:
@@ -95,7 +95,7 @@ export const TAMANO_MAX_DOCUMENTO = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
// Catálogos de Monday (labels exactos). Origen: vocabulario FRASA sembrado en
|
||||
// los tableros Leads y Oportunidades el 2026-09-04 (reemplaza al set anterior)
|
||||
export const ORIGENES_LEAD = ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'OTRO'] as const
|
||||
export const ORIGENES_LEAD = ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'] as const
|
||||
export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const
|
||||
|
||||
// Label de la columna status 'Califica' del tablero Leads (captura de afluencia).
|
||||
|
||||
@@ -38,6 +38,10 @@ interface UseCotizacionResult {
|
||||
propuestaIndex: number
|
||||
setPropuestaIndex: (i: number) => void
|
||||
precioLista: number
|
||||
/** Número de mensualidades elegido por el vendedor; null = usa el calculado */
|
||||
numMensualidades: number | null
|
||||
setNumMensualidades: (n: number | null) => void
|
||||
/** Número efectivo de mensualidades (la elegida o la calculada) */
|
||||
numMeses: number | null
|
||||
propuestas: PropuestaResultado[]
|
||||
/** Restaura el estado completo desde una cotización guardada (historial) */
|
||||
@@ -66,6 +70,7 @@ export function useCotizacion(): UseCotizacionResult {
|
||||
const [enganchePct, setEnganchePct] = useState<number | null>(null)
|
||||
const [descuentos, setDescuentos] = useState<DescuentosPropuestas>(descuentosDefault(DESARROLLO_DEFAULT))
|
||||
const [propuestaIndex, setPropuestaIndex] = useState(0)
|
||||
const [numMensualidades, setNumMensualidades] = useState<number | null>(null)
|
||||
|
||||
const params = obtenerDesarrollo(desarrollo)
|
||||
|
||||
@@ -75,6 +80,7 @@ export function useCotizacion(): UseCotizacionResult {
|
||||
setAcabados(false)
|
||||
setDescuentos(descuentosDefault(id))
|
||||
setPropuestaIndex(0)
|
||||
setNumMensualidades(null)
|
||||
}
|
||||
|
||||
const setDescuento = (clave: keyof DescuentosPropuestas, valor: number) => {
|
||||
@@ -103,13 +109,17 @@ export function useCotizacion(): UseCotizacionResult {
|
||||
setEnganchePct(null)
|
||||
setDescuentos(descuentosDefault(desarrollo))
|
||||
setPropuestaIndex(0)
|
||||
setNumMensualidades(null)
|
||||
}
|
||||
|
||||
const numMeses = useMemo(
|
||||
const numMesesCalculado = useMemo(
|
||||
() => (unidad ? resolverNumMensualidades(unidad, params) : null),
|
||||
[unidad, params],
|
||||
)
|
||||
|
||||
/** Número efectivo de mensualidades: la elegida por el vendedor, o la calculada */
|
||||
const numMeses = numMensualidades ?? numMesesCalculado
|
||||
|
||||
const precioLista = useMemo(
|
||||
() => (unidad ? obtenerPrecioLista(unidad, acabados) : 0),
|
||||
[unidad, acabados],
|
||||
@@ -125,8 +135,8 @@ export function useCotizacion(): UseCotizacionResult {
|
||||
|
||||
const propuestas = useMemo(() => {
|
||||
if (!unidad) return []
|
||||
return calcularPropuestas({ unidad, acabados, apartado: apartadoEfectivo, descuentos, desarrollo, enganchePct: engancheEfectivo })
|
||||
}, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo, engancheEfectivo])
|
||||
return calcularPropuestas({ unidad, acabados, apartado: apartadoEfectivo, descuentos, desarrollo, enganchePct: engancheEfectivo, numMeses: numMeses ?? undefined })
|
||||
}, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo, engancheEfectivo, numMeses])
|
||||
|
||||
return {
|
||||
desarrollo,
|
||||
@@ -148,6 +158,8 @@ export function useCotizacion(): UseCotizacionResult {
|
||||
propuestaIndex,
|
||||
setPropuestaIndex,
|
||||
precioLista,
|
||||
numMensualidades,
|
||||
setNumMensualidades,
|
||||
numMeses,
|
||||
propuestas,
|
||||
restaurar,
|
||||
|
||||
@@ -71,6 +71,8 @@ export default function CotizadorPage() {
|
||||
propuestaIndex,
|
||||
setPropuestaIndex,
|
||||
precioLista,
|
||||
numMensualidades,
|
||||
setNumMensualidades,
|
||||
numMeses,
|
||||
propuestas,
|
||||
restaurar,
|
||||
@@ -465,6 +467,33 @@ export default function CotizadorPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="mensualidades" className={etiquetaCampo}>
|
||||
Mensualidades
|
||||
</label>
|
||||
<select
|
||||
id="mensualidades"
|
||||
value={numMensualidades ?? ''}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setNumMensualidades(v === '' ? null : Number(v))
|
||||
}}
|
||||
className={campoInput}
|
||||
>
|
||||
<option value="">Calcular automáticamente</option>
|
||||
{[6, 12, 14, 18, 24].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n} mensualidades
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{numMeses !== null && (
|
||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
Se usarán {numMeses} mensualidades para el cálculo
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BuscadorCliente
|
||||
onSeleccion={(lead) => {
|
||||
setValue('nombre', lead.nombre, { shouldValidate: true })
|
||||
|
||||
284
apps/mi-dia/src/components/CalendarioCitas.tsx
Normal file
284
apps/mi-dia/src/components/CalendarioCitas.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ActividadMiDia, OportunidadMiDia } from '../lib/mi-dia'
|
||||
import { formatoFechaCorta } from '../lib/mi-dia'
|
||||
|
||||
/** Evento unificado para el calendario */
|
||||
interface EventoCalendario {
|
||||
id: string
|
||||
nombre: string
|
||||
fecha: string // 'YYYY-MM-DD'
|
||||
hora?: string // 'HH:MM'
|
||||
tipo: 'actividad' | 'oportunidad'
|
||||
detalle?: string
|
||||
estado?: string
|
||||
idOriginal: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
actividades: ActividadMiDia[]
|
||||
oportunidades: OportunidadMiDia[]
|
||||
}
|
||||
|
||||
const NOMBRE_DIAS = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb']
|
||||
const NOMBRE_MESES = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']
|
||||
|
||||
function obtenerSemanaBase(fecha: Date): Date {
|
||||
const d = new Date(fecha)
|
||||
const diaSemana = d.getDay()
|
||||
// Lunes como inicio de semana
|
||||
d.setDate(d.getDate() - ((diaSemana + 6) % 7))
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d
|
||||
}
|
||||
|
||||
function formatoDia(fecha: Date): string {
|
||||
return `${fecha.getDate()}`
|
||||
}
|
||||
|
||||
function esMismaFecha(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
||||
}
|
||||
|
||||
function parsearFechaCita(fechaCita: string): { fecha: string; hora?: string } {
|
||||
if (fechaCita.includes('T')) {
|
||||
const [fecha, horaCompleta] = fechaCita.split('T')
|
||||
const hora = horaCompleta?.slice(0, 5)
|
||||
return { fecha, hora }
|
||||
}
|
||||
return { fecha: fechaCita }
|
||||
}
|
||||
|
||||
export default function CalendarioCitas({ actividades, oportunidades }: Props) {
|
||||
const hoy = useMemo(() => {
|
||||
const d = new Date()
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d
|
||||
}, [])
|
||||
|
||||
const [semanaInicio, setSemanaInicio] = useState(() => obtenerSemanaBase(hoy))
|
||||
|
||||
const eventos = useMemo<EventoCalendario[]>(() => {
|
||||
const lista: EventoCalendario[] = []
|
||||
|
||||
// Agregar actividades
|
||||
for (const a of actividades) {
|
||||
if (a.fecha === '') continue
|
||||
lista.push({
|
||||
id: `act-${a.id}`,
|
||||
nombre: a.nombre,
|
||||
fecha: a.fecha,
|
||||
tipo: 'actividad',
|
||||
detalle: a.tipo,
|
||||
estado: a.estado,
|
||||
idOriginal: a.id,
|
||||
})
|
||||
}
|
||||
|
||||
// Agregar oportunidades con fechaCita
|
||||
for (const op of oportunidades) {
|
||||
if (!op.fechaCita) continue
|
||||
const { fecha, hora } = parsearFechaCita(op.fechaCita)
|
||||
lista.push({
|
||||
id: `op-${op.id}`,
|
||||
nombre: op.nombre,
|
||||
fecha,
|
||||
hora,
|
||||
tipo: 'oportunidad',
|
||||
detalle: op.etapa,
|
||||
estado: op.etapa,
|
||||
idOriginal: op.id,
|
||||
})
|
||||
}
|
||||
|
||||
return lista
|
||||
}, [actividades, oportunidades])
|
||||
|
||||
// Generar los 7 días de la semana
|
||||
const diasSemana = useMemo(() => {
|
||||
return Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new Date(semanaInicio)
|
||||
d.setDate(d.getDate() + i)
|
||||
return d
|
||||
})
|
||||
}, [semanaInicio])
|
||||
|
||||
// Eventos agrupados por día
|
||||
const eventosPorDia = useMemo(() => {
|
||||
const mapa = new Map<string, EventoCalendario[]>()
|
||||
for (const ev of eventos) {
|
||||
const clave = ev.fecha
|
||||
if (!mapa.has(clave)) mapa.set(clave, [])
|
||||
mapa.get(clave)!.push(ev)
|
||||
}
|
||||
return mapa
|
||||
}, [eventos])
|
||||
|
||||
const semanaAnterior = () => {
|
||||
const d = new Date(semanaInicio)
|
||||
d.setDate(d.getDate() - 7)
|
||||
setSemanaInicio(d)
|
||||
}
|
||||
|
||||
const semanaSiguiente = () => {
|
||||
const d = new Date(semanaInicio)
|
||||
d.setDate(d.getDate() + 7)
|
||||
setSemanaInicio(d)
|
||||
}
|
||||
|
||||
const irAHoy = () => {
|
||||
setSemanaInicio(obtenerSemanaBase(hoy))
|
||||
}
|
||||
|
||||
const [eventoSeleccionado, setEventoSeleccionado] = useState<EventoCalendario | null>(null)
|
||||
|
||||
const semanaFin = new Date(semanaInicio)
|
||||
semanaFin.setDate(semanaFin.getDate() + 6)
|
||||
|
||||
const tituloSemana = `${formatoDia(semanaInicio)} ${NOMBRE_MESES[semanaInicio.getMonth()]} – ${formatoDia(semanaFin)} ${NOMBRE_MESES[semanaFin.getMonth()]} ${semanaFin.getFullYear()}`
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
{/* Controles de navegación */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={semanaAnterior}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1 text-xs font-medium text-[#374151] shadow-sm transition-all hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={irAHoy}
|
||||
className="rounded-lg border border-accent bg-accent/10 px-3 py-1 text-xs font-medium text-accent shadow-sm transition-all hover:bg-accent hover:text-white"
|
||||
>
|
||||
Hoy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={semanaSiguiente}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1 text-xs font-medium text-[#374151] shadow-sm transition-all hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
|
||||
{tituloSemana}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Grid del calendario */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{/* Encabezados de días */}
|
||||
{diasSemana.map((dia, idx) => {
|
||||
const esHoy = esMismaFecha(dia, hoy)
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`rounded-t-lg px-2 py-2 text-center ${
|
||||
esHoy
|
||||
? 'bg-accent text-white font-semibold'
|
||||
: 'bg-[#F8FAFC] dark:bg-[#0F172A] text-[#64748B] dark:text-[#94A3B8] font-medium'
|
||||
}`}
|
||||
>
|
||||
<p className="text-[10px] uppercase tracking-wider">{NOMBRE_DIAS[dia.getDay()]}</p>
|
||||
<p className={`text-lg ${esHoy ? 'text-white' : 'text-[#111827] dark:text-[#E2E8F0]'}`}>{formatoDia(dia)}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Celdas de eventos */}
|
||||
{diasSemana.map((dia, idx) => {
|
||||
const clave = `${dia.getFullYear()}-${String(dia.getMonth() + 1).padStart(2, '0')}-${String(dia.getDate()).padStart(2, '0')}`
|
||||
const eventosDia = eventosPorDia.get(clave) ?? []
|
||||
const esHoy = esMismaFecha(dia, hoy)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`min-h-[100px] rounded-b-lg border border-t-0 border-[#E5E7EB] dark:border-[#1E293B] p-1 ${
|
||||
esHoy ? 'bg-accent/5 dark:bg-accent/10' : 'bg-white dark:bg-[#1E293B]'
|
||||
}`}
|
||||
>
|
||||
{eventosDia.length === 0 ? (
|
||||
<p className="mt-2 text-center text-[10px] text-[#CBD5E1] dark:text-[#475569]">—</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{eventosDia.map((ev) => (
|
||||
<button
|
||||
key={ev.id}
|
||||
type="button"
|
||||
onClick={() => setEventoSeleccionado(ev)}
|
||||
className={`w-full rounded px-1.5 py-1 text-left text-[10px] font-medium leading-tight transition-all hover:opacity-80 ${
|
||||
ev.tipo === 'oportunidad'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
|
||||
}`}
|
||||
title={ev.detalle ? `${ev.nombre} (${ev.detalle})` : ev.nombre}
|
||||
>
|
||||
{ev.hora && <span className="font-semibold">{ev.hora} </span>}
|
||||
{ev.nombre.length > 12 ? ev.nombre.slice(0, 12) + '…' : ev.nombre}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Leyenda */}
|
||||
<div className="mt-3 flex items-center gap-4 text-[11px] text-[#64748B] dark:text-[#94A3B8]">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-amber-400" /> Actividades
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-blue-400" /> Oportunidades
|
||||
</span>
|
||||
<span className="ml-auto">{eventos.length} evento{eventos.length !== 1 ? 's' : ''} esta semana</span>
|
||||
</div>
|
||||
|
||||
{/* Modal de detalle */}
|
||||
{eventoSeleccionado && (
|
||||
<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-sm rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
{eventoSeleccionado.tipo === 'oportunidad' ? 'Oportunidad' : 'Actividad'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEventoSeleccionado(null)}
|
||||
className="rounded-lg px-2 py-1 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] hover:bg-[#F9FAFB] dark:hover:bg-[#334155]"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">
|
||||
{eventoSeleccionado.nombre}
|
||||
</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
📅 {formatoFechaCorta(eventoSeleccionado.fecha)}
|
||||
{eventoSeleccionado.hora && ` a las ${eventoSeleccionado.hora}`}
|
||||
</p>
|
||||
{eventoSeleccionado.detalle && (
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
📋 {eventoSeleccionado.detalle}
|
||||
</p>
|
||||
)}
|
||||
{eventoSeleccionado.estado && (
|
||||
<p className="text-sm">
|
||||
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
||||
{eventoSeleccionado.estado}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
205
apps/mi-dia/src/components/DetalleLeadModal.tsx
Normal file
205
apps/mi-dia/src/components/DetalleLeadModal.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react'
|
||||
import type { LeadMiDia, OportunidadMiDia, ApartadoMiDia } from '../lib/mi-dia'
|
||||
import { formatoFechaCorta } from '../lib/mi-dia'
|
||||
import TimelineLead, { type EventoLead } from './TimelineLead'
|
||||
import { listarRecordatorios, crearRecordatorio, type Recordatorio } from '../services/recordatorios'
|
||||
|
||||
type LeadItem = LeadMiDia | OportunidadMiDia | ApartadoMiDia
|
||||
|
||||
function esLead(item: LeadItem): item is LeadMiDia {
|
||||
return 'email' in item && 'origen' in item && !('modeloInteres' in item)
|
||||
}
|
||||
|
||||
function esOportunidad(item: LeadItem): item is OportunidadMiDia {
|
||||
return 'modeloInteres' in item
|
||||
}
|
||||
|
||||
export default function DetalleLeadModal({
|
||||
item,
|
||||
onCerrar,
|
||||
}: {
|
||||
item: LeadItem
|
||||
onCerrar: () => void
|
||||
}) {
|
||||
const esOp = esOportunidad(item)
|
||||
const esLd = esLead(item)
|
||||
|
||||
// --- Historial ---
|
||||
const eventos: EventoLead[] = item.ultimoCambio
|
||||
? [{
|
||||
fechaISO: item.ultimoCambio.fechaISO,
|
||||
usuario: item.ultimoCambio.usuario,
|
||||
detalle: item.ultimoCambio.detalle,
|
||||
fuente: esLd ? item.fuente : esOp ? undefined : undefined,
|
||||
}]
|
||||
: []
|
||||
|
||||
// --- Notas ---
|
||||
const [notas, setNotas] = useState<Recordatorio[]>([])
|
||||
const [cargandoNotas, setCargandoNotas] = useState(true)
|
||||
const [notaError, setNotaError] = useState('')
|
||||
const [nuevaNota, setNuevaNota] = useState('')
|
||||
const [enviandoNota, setEnviandoNota] = useState(false)
|
||||
|
||||
const cargarNotas = useCallback(async () => {
|
||||
setCargandoNotas(true)
|
||||
setNotaError('')
|
||||
try {
|
||||
const todas = await listarRecordatorios()
|
||||
setNotas(todas.filter((r) => r.leadId === item.id))
|
||||
} catch {
|
||||
setNotaError('No se pudieron cargar las notas.')
|
||||
} finally {
|
||||
setCargandoNotas(false)
|
||||
}
|
||||
}, [item.id])
|
||||
|
||||
useEffect(() => {
|
||||
void cargarNotas()
|
||||
}, [cargarNotas])
|
||||
|
||||
async function agregarNota(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
const texto = nuevaNota.trim()
|
||||
if (texto === '') return
|
||||
setEnviandoNota(true)
|
||||
setNotaError('')
|
||||
try {
|
||||
await crearRecordatorio({
|
||||
titulo: texto,
|
||||
leadId: item.id,
|
||||
fechaRecordar: new Date().toISOString(),
|
||||
})
|
||||
setNuevaNota('')
|
||||
await cargarNotas()
|
||||
} catch {
|
||||
setNotaError('No se pudo guardar la nota.')
|
||||
} finally {
|
||||
setEnviandoNota(false)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cerrar con Escape ---
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onCerrar()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onCerrar])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-20 flex items-center justify-center bg-black/50 px-4" role="dialog" aria-modal="true">
|
||||
<div className="w-full max-w-lg rounded-2xl border border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] px-6 py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Detalle del lead</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCerrar}
|
||||
className="rounded-lg px-2 py-1 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] hover:bg-[#F9FAFB] dark:hover:bg-[#334155]"
|
||||
>
|
||||
Cerrar ✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body scrollable */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||
{/* Sección Información */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8] mb-3">Información</h3>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<InfoCampo etiqueta="Nombre" valor={item.nombre} />
|
||||
{esLd && item.telefono && <InfoCampo etiqueta="Teléfono" valor={item.telefono} />}
|
||||
{esOp && item.telefono && <InfoCampo etiqueta="Teléfono" valor={item.telefono} />}
|
||||
{esLd && item.email && <InfoCampo etiqueta="Email" valor={item.email} />}
|
||||
{esOp && item.email && <InfoCampo etiqueta="Email" valor={item.email} />}
|
||||
{esLd && item.desarrollo && <InfoCampo etiqueta="Desarrollo" valor={item.desarrollo} />}
|
||||
{esOp && item.desarrollo && <InfoCampo etiqueta="Desarrollo" valor={item.desarrollo} />}
|
||||
{esOp && item.modeloInteres && <InfoCampo etiqueta="Modelo" valor={item.modeloInteres} />}
|
||||
{esOp && item.unidadCodigo && <InfoCampo etiqueta="Unidad" valor={item.unidadCodigo} />}
|
||||
{esLd && item.origen && <InfoCampo etiqueta="Origen" valor={item.origen} />}
|
||||
<InfoCampo etiqueta="Estado" valor={esOp ? (item.etapa || '—') : item.estado || '—'} />
|
||||
{esOp && item.fechaCita && (
|
||||
<InfoCampo etiqueta="Cita" valor={item.fechaCita.replace('T', ' ')} />
|
||||
)}
|
||||
{!esLd && !esOp && (item as ApartadoMiDia).unidadCodigo && (
|
||||
<InfoCampo etiqueta="Unidad" valor={(item as ApartadoMiDia).unidadCodigo} />
|
||||
)}
|
||||
{!esLd && !esOp && (item as ApartadoMiDia).fechaLimite && (
|
||||
<InfoCampo etiqueta="Fecha límite" valor={formatoFechaCorta((item as ApartadoMiDia).fechaLimite)} />
|
||||
)}
|
||||
{item.ultimoCambio && (
|
||||
<div className="col-span-2">
|
||||
<p className="text-[11px] text-[#94A3B8]">
|
||||
Último cambio: {item.ultimoCambio.detalle} · {item.ultimoCambio.usuario ?? '—'} ·{' '}
|
||||
{formatoFechaCorta(item.ultimoCambio.fechaISO.slice(0, 10))}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sección Notas */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8] mb-3">Notas</h3>
|
||||
<form onSubmit={agregarNota} className="flex gap-2 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={nuevaNota}
|
||||
onChange={(e) => setNuevaNota(e.target.value)}
|
||||
placeholder="Escribe una nota…"
|
||||
disabled={enviandoNota}
|
||||
className="flex-1 rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={enviandoNota || nuevaNota.trim() === ''}
|
||||
className="shrink-0 rounded-lg bg-accent px-3 py-2 text-xs font-medium text-white shadow-sm transition-all hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{enviandoNota ? '…' : 'Agregar'}
|
||||
</button>
|
||||
</form>
|
||||
{notaError && (
|
||||
<p className="text-xs text-red-600 mb-2">{notaError}</p>
|
||||
)}
|
||||
{cargandoNotas ? (
|
||||
<p className="text-xs text-[#94A3B8]">Cargando notas…</p>
|
||||
) : notas.length === 0 ? (
|
||||
<p className="text-xs text-[#94A3B8]">Sin notas registradas.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{[...notas].reverse().map((n) => (
|
||||
<li key={n.id} className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#0F172A] px-3 py-2">
|
||||
<p className="text-sm text-[#111827] dark:text-[#E2E8F0]">{n.titulo}</p>
|
||||
{n.descripcion && (
|
||||
<p className="mt-0.5 text-xs text-[#64748B] dark:text-[#94A3B8]">{n.descripcion}</p>
|
||||
)}
|
||||
<p className="mt-0.5 text-[10px] text-[#94A3B8]">
|
||||
{formatoFechaCorta(n.createdAt.slice(0, 10))}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Sección Historial */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8] mb-3">Historial</h3>
|
||||
<TimelineLead eventos={eventos} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoCampo({ etiqueta, valor }: { etiqueta: string; valor: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#94A3B8] uppercase tracking-wider">{etiqueta}</p>
|
||||
<p className="text-sm text-[#111827] dark:text-[#E2E8F0]">{valor || '—'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
|
||||
describe('catálogos', () => {
|
||||
it('orígenes = vocabulario FRASA sembrado en Monday (Leads y Oportunidades)', () => {
|
||||
expect([...ORIGENES_LEAD]).toEqual(['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'OTRO'])
|
||||
expect([...ORIGENES_LEAD]).toEqual(['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'])
|
||||
})
|
||||
|
||||
it('desarrollos = labels reales de la columna Desarrollo del board Leads', () => {
|
||||
|
||||
@@ -68,6 +68,8 @@ export interface OportunidadMiDia {
|
||||
unidadCodigo: string
|
||||
updatedAt: string
|
||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
||||
/** Fecha y hora de la cita agendada (ISO local, ej. '2026-09-09T14:30') */
|
||||
fechaCita?: string
|
||||
/** true cuando el item se creó en modo local y aún no se sincroniza a Monday */
|
||||
pendienteSync?: boolean
|
||||
}
|
||||
@@ -112,7 +114,7 @@ export interface ResumenMiDia {
|
||||
|
||||
// Labels de la columna 'Origen' (board Leads) / 'Origen del Lead' (Oportunidades),
|
||||
// vocabulario FRASA verificado y sembrado en Monday el 2026-09-04
|
||||
export const ORIGENES_LEAD = ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'OTRO'] as const
|
||||
export const ORIGENES_LEAD = ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'] as const
|
||||
|
||||
// Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday)
|
||||
export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
hoyLocal,
|
||||
DESARROLLOS_LEAD,
|
||||
ETIQUETA_FASE_APARTADO,
|
||||
ETIQUETA_FASE_ESCRITURACION,
|
||||
ETIQUETA_FASE_OPORTUNIDAD,
|
||||
FASES_APARTADO,
|
||||
MOTIVOS_NO_COMPRA,
|
||||
@@ -55,6 +56,8 @@ import DashboardVendedor from '../components/DashboardVendedor'
|
||||
import ScoreBadge, { calcularScore } from '../components/ScoreBadge'
|
||||
import KanbanView from '../components/KanbanView'
|
||||
import TimelineLead, { type EventoLead } from '../components/TimelineLead'
|
||||
import CalendarioCitas from '../components/CalendarioCitas'
|
||||
import DetalleLeadModal from '../components/DetalleLeadModal'
|
||||
|
||||
function TituloSeccion({ children }: { children: string }) {
|
||||
return <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{children}</h2>
|
||||
@@ -111,6 +114,109 @@ const CLASE_INPUT =
|
||||
|
||||
const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
||||
|
||||
/** Modal genérico de descarte/cancelación con motivo + subcategoría + comentario */
|
||||
function ModalDescarteGenerico({
|
||||
item,
|
||||
titulo,
|
||||
onCancelar,
|
||||
onConfirmar,
|
||||
enviando,
|
||||
}: {
|
||||
item: { id: string; nombre: string }
|
||||
titulo: string
|
||||
onCancelar: () => void
|
||||
onConfirmar: (motivo: MotivoNoCompra, subcategoria: string | undefined, comentario: string) => void
|
||||
enviando: boolean
|
||||
}) {
|
||||
const [motivo, setMotivo] = useState<MotivoNoCompra>(MOTIVOS_NO_COMPRA[0])
|
||||
const [subcategoria, setSubcategoria] = useState('')
|
||||
const [comentario, setComentario] = useState('')
|
||||
const subcategorias = SUBCATEGORIAS_NO_COMPRA[motivo]
|
||||
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="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
|
||||
<p className="mt-2 text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">{item.nombre}</p>
|
||||
<form
|
||||
className="mt-6 space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
onConfirmar(motivo, subcategoria === '' ? undefined : subcategoria, comentario.trim())
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="motivo-descarte" className={CLASE_LABEL}>
|
||||
Motivo
|
||||
</label>
|
||||
<select
|
||||
id="motivo-descarte"
|
||||
value={motivo}
|
||||
onChange={(e) => {
|
||||
setMotivo(e.target.value as MotivoNoCompra)
|
||||
setSubcategoria('')
|
||||
}}
|
||||
className={CLASE_INPUT}
|
||||
>
|
||||
{MOTIVOS_NO_COMPRA.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="subcategoria-descarte" className={CLASE_LABEL}>
|
||||
Subcategoría
|
||||
</label>
|
||||
<select
|
||||
id="subcategoria-descarte"
|
||||
value={subcategoria}
|
||||
onChange={(e) => setSubcategoria(e.target.value)}
|
||||
className={CLASE_INPUT}
|
||||
>
|
||||
<option value="">— Selecciona —</option>
|
||||
{subcategorias.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="comentario-descarte" className={CLASE_LABEL}>
|
||||
Comentario (opcional)
|
||||
</label>
|
||||
<textarea
|
||||
id="comentario-descarte"
|
||||
value={comentario}
|
||||
onChange={(e) => setComentario(e.target.value)}
|
||||
rows={3}
|
||||
className={CLASE_INPUT}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelar}
|
||||
disabled={enviando}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB] disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={enviando}
|
||||
className="rounded-lg bg-red-600 px-5 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{enviando ? 'Procesando…' : 'Confirmar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Modal de descarte: motivo + subcategoría (catálogo Monday) + comentario opcional */
|
||||
function ModalDescartar({
|
||||
lead,
|
||||
@@ -219,6 +325,7 @@ function FilaOportunidad({
|
||||
onCotizar,
|
||||
onAfluencia,
|
||||
onDescartar,
|
||||
onVerDetalle,
|
||||
enviando,
|
||||
acciones,
|
||||
}: {
|
||||
@@ -227,19 +334,25 @@ function FilaOportunidad({
|
||||
onCotizar: () => void
|
||||
onAfluencia: () => void
|
||||
onDescartar: () => void
|
||||
onVerDetalle?: () => void
|
||||
enviando: boolean
|
||||
acciones?: string[]
|
||||
}) {
|
||||
const [conCita, setConCita] = useState(false)
|
||||
const [fechaCita, setFechaCita] = useState('')
|
||||
// Sin 'No Asistio' ni 'Abandono': son redundantes con Descartar
|
||||
const fases = ['Agendo Cita', 'Aparto'] as const
|
||||
const fases = ['Agendo Cita', 'Afluencia', 'Cotizador'] as const
|
||||
return (
|
||||
<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>
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
{op.nombre}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onVerDetalle}
|
||||
className="text-left text-accent hover:underline cursor-pointer"
|
||||
>
|
||||
{op.nombre}
|
||||
</button>
|
||||
{op.pendienteSync === true && <ChipPendienteSync />}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
@@ -248,6 +361,11 @@ function FilaOportunidad({
|
||||
{op.modeloInteres ? ` · ${op.modeloInteres}` : ''}
|
||||
{op.unidadCodigo ? ` · ${op.unidadCodigo}` : ''}
|
||||
</p>
|
||||
{op.fechaCita && (
|
||||
<p className="mt-0.5 text-[11px] text-accent font-medium">
|
||||
📅 Cita: {op.fechaCita.replace('T', ' ')}
|
||||
</p>
|
||||
)}
|
||||
{op.ultimoCambio && (
|
||||
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
|
||||
Último cambio: {op.ultimoCambio.detalle} · {op.ultimoCambio.usuario ?? '—'} ·{' '}
|
||||
@@ -291,8 +409,8 @@ function FilaOportunidad({
|
||||
</button>
|
||||
{conCita && (
|
||||
<input
|
||||
type="date"
|
||||
aria-label="Fecha de la cita"
|
||||
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"
|
||||
@@ -305,9 +423,7 @@ function FilaOportunidad({
|
||||
type="button"
|
||||
disabled={enviando}
|
||||
onClick={() => onEtapa(f)}
|
||||
className={f === 'Aparto'
|
||||
? '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'
|
||||
: CLASE_BOTON_ACCION}
|
||||
className={CLASE_BOTON_ACCION}
|
||||
>
|
||||
{ETIQUETA_FASE_OPORTUNIDAD[f]}
|
||||
</button>
|
||||
@@ -330,10 +446,14 @@ function FilaOportunidad({
|
||||
function FilaApartado({
|
||||
apartado,
|
||||
onFase,
|
||||
onCancelacion,
|
||||
onVerDetalle,
|
||||
enviando,
|
||||
}: {
|
||||
apartado: ApartadoMiDia
|
||||
onFase: (fase: FaseApartado) => void
|
||||
onCancelacion: () => void
|
||||
onVerDetalle?: () => void
|
||||
enviando: boolean
|
||||
}) {
|
||||
const [docsAbiertos, setDocsAbiertos] = useState(false)
|
||||
@@ -342,7 +462,13 @@ function FilaApartado({
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
{apartado.nombre}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onVerDetalle}
|
||||
className="text-left text-accent hover:underline cursor-pointer"
|
||||
>
|
||||
{apartado.nombre}
|
||||
</button>
|
||||
{apartado.pendienteSync === true && <ChipPendienteSync />}
|
||||
{apartado.unidadCodigo ? (
|
||||
<span className="text-xs font-normal text-[#94A3B8]">{apartado.unidadCodigo}</span>
|
||||
@@ -375,6 +501,10 @@ function FilaApartado({
|
||||
window.location.href = `/client/?apartado=${apartado.id}`
|
||||
return
|
||||
}
|
||||
if (fase === 'Cancelacion Apartado') {
|
||||
onCancelacion()
|
||||
return
|
||||
}
|
||||
onFase(fase)
|
||||
}}
|
||||
className={
|
||||
@@ -505,8 +635,8 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados / Mi tablero */
|
||||
type TabCrm = 'leads' | 'oportunidades' | 'apartados' | 'escrituracion' | 'tablero'
|
||||
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados / Mi tablero / Calendario */
|
||||
type TabCrm = 'leads' | 'oportunidades' | 'apartados' | 'escrituracion' | 'tablero' | 'calendario'
|
||||
|
||||
function TabsCrm({
|
||||
activa,
|
||||
@@ -527,6 +657,7 @@ function TabsCrm({
|
||||
{ id: 'apartados', titulo: 'Apartados' },
|
||||
{ id: 'escrituracion', titulo: 'Escrituración' },
|
||||
{ id: 'tablero', titulo: 'Tablero' },
|
||||
{ id: 'calendario', titulo: 'Calendario' },
|
||||
]
|
||||
return (
|
||||
<div className="mb-4 flex items-center gap-1 overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white p-1 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm" role="tablist">
|
||||
@@ -575,6 +706,7 @@ function FilaLead({
|
||||
onConvertir,
|
||||
onDescartar,
|
||||
onTimeline,
|
||||
onVerDetalle,
|
||||
enviando,
|
||||
acciones,
|
||||
}: {
|
||||
@@ -583,6 +715,7 @@ function FilaLead({
|
||||
onConvertir: () => void
|
||||
onDescartar: () => void
|
||||
onTimeline?: () => void
|
||||
onVerDetalle?: () => void
|
||||
enviando: boolean
|
||||
acciones?: string[]
|
||||
}) {
|
||||
@@ -591,7 +724,13 @@ function FilaLead({
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
{lead.nombre}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onVerDetalle}
|
||||
className="text-left text-accent hover:underline cursor-pointer"
|
||||
>
|
||||
{lead.nombre}
|
||||
</button>
|
||||
<ScoreBadge score={calcularScore(lead)} />
|
||||
{lead.pendienteSync === true && <ChipPendienteSync />}
|
||||
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
|
||||
@@ -679,6 +818,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
const [tabCrm, setTabCrm] = useState<TabCrm>('leads')
|
||||
const [nota, setNota] = useState<string | null>(null)
|
||||
const [apartadoSeleccionado, setApartadoSeleccionado] = useState<ApartadoMiDia | null>(null)
|
||||
const [leadSeleccionado, setLeadSeleccionado] = useState<LeadMiDia | OportunidadMiDia | ApartadoMiDia | null>(null)
|
||||
const [apartadoCancelando, setApartadoCancelando] = useState<{
|
||||
id: string
|
||||
nombre: string
|
||||
} | null>(null)
|
||||
const [filtros, setFiltros] = useState<FiltrosCRM>({ texto: '', desarrollo: '', origen: '', fechaDesde: '', fechaHasta: '' })
|
||||
const [offline, setOffline] = useState(!navigator.onLine)
|
||||
const [offlineFecha, setOfflineFecha] = useState<string | null>(null)
|
||||
@@ -1038,7 +1182,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
conteos={{
|
||||
leads: resumen.misLeads.filter((l) => l.fuente === 'lead').length,
|
||||
oportunidades: resumen.misOportunidades.items.length,
|
||||
apartados: resumen.misApartados.length, escrituracion: resumen.misApartados.length, tablero: 0,
|
||||
apartados: resumen.misApartados.length, escrituracion: resumen.misApartados.length, tablero: 0, calendario: 0,
|
||||
}}
|
||||
onCambiar={setTabCrm}
|
||||
vistaKanban={vistaKanban}
|
||||
@@ -1097,6 +1241,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
|
||||
onConvertir={() => void convertir(lead)}
|
||||
onDescartar={() => setDescartando(lead)}
|
||||
onVerDetalle={() => setLeadSeleccionado(lead)}
|
||||
onTimeline={() => setLeadTimeline({
|
||||
nombre: lead.nombre,
|
||||
eventos: lead.ultimoCambio ? [{
|
||||
@@ -1152,6 +1297,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
window.location.href = urlAfluenciaParaOportunidad(op)
|
||||
}}
|
||||
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
|
||||
onVerDetalle={() => setLeadSeleccionado(op)}
|
||||
acciones={acciones}
|
||||
/>
|
||||
))}
|
||||
@@ -1196,6 +1342,8 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
<FilaApartado
|
||||
apartado={a}
|
||||
enviando={enviando}
|
||||
onCancelacion={() => setApartadoCancelando({ id: a.id, nombre: a.nombre })}
|
||||
onVerDetalle={() => setLeadSeleccionado(a)}
|
||||
onFase={(fase) => {
|
||||
if (fase === 'Firma Cliente') {
|
||||
// Cambiar etapa AÚNQUE luego cambie a pestaña Escrituración
|
||||
@@ -1246,17 +1394,25 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
{['Seguimiento', 'Llamada Escrituras', 'X Escriturar', 'Banco/Infonavit', 'X Autorizar', 'Escriturado', 'Cancelo'].map((fase) => (
|
||||
{(['Seguimiento', 'Llamada Escrituras', 'X Escriturar', 'Banco/Infonavit', 'X Autorizar', 'Escriturado', 'Cancelo'] as const).map((fase) => (
|
||||
<button
|
||||
key={fase}
|
||||
type="button"
|
||||
disabled={enviando}
|
||||
onClick={() => void ejecutar(() => cambiarEstadoApartado(apartado.id, fase), 'No se pudo actualizar')}
|
||||
onClick={() => {
|
||||
if (fase === 'Cancelo') {
|
||||
setApartadoCancelando({ id: apartado.id, nombre: apartado.nombre })
|
||||
return
|
||||
}
|
||||
void ejecutar(() => cambiarEstadoApartado(apartado.id, fase), 'No se pudo actualizar')
|
||||
}}
|
||||
className={fase === apartado.estado
|
||||
? 'rounded-lg border border-accent bg-accent/5 px-3 py-1.5 text-xs font-medium text-accent shadow-sm disabled:opacity-50'
|
||||
: CLASE_BOTON_ACCION}
|
||||
: fase === 'Cancelo'
|
||||
? 'rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 shadow-sm transition-all duration-100 ease-out hover:bg-red-50 disabled:opacity-50 dark:bg-[#0F172A] dark:border-red-800 dark:text-red-400'
|
||||
: CLASE_BOTON_ACCION}
|
||||
>
|
||||
{fase}
|
||||
{ETIQUETA_FASE_ESCRITURACION[fase]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -1270,6 +1426,15 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
{tabCrm === 'tablero' && (
|
||||
<DashboardVendedor />
|
||||
)}
|
||||
|
||||
{tabCrm === 'calendario' && (
|
||||
<section aria-label="Calendario">
|
||||
<CalendarioCitas
|
||||
actividades={resumen.misActividadesHoy}
|
||||
oportunidades={resumen.misOportunidades.items}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-label="Mis capturas de afluencia">
|
||||
@@ -1582,6 +1747,23 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
/>
|
||||
)}
|
||||
|
||||
{apartadoCancelando && (
|
||||
<ModalDescarteGenerico
|
||||
item={apartadoCancelando}
|
||||
titulo="Cancelar apartado"
|
||||
enviando={enviando}
|
||||
onCancelar={() => setApartadoCancelando(null)}
|
||||
onConfirmar={(motivo, subcategoria, comentario) => {
|
||||
const item = apartadoCancelando
|
||||
setApartadoCancelando(null)
|
||||
void ejecutar(
|
||||
() => cambiarEstadoApartado(item.id, 'Cancelacion Apartado', motivo, subcategoria, comentario),
|
||||
'No se pudo cancelar el apartado',
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{apartadoSeleccionado && (
|
||||
<NotasApartado
|
||||
apartado={apartadoSeleccionado}
|
||||
@@ -1610,6 +1792,13 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leadSeleccionado && (
|
||||
<DetalleLeadModal
|
||||
item={leadSeleccionado}
|
||||
onCerrar={() => setLeadSeleccionado(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -88,11 +88,22 @@ export function convertirLead(itemId: string): Promise<{ oportunidadId: string }
|
||||
}
|
||||
|
||||
/** POST /apartados/:itemId/estado — botones de fase de "Mis apartados" */
|
||||
export function cambiarEstadoApartado(itemId: string, estado: string): Promise<unknown> {
|
||||
export function cambiarEstadoApartado(
|
||||
itemId: string,
|
||||
estado: string,
|
||||
motivo?: string,
|
||||
subcategoria?: string,
|
||||
comentario?: string,
|
||||
): Promise<unknown> {
|
||||
return fetchJson(`/apartados/${itemId}/estado`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ estado }),
|
||||
body: JSON.stringify({
|
||||
estado,
|
||||
...(motivo !== undefined && motivo !== '' ? { motivo } : {}),
|
||||
...(subcategoria !== undefined && subcategoria !== '' ? { subcategoria } : {}),
|
||||
...(comentario !== undefined && comentario !== '' ? { comentario } : {}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
44
apps/mi-dia/src/services/recordatorios.ts
Normal file
44
apps/mi-dia/src/services/recordatorios.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { conAuth, manejar401 } from './auth'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? '/api'
|
||||
|
||||
export interface Recordatorio {
|
||||
id: string
|
||||
titulo: string
|
||||
descripcion: string | null
|
||||
leadId: string | null
|
||||
fechaRecordar: string // ISO
|
||||
completado: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** GET /api/recordatorios — listar recordatorios del usuario actual */
|
||||
export async function listarRecordatorios(): Promise<Recordatorio[]> {
|
||||
const res = await fetch(`${API_URL}/recordatorios`, conAuth())
|
||||
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 (await res.json()) as Recordatorio[]
|
||||
}
|
||||
|
||||
/** POST /api/recordatorios — crear un recordatorio para un lead */
|
||||
export async function crearRecordatorio(data: {
|
||||
titulo: string
|
||||
descripcion?: string
|
||||
leadId: string
|
||||
fechaRecordar: string
|
||||
}): Promise<Recordatorio> {
|
||||
const res = await fetch(`${API_URL}/recordatorios`, conAuth({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
}))
|
||||
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 (await res.json()) as Recordatorio
|
||||
}
|
||||
Reference in New Issue
Block a user