feat(7-mejoras): notas persisten + soporte + captura visible

1. Notas propagan entre etapas (convertirLead/apartar updateMany)
2. Afluencia: conexión → aviso amarillo
3. Escrituración: click nombre abre bitácora
4. Co-acreditado: opción 'no tiene'
5. Nota del alta de lead → bitácora
6. Módulo Soporte: formulario + listado solicitudes
7. Captura visible para todos
This commit is contained in:
2026-09-11 23:17:25 +00:00
parent 1ba7aa49da
commit c8357fe582
22 changed files with 693 additions and 19 deletions

View File

@@ -174,14 +174,14 @@ describe('AltaAfluenciaForm', () => {
)
})
it('con error de red muestra un mensaje reintentable', async () => {
it('con error de red muestra aviso amarillo: el registro se guardó igualmente', async () => {
fetchMock.mockRejectedValueOnce(new Error('red caída'))
render(<AltaAfluenciaForm onVolver={() => undefined} onPasarAAltaCliente={() => undefined} />)
llenarRequeridos()
fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ }))
expect((await screen.findByRole('alert')).textContent).toContain(
'No se pudo registrar el lead. Revisa tu conexión e intenta de nuevo.',
expect((await screen.findByRole('status')).textContent).toContain(
'La afluencia se registró, pero hubo un problema de conexión. Verifica en Mi día.',
)
})

View File

@@ -75,6 +75,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
const [leadCreado, setLeadCreado] = useState<LeadResumen | null>(null)
const [asignacion, setAsignacion] = useState<'capturista' | 'round-robin' | null>(null)
const [aviso, setAviso] = useState('')
const [avisoTipo, setAvisoTipo] = useState<'error' | 'advertencia'>('error')
const cambiar = (campo: keyof typeof FORM_INICIAL) => (e: { target: { value: string } }) => {
setForm((prev) => ({ ...prev, [campo]: e.target.value }))
@@ -190,8 +191,15 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
})
setAsignacion(res.lead.asignacion ?? 'round-robin')
} else if (res.motivo === 'duplicado') {
setAvisoTipo('error')
setAviso(`Ya existe un lead con ese teléfono: ${res.nombre}.`)
} else if (res.motivo === 'conexion') {
// El backend es local: el registro YA se guardó aunque el fetch se haya
// cortado (timeout/red). Aviso amarillo en vez de error rojo.
setAvisoTipo('advertencia')
setAviso('La afluencia se registró, pero hubo un problema de conexión. Verifica en Mi día.')
} else {
setAvisoTipo('error')
setAviso('No se pudo registrar el lead. Revisa tu conexión e intenta de nuevo.')
}
}
@@ -583,7 +591,13 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
</label>
</div>
{aviso && (
{aviso && avisoTipo === 'advertencia' && (
<p role="status" className="mt-6 rounded-lg border border-yellow-200 bg-yellow-50 px-4 py-3 text-xs font-medium text-yellow-800">
{aviso}
</p>
)}
{aviso && avisoTipo === 'error' && (
<p role="alert" className="mt-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700">
{aviso}
</p>

View File

@@ -17,9 +17,28 @@ export default function CoacreditadoForm({ valores, onAnterior, onSiguiente }: P
})
const tieneCoacreditado = metodos.watch('tieneCoacreditado')
// "El cliente no tiene co-acreditado" es el espejo del toggle: marcado oculta
// todos los campos y el schema valida sin datos (superRefine solo exige el
// nombre cuando hay co-acreditado).
const cambiarSinCoacreditado = (e: { target: { checked: boolean } }) => {
metodos.setValue('tieneCoacreditado', !e.target.checked, { shouldValidate: false })
metodos.clearErrors()
}
return (
<FormProvider {...metodos}>
<form onSubmit={metodos.handleSubmit(onSiguiente)} noValidate>
<label htmlFor="sinCoacreditado" className="flex items-center gap-3 px-6 py-5">
<input
id="sinCoacreditado"
type="checkbox"
className="h-5 w-5 accent-accent"
checked={!tieneCoacreditado}
onChange={cambiarSinCoacreditado}
/>
<span className="text-sm font-medium text-[#111827]">El cliente no tiene co-acreditado</span>
</label>
<label htmlFor="tieneCoacreditado" className="flex items-center gap-3 px-6 py-5">
<input
id="tieneCoacreditado"

View File

@@ -7,10 +7,11 @@ import { VistaContactos } from './components/VistaContactos'
import { ConfigPage } from './components/config/ConfigPage'
import { CapturaPage } from './pages/CapturaPage'
import { ClientesMaestroPage } from './pages/ClientesMaestroPage'
import { SoportePage } from './pages/SoportePage'
import { getToken, type UsuarioSesion } from './services/auth'
import { getMe } from './services/api'
type Vista = 'home' | 'contactos' | 'captura' | 'clientes-maestro' | 'configuraciones'
type Vista = 'home' | 'contactos' | 'captura' | 'clientes-maestro' | 'soporte' | 'configuraciones'
export default function App() {
const [vista, setVista] = useState<Vista>('home')
@@ -74,6 +75,16 @@ export default function App() {
)
}
if (vista === 'soporte') {
return (
<div className="flex min-h-screen flex-col">
<Navbar usuario={usuario} onConfiguraciones={() => setVista('configuraciones')} />
<SoportePage onVolver={() => setVista('home')} />
</div>
)
}
if (vista === 'configuraciones' && usuario?.rol === 'admin') {
return (
<div className="flex min-h-screen flex-col">
@@ -115,7 +126,9 @@ export default function App() {
? () => setVista('captura')
: modulo.id === 'clientes-maestro'
? () => setVista('clientes-maestro')
: undefined
: modulo.id === 'soporte'
? () => setVista('soporte')
: undefined
}
/>
))}

View File

@@ -8,8 +8,8 @@ const porId = (id: string): Modulo => {
}
describe('MODULOS', () => {
it('expone los 9 módulos del ecosistema', () => {
expect(MODULOS).toHaveLength(9)
it('expone los 10 módulos del ecosistema', () => {
expect(MODULOS).toHaveLength(10)
expect(MODULOS.map((m) => m.id)).toEqual([
'cotizador',
'alta-clientes',
@@ -20,6 +20,7 @@ describe('MODULOS', () => {
'contactos',
'captura',
'clientes-maestro',
'soporte',
])
})

View File

@@ -71,6 +71,13 @@ export const MODULOS: Modulo[] = [
descripcion: 'Base de datos maestra de clientes: búsqueda, filtros y exportación',
claves: ['clientes-maestro'],
},
{
id: 'soporte',
nombre: 'Soporte',
descripcion: 'Solicitudes a los departamentos internos: Sistemas, Administración, Ventas y más',
// Visible para todos: la clave 'soporte' se agrega a todos los roles del API
claves: ['soporte'],
},
]
/**

View File

@@ -0,0 +1,325 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react'
import {
DEPARTAMENTOS_SOPORTE,
PRIORIDADES_SOPORTE,
crearSolicitudSoporte,
listarSolicitudesSoporte,
type SolicitudSoporte,
} from '../services/soporte'
import { getSesion } from '../services/auth'
interface Props {
onVolver: () => void
}
const inputClase =
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]'
const labelClase = 'mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
const ETIQUETA_ESTADO: Record<string, string> = {
ABIERTA: 'Abierta',
EN_PROCESO: 'En proceso',
CERRADA: 'Cerrada',
}
const CLASE_ESTADO: Record<string, string> = {
ABIERTA: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300',
EN_PROCESO: 'border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-300',
CERRADA: 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-300',
}
/** Fecha de hoy en formato YYYY-MM-DD (hora local) */
function hoyISO(): string {
const hoy = new Date()
const mes = String(hoy.getMonth() + 1).padStart(2, '0')
const dia = String(hoy.getDate()).padStart(2, '0')
return `${hoy.getFullYear()}-${mes}-${dia}`
}
export function SoportePage({ onVolver }: Props) {
const sesion = getSesion()
const [titulo, setTitulo] = useState('')
const [descripcion, setDescripcion] = useState('')
const [departamento, setDepartamento] = useState<string>(DEPARTAMENTOS_SOPORTE[0])
const [prioridad, setPrioridad] = useState<string>('Media')
const [responsable, setResponsable] = useState('')
const [fechaSolicitud, setFechaSolicitud] = useState(hoyISO())
const [fechaLimite, setFechaLimite] = useState('')
const [notas, setNotas] = useState('')
const [archivos, setArchivos] = useState<string[]>([])
const [enviando, setEnviando] = useState(false)
const [aviso, setAviso] = useState('')
const [error, setError] = useState('')
const [solicitudes, setSolicitudes] = useState<SolicitudSoporte[]>([])
const [cargando, setCargando] = useState(true)
const cargar = useCallback(async () => {
setCargando(true)
try {
setSolicitudes(await listarSolicitudesSoporte())
} catch {
// La lista es secundaria: el form sigue usable si el listado falla
} finally {
setCargando(false)
}
}, [])
useEffect(() => {
void cargar()
}, [cargar])
const agregarArchivos = (lista: FileList | null) => {
if (lista === null) return
setArchivos((prev) => [...prev, ...Array.from(lista).map((f) => f.name)])
}
const enviar = async (e: FormEvent) => {
e.preventDefault()
setAviso('')
setError('')
if (!titulo.trim() || !descripcion.trim()) {
setError('El título y la descripción son obligatorios.')
return
}
setEnviando(true)
try {
const creada = await crearSolicitudSoporte({
titulo: titulo.trim(),
descripcion: descripcion.trim(),
departamento: departamento as (typeof DEPARTAMENTOS_SOPORTE)[number],
prioridad: prioridad as (typeof PRIORIDADES_SOPORTE)[number],
...(responsable.trim() ? { responsable: responsable.trim() } : {}),
fechaSolicitud,
...(fechaLimite ? { fechaLimite } : {}),
...(notas.trim() ? { notas: notas.trim() } : {}),
...(sesion?.usuario.nombre ? { solicitante: sesion.usuario.nombre } : {}),
...(archivos.length > 0 ? { archivos } : {}),
})
setAviso(`Solicitud registrada (${ETIQUETA_ESTADO[creada.estado] ?? creada.estado}).`)
setTitulo('')
setDescripcion('')
setResponsable('')
setNotas('')
setArchivos([])
setFechaLimite('')
await cargar()
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo registrar la solicitud.')
} finally {
setEnviando(false)
}
}
return (
<main className="w-full flex-1 px-4 py-12 sm:px-6 lg:px-8 sm:py-16">
{/* Header */}
<div className="flex flex-wrap items-center gap-4">
<button
type="button"
onClick={onVolver}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
>
&larr; Volver
</button>
<h1 className="text-2xl font-semibold tracking-tight text-[#111827] dark:text-[#E2E8F0] sm:text-3xl">
Soporte
</h1>
</div>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-[#64748B] dark:text-[#94A3B8]">
Registra una solicitud para los departamentos internos y dale seguimiento desde esta misma
pantalla.
</p>
{/* Formulario */}
<form onSubmit={(e) => void enviar(e)} noValidate className="mt-8 max-w-3xl">
<div className="grid gap-5 sm:grid-cols-2">
<label htmlFor="soporte-titulo" className="block sm:col-span-2">
<span className={labelClase}>Título<span className="text-red-700"> *</span></span>
<input
id="soporte-titulo"
type="text"
value={titulo}
onChange={(e) => setTitulo(e.target.value)}
className={inputClase}
/>
</label>
<label htmlFor="soporte-descripcion" className="block sm:col-span-2">
<span className={labelClase}>Descripción<span className="text-red-700"> *</span></span>
<textarea
id="soporte-descripcion"
rows={4}
value={descripcion}
onChange={(e) => setDescripcion(e.target.value)}
className={inputClase}
/>
</label>
<label htmlFor="soporte-departamento" className="block">
<span className={labelClase}>Departamento</span>
<select
id="soporte-departamento"
value={departamento}
onChange={(e) => setDepartamento(e.target.value)}
className={inputClase}
>
{DEPARTAMENTOS_SOPORTE.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
</label>
<label htmlFor="soporte-prioridad" className="block">
<span className={labelClase}>Prioridad</span>
<select
id="soporte-prioridad"
value={prioridad}
onChange={(e) => setPrioridad(e.target.value)}
className={inputClase}
>
{PRIORIDADES_SOPORTE.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
</label>
<label htmlFor="soporte-responsable" className="block">
<span className={labelClase}>Responsable</span>
<input
id="soporte-responsable"
type="text"
value={responsable}
onChange={(e) => setResponsable(e.target.value)}
className={inputClase}
/>
</label>
<label htmlFor="soporte-fecha-solicitud" className="block">
<span className={labelClase}>Fecha de solicitud</span>
<input
id="soporte-fecha-solicitud"
type="date"
value={fechaSolicitud}
onChange={(e) => setFechaSolicitud(e.target.value)}
className={inputClase}
/>
</label>
<label htmlFor="soporte-fecha-limite" className="block">
<span className={labelClase}>Fecha límite</span>
<input
id="soporte-fecha-limite"
type="date"
value={fechaLimite}
onChange={(e) => setFechaLimite(e.target.value)}
className={inputClase}
/>
</label>
<label htmlFor="soporte-archivos" className="block">
<span className={labelClase}>Archivos</span>
<input
id="soporte-archivos"
type="file"
multiple
onChange={(e) => agregarArchivos(e.target.files)}
className={`${inputClase} file:mr-3 file:rounded-md file:border-0 file:bg-[#F1F5F9] file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-[#374151]`}
/>
</label>
<label htmlFor="soporte-notas" className="block sm:col-span-2">
<span className={labelClase}>Notas</span>
<textarea
id="soporte-notas"
rows={2}
value={notas}
onChange={(e) => setNotas(e.target.value)}
className={inputClase}
/>
</label>
</div>
{archivos.length > 0 && (
<ul className="mt-3 flex flex-wrap gap-2">
{archivos.map((nombre, i) => (
<li
key={`${nombre}-${i}`}
className="flex items-center gap-2 rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] px-3 py-1.5 text-xs text-[#374151] dark:border-[#1E293B] dark:bg-[#1E293B] dark:text-[#CBD5E1]"
>
{nombre}
<button
type="button"
aria-label={`Quitar ${nombre}`}
onClick={() => setArchivos((prev) => prev.filter((_, j) => j !== i))}
className="text-[#64748B] hover:text-red-600 dark:text-[#94A3B8]"
>
✕
</button>
</li>
))}
</ul>
)}
{error && (
<p role="alert" className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700">
{error}
</p>
)}
{aviso && (
<p role="status" className="mt-4 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-xs font-medium text-green-700">
{aviso}
</p>
)}
<button
type="submit"
disabled={enviando}
className="mt-6 rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
>
{enviando ? 'Registrando…' : 'Enviar solicitud'}
</button>
</form>
{/* Listado de solicitudes */}
<section aria-label="Mis solicitudes" className="mt-12 max-w-3xl">
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">
{sesion?.usuario.rol === 'admin' ? 'Todas las solicitudes' : 'Mis solicitudes'}
</h2>
{cargando ? (
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando…</p>
) : solicitudes.length === 0 ? (
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">Aún no hay solicitudes registradas.</p>
) : (
<ul className="mt-4 space-y-3">
{solicitudes.map((s) => (
<li
key={s.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-2">
<p className="font-medium text-[#111827] dark:text-[#E2E8F0]">{s.titulo}</p>
<span
className={`rounded-full border px-2.5 py-0.5 text-xs font-medium ${CLASE_ESTADO[s.estado] ?? 'border-[#E5E7EB] text-[#64748B] dark:border-[#334155] dark:text-[#94A3B8]'}`}
>
{ETIQUETA_ESTADO[s.estado] ?? s.estado}
</span>
</div>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{s.departamento} · {s.prioridad} · Solicitó {s.solicitante || '—'} ·{' '}
{new Date(s.fechaSolicitud).toLocaleDateString('es-MX')}
</p>
<p className="mt-2 text-sm text-[#374151] dark:text-[#CBD5E1]">{s.descripcion}</p>
{s.archivos.length > 0 && (
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">
Archivos: {s.archivos.join(', ')}
</p>
)}
</li>
))}
</ul>
)}
</section>
</main>
)
}

View File

@@ -0,0 +1,73 @@
import { API_URL, conAuth, manejar401 } from './auth'
/** Departamentos a los que se puede dirigir una solicitud de soporte */
export const DEPARTAMENTOS_SOPORTE = [
'Sistemas',
'Administración',
'Ventas',
'Cobranza',
'Escrituración',
'Dirección',
] as const
/** Prioridades de una solicitud */
export const PRIORIDADES_SOPORTE = ['Baja', 'Media', 'Alta', 'Urgente'] as const
export type DepartamentoSoporte = (typeof DEPARTAMENTOS_SOPORTE)[number]
export type PrioridadSoporte = (typeof PRIORIDADES_SOPORTE)[number]
export interface SolicitudSoporte {
id: string
titulo: string
descripcion: string
departamento: string
prioridad: string
responsable: string | null
fechaSolicitud: string
fechaLimite: string | null
notas: string | null
archivos: string[]
solicitante: string
solicitanteEmail: string | null
estado: string
createdAt: string
updatedAt: string
}
/** POST /api/soporte — crear solicitud de soporte */
export async function crearSolicitudSoporte(payload: {
titulo: string
descripcion: string
departamento: DepartamentoSoporte
prioridad: PrioridadSoporte
responsable?: string
fechaSolicitud: string
fechaLimite?: string
notas?: string
solicitante?: string
email?: string
archivos?: string[]
}): Promise<SolicitudSoporte> {
const resp = await fetch(`${API_URL}/soporte`, conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}))
manejar401(resp)
if (!resp.ok) {
const body = (await resp.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `HTTP ${resp.status}`)
}
return (await resp.json()) as SolicitudSoporte
}
/** GET /api/soporte — solicitudes propias (admin: todas) */
export async function listarSolicitudesSoporte(): Promise<SolicitudSoporte[]> {
const resp = await fetch(`${API_URL}/soporte`, conAuth())
manejar401(resp)
if (!resp.ok) {
const body = (await resp.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `HTTP ${resp.status}`)
}
return (await resp.json()) as SolicitudSoporte[]
}

View File

@@ -1290,7 +1290,13 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<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>
<p className="font-medium text-[#111827] dark:text-[#E2E8F0]">{apartado.nombre}</p>
<button
type="button"
onClick={() => setLeadSeleccionado(apartado)}
className="font-medium text-[#111827] hover:text-accent hover:underline dark:text-[#E2E8F0]"
>
{apartado.nombre}
</button>
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">{apartado.unidadCodigo || 'Sin unidad'} · {apartado.estado}</p>
</div>
</div>