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:
@@ -39,6 +39,9 @@ export const FASES_APARTADO_PERMITIDAS = [
|
|||||||
|
|
||||||
const cambiarEstadoSchema = z.object({
|
const cambiarEstadoSchema = z.object({
|
||||||
estado: z.enum(FASES_APARTADO_PERMITIDAS),
|
estado: z.enum(FASES_APARTADO_PERMITIDAS),
|
||||||
|
motivo: z.string().optional(),
|
||||||
|
subcategoria: z.string().optional(),
|
||||||
|
comentario: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
type MondayApartados = Pick<
|
type MondayApartados = Pick<
|
||||||
@@ -84,7 +87,10 @@ export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
res.json({ itemId, estado: body.estado })
|
res.json({ itemId, estado: body.estado })
|
||||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CAMBIAR_ESTADO', 'apartado', itemId, body.estado)
|
const detalle = body.motivo
|
||||||
|
? `${body.estado} — motivo: ${body.motivo}${body.subcategoria ? ` / ${body.subcategoria}` : ''}${body.comentario ? ` (${body.comentario})` : ''}`
|
||||||
|
: body.estado
|
||||||
|
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CAMBIAR_ESTADO', 'apartado', itemId, detalle)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Precarga de documentos del expediente (desde "Mis apartados") ----------
|
// ---------- Precarga de documentos del expediente (desde "Mis apartados") ----------
|
||||||
|
|||||||
@@ -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
|
// Catálogos de Monday (labels exactos). Origen: vocabulario FRASA sembrado en
|
||||||
// los tableros Leads y Oportunidades el 2026-09-04 (reemplaza al set anterior)
|
// 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
|
export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const
|
||||||
|
|
||||||
// Label de la columna status 'Califica' del tablero Leads (captura de afluencia).
|
// Label de la columna status 'Califica' del tablero Leads (captura de afluencia).
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ interface UseCotizacionResult {
|
|||||||
propuestaIndex: number
|
propuestaIndex: number
|
||||||
setPropuestaIndex: (i: number) => void
|
setPropuestaIndex: (i: number) => void
|
||||||
precioLista: number
|
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
|
numMeses: number | null
|
||||||
propuestas: PropuestaResultado[]
|
propuestas: PropuestaResultado[]
|
||||||
/** Restaura el estado completo desde una cotización guardada (historial) */
|
/** 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 [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)
|
||||||
|
const [numMensualidades, setNumMensualidades] = useState<number | null>(null)
|
||||||
|
|
||||||
const params = obtenerDesarrollo(desarrollo)
|
const params = obtenerDesarrollo(desarrollo)
|
||||||
|
|
||||||
@@ -75,6 +80,7 @@ export function useCotizacion(): UseCotizacionResult {
|
|||||||
setAcabados(false)
|
setAcabados(false)
|
||||||
setDescuentos(descuentosDefault(id))
|
setDescuentos(descuentosDefault(id))
|
||||||
setPropuestaIndex(0)
|
setPropuestaIndex(0)
|
||||||
|
setNumMensualidades(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const setDescuento = (clave: keyof DescuentosPropuestas, valor: number) => {
|
const setDescuento = (clave: keyof DescuentosPropuestas, valor: number) => {
|
||||||
@@ -103,13 +109,17 @@ export function useCotizacion(): UseCotizacionResult {
|
|||||||
setEnganchePct(null)
|
setEnganchePct(null)
|
||||||
setDescuentos(descuentosDefault(desarrollo))
|
setDescuentos(descuentosDefault(desarrollo))
|
||||||
setPropuestaIndex(0)
|
setPropuestaIndex(0)
|
||||||
|
setNumMensualidades(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const numMeses = useMemo(
|
const numMesesCalculado = useMemo(
|
||||||
() => (unidad ? resolverNumMensualidades(unidad, params) : null),
|
() => (unidad ? resolverNumMensualidades(unidad, params) : null),
|
||||||
[unidad, params],
|
[unidad, params],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Número efectivo de mensualidades: la elegida por el vendedor, o la calculada */
|
||||||
|
const numMeses = numMensualidades ?? numMesesCalculado
|
||||||
|
|
||||||
const precioLista = useMemo(
|
const precioLista = useMemo(
|
||||||
() => (unidad ? obtenerPrecioLista(unidad, acabados) : 0),
|
() => (unidad ? obtenerPrecioLista(unidad, acabados) : 0),
|
||||||
[unidad, acabados],
|
[unidad, acabados],
|
||||||
@@ -125,8 +135,8 @@ export function useCotizacion(): UseCotizacionResult {
|
|||||||
|
|
||||||
const propuestas = useMemo(() => {
|
const propuestas = useMemo(() => {
|
||||||
if (!unidad) return []
|
if (!unidad) return []
|
||||||
return calcularPropuestas({ unidad, acabados, apartado: apartadoEfectivo, descuentos, desarrollo, enganchePct: engancheEfectivo })
|
return calcularPropuestas({ unidad, acabados, apartado: apartadoEfectivo, descuentos, desarrollo, enganchePct: engancheEfectivo, numMeses: numMeses ?? undefined })
|
||||||
}, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo, engancheEfectivo])
|
}, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo, engancheEfectivo, numMeses])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
desarrollo,
|
desarrollo,
|
||||||
@@ -148,6 +158,8 @@ export function useCotizacion(): UseCotizacionResult {
|
|||||||
propuestaIndex,
|
propuestaIndex,
|
||||||
setPropuestaIndex,
|
setPropuestaIndex,
|
||||||
precioLista,
|
precioLista,
|
||||||
|
numMensualidades,
|
||||||
|
setNumMensualidades,
|
||||||
numMeses,
|
numMeses,
|
||||||
propuestas,
|
propuestas,
|
||||||
restaurar,
|
restaurar,
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ export default function CotizadorPage() {
|
|||||||
propuestaIndex,
|
propuestaIndex,
|
||||||
setPropuestaIndex,
|
setPropuestaIndex,
|
||||||
precioLista,
|
precioLista,
|
||||||
|
numMensualidades,
|
||||||
|
setNumMensualidades,
|
||||||
numMeses,
|
numMeses,
|
||||||
propuestas,
|
propuestas,
|
||||||
restaurar,
|
restaurar,
|
||||||
@@ -465,6 +467,33 @@ export default function CotizadorPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<BuscadorCliente
|
||||||
onSeleccion={(lead) => {
|
onSeleccion={(lead) => {
|
||||||
setValue('nombre', lead.nombre, { shouldValidate: true })
|
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', () => {
|
describe('catálogos', () => {
|
||||||
it('orígenes = vocabulario FRASA sembrado en Monday (Leads y Oportunidades)', () => {
|
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', () => {
|
it('desarrollos = labels reales de la columna Desarrollo del board Leads', () => {
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export interface OportunidadMiDia {
|
|||||||
unidadCodigo: string
|
unidadCodigo: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
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 */
|
/** true cuando el item se creó en modo local y aún no se sincroniza a Monday */
|
||||||
pendienteSync?: boolean
|
pendienteSync?: boolean
|
||||||
}
|
}
|
||||||
@@ -112,7 +114,7 @@ export interface ResumenMiDia {
|
|||||||
|
|
||||||
// Labels de la columna 'Origen' (board Leads) / 'Origen del Lead' (Oportunidades),
|
// Labels de la columna 'Origen' (board Leads) / 'Origen del Lead' (Oportunidades),
|
||||||
// vocabulario FRASA verificado y sembrado en Monday el 2026-09-04
|
// 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)
|
// Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday)
|
||||||
export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const
|
export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
hoyLocal,
|
hoyLocal,
|
||||||
DESARROLLOS_LEAD,
|
DESARROLLOS_LEAD,
|
||||||
ETIQUETA_FASE_APARTADO,
|
ETIQUETA_FASE_APARTADO,
|
||||||
|
ETIQUETA_FASE_ESCRITURACION,
|
||||||
ETIQUETA_FASE_OPORTUNIDAD,
|
ETIQUETA_FASE_OPORTUNIDAD,
|
||||||
FASES_APARTADO,
|
FASES_APARTADO,
|
||||||
MOTIVOS_NO_COMPRA,
|
MOTIVOS_NO_COMPRA,
|
||||||
@@ -55,6 +56,8 @@ import DashboardVendedor from '../components/DashboardVendedor'
|
|||||||
import ScoreBadge, { calcularScore } from '../components/ScoreBadge'
|
import ScoreBadge, { calcularScore } from '../components/ScoreBadge'
|
||||||
import KanbanView from '../components/KanbanView'
|
import KanbanView from '../components/KanbanView'
|
||||||
import TimelineLead, { type EventoLead } from '../components/TimelineLead'
|
import TimelineLead, { type EventoLead } from '../components/TimelineLead'
|
||||||
|
import CalendarioCitas from '../components/CalendarioCitas'
|
||||||
|
import DetalleLeadModal from '../components/DetalleLeadModal'
|
||||||
|
|
||||||
function TituloSeccion({ children }: { children: string }) {
|
function TituloSeccion({ children }: { children: string }) {
|
||||||
return <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{children}</h2>
|
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]'
|
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 */
|
/** Modal de descarte: motivo + subcategoría (catálogo Monday) + comentario opcional */
|
||||||
function ModalDescartar({
|
function ModalDescartar({
|
||||||
lead,
|
lead,
|
||||||
@@ -219,6 +325,7 @@ function FilaOportunidad({
|
|||||||
onCotizar,
|
onCotizar,
|
||||||
onAfluencia,
|
onAfluencia,
|
||||||
onDescartar,
|
onDescartar,
|
||||||
|
onVerDetalle,
|
||||||
enviando,
|
enviando,
|
||||||
acciones,
|
acciones,
|
||||||
}: {
|
}: {
|
||||||
@@ -227,19 +334,25 @@ function FilaOportunidad({
|
|||||||
onCotizar: () => void
|
onCotizar: () => void
|
||||||
onAfluencia: () => void
|
onAfluencia: () => void
|
||||||
onDescartar: () => void
|
onDescartar: () => void
|
||||||
|
onVerDetalle?: () => void
|
||||||
enviando: boolean
|
enviando: boolean
|
||||||
acciones?: string[]
|
acciones?: string[]
|
||||||
}) {
|
}) {
|
||||||
const [conCita, setConCita] = useState(false)
|
const [conCita, setConCita] = useState(false)
|
||||||
const [fechaCita, setFechaCita] = useState('')
|
const [fechaCita, setFechaCita] = useState('')
|
||||||
// Sin 'No Asistio' ni 'Abandono': son redundantes con Descartar
|
const fases = ['Agendo Cita', 'Afluencia', 'Cotizador'] as const
|
||||||
const fases = ['Agendo Cita', '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">
|
||||||
<div>
|
<div>
|
||||||
<p className="flex items-center gap-2 text-sm font-medium">
|
<p className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onVerDetalle}
|
||||||
|
className="text-left text-accent hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
{op.nombre}
|
{op.nombre}
|
||||||
|
</button>
|
||||||
{op.pendienteSync === true && <ChipPendienteSync />}
|
{op.pendienteSync === true && <ChipPendienteSync />}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||||
@@ -248,6 +361,11 @@ function FilaOportunidad({
|
|||||||
{op.modeloInteres ? ` · ${op.modeloInteres}` : ''}
|
{op.modeloInteres ? ` · ${op.modeloInteres}` : ''}
|
||||||
{op.unidadCodigo ? ` · ${op.unidadCodigo}` : ''}
|
{op.unidadCodigo ? ` · ${op.unidadCodigo}` : ''}
|
||||||
</p>
|
</p>
|
||||||
|
{op.fechaCita && (
|
||||||
|
<p className="mt-0.5 text-[11px] text-accent font-medium">
|
||||||
|
📅 Cita: {op.fechaCita.replace('T', ' ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{op.ultimoCambio && (
|
{op.ultimoCambio && (
|
||||||
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
|
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
|
||||||
Último cambio: {op.ultimoCambio.detalle} · {op.ultimoCambio.usuario ?? '—'} ·{' '}
|
Último cambio: {op.ultimoCambio.detalle} · {op.ultimoCambio.usuario ?? '—'} ·{' '}
|
||||||
@@ -291,8 +409,8 @@ function FilaOportunidad({
|
|||||||
</button>
|
</button>
|
||||||
{conCita && (
|
{conCita && (
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="datetime-local"
|
||||||
aria-label="Fecha de la cita"
|
aria-label="Fecha y hora de la cita"
|
||||||
value={fechaCita}
|
value={fechaCita}
|
||||||
onChange={(e) => setFechaCita(e.target.value)}
|
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"
|
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"
|
type="button"
|
||||||
disabled={enviando}
|
disabled={enviando}
|
||||||
onClick={() => onEtapa(f)}
|
onClick={() => onEtapa(f)}
|
||||||
className={f === 'Aparto'
|
className={CLASE_BOTON_ACCION}
|
||||||
? '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}
|
|
||||||
>
|
>
|
||||||
{ETIQUETA_FASE_OPORTUNIDAD[f]}
|
{ETIQUETA_FASE_OPORTUNIDAD[f]}
|
||||||
</button>
|
</button>
|
||||||
@@ -330,10 +446,14 @@ function FilaOportunidad({
|
|||||||
function FilaApartado({
|
function FilaApartado({
|
||||||
apartado,
|
apartado,
|
||||||
onFase,
|
onFase,
|
||||||
|
onCancelacion,
|
||||||
|
onVerDetalle,
|
||||||
enviando,
|
enviando,
|
||||||
}: {
|
}: {
|
||||||
apartado: ApartadoMiDia
|
apartado: ApartadoMiDia
|
||||||
onFase: (fase: FaseApartado) => void
|
onFase: (fase: FaseApartado) => void
|
||||||
|
onCancelacion: () => void
|
||||||
|
onVerDetalle?: () => void
|
||||||
enviando: boolean
|
enviando: boolean
|
||||||
}) {
|
}) {
|
||||||
const [docsAbiertos, setDocsAbiertos] = useState(false)
|
const [docsAbiertos, setDocsAbiertos] = useState(false)
|
||||||
@@ -342,7 +462,13 @@ function FilaApartado({
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="flex items-center gap-2 text-sm font-medium">
|
<p className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onVerDetalle}
|
||||||
|
className="text-left text-accent hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
{apartado.nombre}
|
{apartado.nombre}
|
||||||
|
</button>
|
||||||
{apartado.pendienteSync === true && <ChipPendienteSync />}
|
{apartado.pendienteSync === true && <ChipPendienteSync />}
|
||||||
{apartado.unidadCodigo ? (
|
{apartado.unidadCodigo ? (
|
||||||
<span className="text-xs font-normal text-[#94A3B8]">{apartado.unidadCodigo}</span>
|
<span className="text-xs font-normal text-[#94A3B8]">{apartado.unidadCodigo}</span>
|
||||||
@@ -375,6 +501,10 @@ function FilaApartado({
|
|||||||
window.location.href = `/client/?apartado=${apartado.id}`
|
window.location.href = `/client/?apartado=${apartado.id}`
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (fase === 'Cancelacion Apartado') {
|
||||||
|
onCancelacion()
|
||||||
|
return
|
||||||
|
}
|
||||||
onFase(fase)
|
onFase(fase)
|
||||||
}}
|
}}
|
||||||
className={
|
className={
|
||||||
@@ -505,8 +635,8 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados / Mi tablero */
|
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados / Mi tablero / Calendario */
|
||||||
type TabCrm = 'leads' | 'oportunidades' | 'apartados' | 'escrituracion' | 'tablero'
|
type TabCrm = 'leads' | 'oportunidades' | 'apartados' | 'escrituracion' | 'tablero' | 'calendario'
|
||||||
|
|
||||||
function TabsCrm({
|
function TabsCrm({
|
||||||
activa,
|
activa,
|
||||||
@@ -527,6 +657,7 @@ function TabsCrm({
|
|||||||
{ id: 'apartados', titulo: 'Apartados' },
|
{ id: 'apartados', titulo: 'Apartados' },
|
||||||
{ id: 'escrituracion', titulo: 'Escrituración' },
|
{ id: 'escrituracion', titulo: 'Escrituración' },
|
||||||
{ id: 'tablero', titulo: 'Tablero' },
|
{ id: 'tablero', titulo: 'Tablero' },
|
||||||
|
{ id: 'calendario', titulo: 'Calendario' },
|
||||||
]
|
]
|
||||||
return (
|
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">
|
<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,
|
onConvertir,
|
||||||
onDescartar,
|
onDescartar,
|
||||||
onTimeline,
|
onTimeline,
|
||||||
|
onVerDetalle,
|
||||||
enviando,
|
enviando,
|
||||||
acciones,
|
acciones,
|
||||||
}: {
|
}: {
|
||||||
@@ -583,6 +715,7 @@ function FilaLead({
|
|||||||
onConvertir: () => void
|
onConvertir: () => void
|
||||||
onDescartar: () => void
|
onDescartar: () => void
|
||||||
onTimeline?: () => void
|
onTimeline?: () => void
|
||||||
|
onVerDetalle?: () => void
|
||||||
enviando: boolean
|
enviando: boolean
|
||||||
acciones?: string[]
|
acciones?: string[]
|
||||||
}) {
|
}) {
|
||||||
@@ -591,7 +724,13 @@ function FilaLead({
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="flex items-center gap-2 text-sm font-medium">
|
<p className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onVerDetalle}
|
||||||
|
className="text-left text-accent hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
{lead.nombre}
|
{lead.nombre}
|
||||||
|
</button>
|
||||||
<ScoreBadge score={calcularScore(lead)} />
|
<ScoreBadge score={calcularScore(lead)} />
|
||||||
{lead.pendienteSync === true && <ChipPendienteSync />}
|
{lead.pendienteSync === true && <ChipPendienteSync />}
|
||||||
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
|
{/* 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 [tabCrm, setTabCrm] = useState<TabCrm>('leads')
|
||||||
const [nota, setNota] = useState<string | null>(null)
|
const [nota, setNota] = useState<string | null>(null)
|
||||||
const [apartadoSeleccionado, setApartadoSeleccionado] = useState<ApartadoMiDia | 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 [filtros, setFiltros] = useState<FiltrosCRM>({ texto: '', desarrollo: '', origen: '', fechaDesde: '', fechaHasta: '' })
|
||||||
const [offline, setOffline] = useState(!navigator.onLine)
|
const [offline, setOffline] = useState(!navigator.onLine)
|
||||||
const [offlineFecha, setOfflineFecha] = useState<string | null>(null)
|
const [offlineFecha, setOfflineFecha] = useState<string | null>(null)
|
||||||
@@ -1038,7 +1182,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
conteos={{
|
conteos={{
|
||||||
leads: resumen.misLeads.filter((l) => l.fuente === 'lead').length,
|
leads: resumen.misLeads.filter((l) => l.fuente === 'lead').length,
|
||||||
oportunidades: resumen.misOportunidades.items.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}
|
onCambiar={setTabCrm}
|
||||||
vistaKanban={vistaKanban}
|
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')}
|
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
|
||||||
onConvertir={() => void convertir(lead)}
|
onConvertir={() => void convertir(lead)}
|
||||||
onDescartar={() => setDescartando(lead)}
|
onDescartar={() => setDescartando(lead)}
|
||||||
|
onVerDetalle={() => setLeadSeleccionado(lead)}
|
||||||
onTimeline={() => setLeadTimeline({
|
onTimeline={() => setLeadTimeline({
|
||||||
nombre: lead.nombre,
|
nombre: lead.nombre,
|
||||||
eventos: lead.ultimoCambio ? [{
|
eventos: lead.ultimoCambio ? [{
|
||||||
@@ -1152,6 +1297,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
window.location.href = urlAfluenciaParaOportunidad(op)
|
window.location.href = urlAfluenciaParaOportunidad(op)
|
||||||
}}
|
}}
|
||||||
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
|
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
|
||||||
|
onVerDetalle={() => setLeadSeleccionado(op)}
|
||||||
acciones={acciones}
|
acciones={acciones}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -1196,6 +1342,8 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
<FilaApartado
|
<FilaApartado
|
||||||
apartado={a}
|
apartado={a}
|
||||||
enviando={enviando}
|
enviando={enviando}
|
||||||
|
onCancelacion={() => setApartadoCancelando({ id: a.id, nombre: a.nombre })}
|
||||||
|
onVerDetalle={() => setLeadSeleccionado(a)}
|
||||||
onFase={(fase) => {
|
onFase={(fase) => {
|
||||||
if (fase === 'Firma Cliente') {
|
if (fase === 'Firma Cliente') {
|
||||||
// Cambiar etapa AÚNQUE luego cambie a pestaña Escrituración
|
// 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>
|
</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">
|
||||||
{['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
|
<button
|
||||||
key={fase}
|
key={fase}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={enviando}
|
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
|
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'
|
? 'rounded-lg border border-accent bg-accent/5 px-3 py-1.5 text-xs font-medium text-accent shadow-sm disabled:opacity-50'
|
||||||
|
: 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}
|
: CLASE_BOTON_ACCION}
|
||||||
>
|
>
|
||||||
{fase}
|
{ETIQUETA_FASE_ESCRITURACION[fase]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1270,6 +1426,15 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
{tabCrm === 'tablero' && (
|
{tabCrm === 'tablero' && (
|
||||||
<DashboardVendedor />
|
<DashboardVendedor />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tabCrm === 'calendario' && (
|
||||||
|
<section aria-label="Calendario">
|
||||||
|
<CalendarioCitas
|
||||||
|
actividades={resumen.misActividadesHoy}
|
||||||
|
oportunidades={resumen.misOportunidades.items}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-label="Mis capturas de afluencia">
|
<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 && (
|
{apartadoSeleccionado && (
|
||||||
<NotasApartado
|
<NotasApartado
|
||||||
apartado={apartadoSeleccionado}
|
apartado={apartadoSeleccionado}
|
||||||
@@ -1610,6 +1792,13 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
</div>
|
</div>
|
||||||
</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" */
|
/** 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`, {
|
return fetchJson(`/apartados/${itemId}/estado`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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