feat(mejoras): 22 mejoras UX en todos los módulos
Mi día: cancelación con motivo, conteo escrituración real, cita sin retroceder, calendario completo Cobranza: MoraDashboard respeta alcance, hoy dinámico, cancelar con motivo Landing: contactos paginados, error vs vacío diferenciado Dashboards: KPI renombrado, drill-down planes activos Chat: historial recarga, preguntas rotativas, error solo toast Transversal: shadow-sm corrupto corregido en 9 archivos 553 API tests + 7 apps construidas
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import { X, User, Calendar, CheckSquare, FileText, Users, Search } from 'lucide-react'
|
||||
import type { LeadMiDia, OportunidadMiDia, ApartadoMiDia } from '../lib/mi-dia'
|
||||
import { formatoFechaCorta, urlWhatsApp } from '../lib/mi-dia'
|
||||
@@ -56,10 +56,12 @@ export default function DetalleLeadModal({
|
||||
const [nuevaNota, setNuevaNota] = useState('')
|
||||
const [enviandoNota, setEnviandoNota] = useState(false)
|
||||
const [busquedaNota, setBusquedaNota] = useState('')
|
||||
// Input inline de nueva nota: el botón "+ Añadir nota" lo enfoca
|
||||
const inputNotaRef = useRef<HTMLInputElement>(null)
|
||||
// --- Tareas ---
|
||||
const [completandoTarea, setCompletandoTarea] = useState<string | null>(null)
|
||||
// --- Cita ---
|
||||
const [fechaCita, setFechaCita] = useState('')
|
||||
const [fechaCita, setFechaCita] = useState(() => ('fechaCita' in item ? (item.fechaCita ?? '') : ''))
|
||||
const [guardandoCita, setGuardandoCita] = useState(false)
|
||||
|
||||
/** Guarda la cita: el cambio de etapa lo ejecuta la página (onGuardarCita) */
|
||||
@@ -192,40 +194,37 @@ export default function DetalleLeadModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'cita' && (
|
||||
{tab === 'cita' && esOp && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Cita</h3>
|
||||
{esOp && item.fechaCita ? (
|
||||
<div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] p-4">
|
||||
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">Cita programada</p>
|
||||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8] mt-1">{item.fechaCita.replace('T', ' a las ')}</p>
|
||||
</div>
|
||||
) : esOp ? (
|
||||
<form onSubmit={guardarCita} className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="cita-fecha" className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
|
||||
Fecha y hora de la cita
|
||||
</label>
|
||||
<input
|
||||
id="cita-fecha"
|
||||
type="datetime-local"
|
||||
value={fechaCita}
|
||||
onChange={(e) => setFechaCita(e.target.value)}
|
||||
disabled={guardandoCita}
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 disabled:opacity-50"
|
||||
/>
|
||||
<form onSubmit={guardarCita} className="space-y-3">
|
||||
{item.fechaCita && (
|
||||
<div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] p-4">
|
||||
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">Cita programada</p>
|
||||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8] mt-1">{item.fechaCita.replace('T', ' a las ')} · puedes reagendar abajo</p>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={guardandoCita || fechaCita === ''}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{guardandoCita ? 'Guardando…' : 'Guardar cita'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="text-sm text-[#94A3B8]">No hay cita programada. Las citas se agendan desde Mis oportunidades.</p>
|
||||
)}
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="cita-fecha" className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
|
||||
{item.fechaCita ? 'Nueva fecha y hora de la cita' : 'Fecha y hora de la cita'}
|
||||
</label>
|
||||
<input
|
||||
id="cita-fecha"
|
||||
type="datetime-local"
|
||||
value={fechaCita}
|
||||
onChange={(e) => setFechaCita(e.target.value)}
|
||||
disabled={guardandoCita}
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={guardandoCita || fechaCita === ''}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{guardandoCita ? 'Guardando…' : item.fechaCita ? 'Actualizar cita' : 'Guardar cita'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -272,8 +271,8 @@ export default function DetalleLeadModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botón añadir */}
|
||||
<button type="button" onClick={() => { setTab('notas') }}
|
||||
{/* Botón añadir: enfoca el input inline en vez de recargar el tab */}
|
||||
<button type="button" onClick={() => inputNotaRef.current?.focus()}
|
||||
className="w-full rounded-lg border-2 border-dashed border-[#D1D5DB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#0F172A] py-3 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] hover:border-accent hover:text-accent transition-colors">
|
||||
+ Añadir nota
|
||||
</button>
|
||||
@@ -282,7 +281,7 @@ export default function DetalleLeadModal({
|
||||
|
||||
{/* Formulario inline */}
|
||||
<form onSubmit={agregarNota} className="flex gap-2">
|
||||
<input type="text" value={nuevaNota} onChange={(e) => setNuevaNota(e.target.value)}
|
||||
<input ref={inputNotaRef} type="text" value={nuevaNota} onChange={(e) => setNuevaNota(e.target.value)}
|
||||
placeholder="Escribe tu nota…" disabled={enviandoNota}
|
||||
className="flex-1 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] focus:border-accent focus:ring-2 focus:ring-accent/20 disabled:opacity-50" />
|
||||
<button type="submit" disabled={enviandoNota || !nuevaNota.trim()}
|
||||
|
||||
@@ -84,6 +84,8 @@ export interface ResumenMiDia {
|
||||
misCapturas: CapturaMiDia[]
|
||||
misClientes: ClienteMiDia[]
|
||||
misActividadesHoy: ActividadMiDia[]
|
||||
/** Todas las actividades ABIERTAS del usuario (sin filtro de fecha): alimenta el calendario */
|
||||
todasMisActividades: ActividadMiDia[]
|
||||
apartadosPorVencer: ApartadoPorVencer[]
|
||||
misOportunidades: { porEtapa: Record<string, number>; items: OportunidadMiDia[] } // conteo + lista de trabajo
|
||||
misApartados: ApartadoMiDia[]
|
||||
|
||||
@@ -389,10 +389,6 @@ function FilaApartado({
|
||||
window.open(`/client/?apartado=${apartado.id}`, '_blank')
|
||||
return
|
||||
}
|
||||
if (fase === 'Cancelacion Apartado') {
|
||||
onFase(fase)
|
||||
return
|
||||
}
|
||||
onFase(fase)
|
||||
}}
|
||||
className={
|
||||
@@ -976,6 +972,20 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
fecha: (a) => a.fechaLimite,
|
||||
})
|
||||
: []
|
||||
// Escrituración: apartados en fases activas posteriores a Firma Cliente
|
||||
// (Cancelo y Escriturado son terminales). Usa los filtros PROPIOS del tab
|
||||
// para que el conteo del tab refleje la lista filtrada de escrituración.
|
||||
const enEscrituracion = resumen
|
||||
? (resumen.todosApartados || resumen.misApartados).filter((a) => {
|
||||
const e = (a.estado ?? '').toUpperCase()
|
||||
return e.includes('FIRMA') || e.includes('VENTA') || e.includes('SEGUIMIENTO') || e.includes('INTEGRACION') || e.includes('X AUTORIZAR') || e.includes('AUTORIZADO') || e.includes('X ESCRITURAR')
|
||||
})
|
||||
: []
|
||||
const escrituracionFiltrados = aplicarFiltros(enEscrituracion, filtrosPorTab.escrituracion, {
|
||||
texto: (a) => a.nombre,
|
||||
estado: (a) => a.estado,
|
||||
fecha: (a) => a.fechaLimite,
|
||||
})
|
||||
|
||||
const hoy = hoyLocal()
|
||||
const iniciales = nombreVendedor
|
||||
@@ -1095,7 +1105,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, calendario: 0,
|
||||
apartados: resumen.misApartados.length, escrituracion: escrituracionFiltrados.length, tablero: 0, calendario: 0,
|
||||
}}
|
||||
onCambiar={setTabCrm}
|
||||
vistaKanban={vistaKanban}
|
||||
@@ -1211,7 +1221,15 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
items={apartadosFiltrados}
|
||||
columnas={columnasApartados}
|
||||
obtenerColumna={obtenerColumnaApartado}
|
||||
onCambiarEstado={(id, estado) => void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar')}
|
||||
onCambiarEstado={(id, estado) => {
|
||||
if (estado === 'Cancelacion Apartado') {
|
||||
// Pedir motivo antes de cambiar (ModalDescarteGenerico)
|
||||
const a = apartadosFiltrados.find((x) => x.id === id)
|
||||
if (a) setApartadoCancelando({ id: a.id, nombre: a.nombre })
|
||||
return
|
||||
}
|
||||
void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar')
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
@@ -1224,6 +1242,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
enviando={enviando}
|
||||
onVerDetalle={() => setLeadSeleccionado(a)}
|
||||
onFase={(fase) => {
|
||||
if (fase === 'Cancelacion Apartado') {
|
||||
// Pedir motivo antes de cambiar (ModalDescarteGenerico)
|
||||
setApartadoCancelando({ id: a.id, nombre: a.nombre })
|
||||
return
|
||||
}
|
||||
if (fase === 'Firma Cliente') {
|
||||
// Cambiar etapa AÚNQUE luego cambie a pestaña Escrituración
|
||||
void ejecutar(() => cambiarEstadoApartado(a.id, fase).then(() => setTabCrm('escrituracion')), 'No se pudo actualizar el apartado')
|
||||
@@ -1245,33 +1268,16 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8] mb-4">
|
||||
Seguimiento de escrituración de apartados.
|
||||
</p>
|
||||
{(() => {
|
||||
// Filtrar: solo apartados en fases de escrituración o Firma Cliente
|
||||
// Solo fases activas (Cancelo y Escriturado son terminales)
|
||||
// Fases activas de escrituración (excluye Cancelo y Escriturado que son terminales)
|
||||
const enEscrituracion = (resumen.todosApartados || resumen.misApartados || []).filter((a) => {
|
||||
const e = (a.estado ?? '').toUpperCase()
|
||||
return e.includes('FIRMA') || e.includes('VENTA') || e.includes('SEGUIMIENTO') || e.includes('INTEGRACION') || e.includes('X AUTORIZAR') || e.includes('AUTORIZADO') || e.includes('X ESCRITURAR')
|
||||
})
|
||||
const apartadosEscrituracion = aplicarFiltros(
|
||||
enEscrituracion,
|
||||
filtros,
|
||||
{
|
||||
texto: (a) => a.nombre,
|
||||
estado: (a) => a.estado,
|
||||
fecha: (a) => a.fechaLimite,
|
||||
},
|
||||
)
|
||||
return apartadosEscrituracion.length === 0 ? (
|
||||
{escrituracionFiltrados.length === 0 ? (
|
||||
<EstadoVacio mensaje="No hay apartados en escrituración" />
|
||||
) : vistaKanban ? (
|
||||
<KanbanView
|
||||
items={apartadosEscrituracion}
|
||||
items={escrituracionFiltrados}
|
||||
columnas={columnasEscrituracion}
|
||||
obtenerColumna={obtenerColumnaEscrituracion}
|
||||
onCambiarEstado={(id, estado) => {
|
||||
if (estado === 'Cancelo') {
|
||||
const a = apartadosEscrituracion.find((x) => x.id === id)
|
||||
const a = escrituracionFiltrados.find((x) => x.id === id)
|
||||
if (a) setApartadoCancelando({ id: a.id, nombre: a.nombre })
|
||||
return
|
||||
}
|
||||
@@ -1280,7 +1286,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{apartadosEscrituracion.map((apartado) => (
|
||||
{escrituracionFiltrados.map((apartado) => (
|
||||
<li key={apartado.id} className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -1314,7 +1320,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)})()}
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -1325,7 +1331,9 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
{tabCrm === 'calendario' && (
|
||||
<section aria-label="Calendario">
|
||||
<CalendarioCitas
|
||||
actividades={resumen.misActividadesHoy}
|
||||
// Todas las actividades abiertas del usuario (no solo las de hoy);
|
||||
// los resúmenes cacheados previos pueden no traer el campo nuevo.
|
||||
actividades={resumen.todasMisActividades ?? resumen.misActividadesHoy}
|
||||
oportunidades={resumen.misOportunidades.items}
|
||||
onVerDetalle={(op) => setLeadSeleccionado(op)}
|
||||
/>
|
||||
@@ -1673,8 +1681,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
'modeloInteres' in leadSeleccionado
|
||||
? (fechaHora) => {
|
||||
const op = leadSeleccionado
|
||||
// Si la op ya pasó por Agendo Cita (Cotizador/Aparto) NO se
|
||||
// retrocede la etapa: solo se actualiza la fecha de la cita.
|
||||
const etapa = op.etapa === 'Cotizador' || op.etapa === 'Aparto' ? op.etapa : 'Agendo Cita'
|
||||
return ejecutar(
|
||||
() => cambiarEtapaOportunidad(op.id, 'Agendo Cita', fechaHora),
|
||||
() => cambiarEtapaOportunidad(op.id, etapa, fechaHora),
|
||||
'No se pudo guardar la cita',
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user