feat(mi-dia): gate de afluencia para apartar, pestana Afluencia en bitacora, cita cliente con fecha en calendario, filtros por desarrollo/origen corregidos, rangos salariales nuevos y soporte como acceso directo

This commit is contained in:
2026-09-25 10:23:24 +00:00
parent 8a5db114ec
commit 7f663208a0
19 changed files with 381 additions and 501 deletions

View File

@@ -1,197 +1,18 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react'
import {
DEPARTAMENTOS_SOPORTE,
ESTADOS_SOPORTE,
MAX_ADJUNTOS_SOPORTE,
PRIORIDADES_SOPORTE,
cambiarEstadoSolicitud,
crearSolicitudSoporte,
descargarArchivoSolicitud,
listarSolicitudesSoporte,
nombreArchivoSoporte,
validarAdjuntoSoporte,
type EstadoSoporte,
type SolicitudSoporte,
} from '../services/soporte'
import { getSesion } from '../services/auth'
/**
* Módulo de Soporte: acceso directo al formulario oficial de solicitudes
* internas (Monday WorkForms). Ya no se registra nada en este sistema: el
* botón abre el formulario oficial en una pestaña nueva.
*/
const URL_FORMULARIO_SOPORTE = 'https://wkf.ms/3T5NyTY'
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 OPCIONES_ESTADO = [
{ valor: '', etiqueta: 'Todos los estatus' },
...ESTADOS_SOPORTE.map((e) => ({ valor: e, etiqueta: ETIQUETA_ESTADO[e] ?? e })),
]
const OPCIONES_DEPARTAMENTO = [
{ valor: '', etiqueta: 'Todos los departamentos' },
...DEPARTAMENTOS_SOPORTE.map((d) => ({ valor: d, etiqueta: d })),
]
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 puedeGestionar = sesion?.usuario.rol === 'admin' || sesion?.usuario.rol === 'manager'
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 [email, setEmail] = useState('')
const [archivos, setArchivos] = useState<File[]>([])
const [errorArchivos, setErrorArchivos] = useState('')
const [enviando, setEnviando] = useState(false)
const [aviso, setAviso] = useState('')
const [error, setError] = useState('')
const [solicitudes, setSolicitudes] = useState<SolicitudSoporte[]>([])
const [cargando, setCargando] = useState(true)
const [filtroEstado, setFiltroEstado] = useState('')
const [filtroDepartamento, setFiltroDepartamento] = useState('')
const [ocupado, setOcupado] = useState('')
const cargar = useCallback(async () => {
setCargando(true)
try {
setSolicitudes(
await listarSolicitudesSoporte({
...(filtroEstado ? { estado: filtroEstado } : {}),
...(filtroDepartamento ? { departamento: filtroDepartamento } : {}),
}),
)
} catch {
// La lista es secundaria: el form sigue usable si el listado falla
} finally {
setCargando(false)
}
}, [filtroEstado, filtroDepartamento])
useEffect(() => {
void cargar()
}, [cargar])
const agregarArchivos = (lista: FileList | null) => {
if (lista === null) return
const nuevos: File[] = []
const rechazados: string[] = []
for (const archivo of Array.from(lista)) {
const problema = validarAdjuntoSoporte(archivo)
if (problema) rechazados.push(problema)
else nuevos.push(archivo)
}
const combinados = [...archivos, ...nuevos]
if (combinados.length > MAX_ADJUNTOS_SOPORTE) {
rechazados.push(`Máximo ${MAX_ADJUNTOS_SOPORTE} archivos por solicitud.`)
}
setArchivos(combinados.slice(0, MAX_ADJUNTOS_SOPORTE))
setErrorArchivos(rechazados.join(' '))
}
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 } : {}),
...(email.trim() ? { email: email.trim() } : {}),
...(archivos.length > 0 ? { archivos } : {}),
})
setAviso(`Solicitud registrada (${ETIQUETA_ESTADO[creada.estado] ?? creada.estado}).`)
setTitulo('')
setDescripcion('')
setResponsable('')
setNotas('')
setArchivos([])
setErrorArchivos('')
setFechaLimite('')
await cargar()
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo registrar la solicitud.')
} finally {
setEnviando(false)
}
}
const descargar = async (solicitud: SolicitudSoporte, indice: number) => {
const ruta = solicitud.archivos[indice] ?? ''
setError('')
setOcupado(`${solicitud.id}:${indice}`)
try {
const blob = await descargarArchivoSolicitud(solicitud.id, indice)
const url = URL.createObjectURL(blob)
const enlace = document.createElement('a')
enlace.href = url
enlace.download = nombreArchivoSoporte(ruta)
document.body.appendChild(enlace)
enlace.click()
enlace.remove()
URL.revokeObjectURL(url)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo descargar el archivo.')
} finally {
setOcupado('')
}
}
const cambiarEstado = async (solicitud: SolicitudSoporte, estado: string) => {
setAviso('')
setError('')
setOcupado(solicitud.id)
try {
const actualizada = await cambiarEstadoSolicitud(solicitud.id, estado as EstadoSoporte)
setSolicitudes((prev) => prev.map((s) => (s.id === actualizada.id ? actualizada : s)))
setAviso(`"${actualizada.titulo}" ahora está ${ETIQUETA_ESTADO[actualizada.estado] ?? actualizada.estado}.`)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cambiar el estado.')
} finally {
setOcupado('')
}
}
return (
<main className="w-full flex-1 px-4 py-12 sm:px-6 lg:px-8 sm:py-16">
{/* Header */}
<div className="mx-auto max-w-2xl px-4 py-10">
<div className="flex flex-wrap items-center gap-4">
<button
type="button"
@@ -204,281 +25,31 @@ export function SoportePage({ onVolver }: Props) {
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-email" className="block">
<span className={labelClase}>Correo del solicitante</span>
<input
id="soporte-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="nombre@frasa.mx"
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 (PDF, JPG o PNG · máx 10 MB)</span>
<input
id="soporte-archivos"
type="file"
multiple
accept="application/pdf,image/jpeg,image/png"
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((archivo, i) => (
<li
key={`${archivo.name}-${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]"
>
{archivo.name}
<button
type="button"
aria-label={`Quitar ${archivo.name}`}
onClick={() => setArchivos((prev) => prev.filter((_, j) => j !== i))}
className="text-[#64748B] hover:text-red-600 dark:text-[#94A3B8]"
>
✕
</button>
</li>
))}
</ul>
)}
{errorArchivos && (
<p role="alert" className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800">
{errorArchivos}
</p>
)}
{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={puedeGestionar ? 'Todas las solicitudes' : 'Mis solicitudes'} className="mt-12 max-w-3xl">
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">
{puedeGestionar ? 'Todas las solicitudes' : 'Mis solicitudes'}
<div className="mt-8 rounded-2xl border border-[#E5E7EB] bg-white p-8 text-center shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<p className="text-4xl" aria-hidden="true">
🛠️
</p>
<h2 className="mt-4 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">
Solicitudes internas
</h2>
{/* Filtros del listado */}
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<label htmlFor="soporte-filtro-estado" className="block">
<span className={labelClase}>Filtrar por estatus</span>
<select
id="soporte-filtro-estado"
value={filtroEstado}
onChange={(e) => setFiltroEstado(e.target.value)}
className={inputClase}
>
{OPCIONES_ESTADO.map((o) => (
<option key={o.valor} value={o.valor}>{o.etiqueta}</option>
))}
</select>
</label>
<label htmlFor="soporte-filtro-departamento" className="block">
<span className={labelClase}>Filtrar por departamento</span>
<select
id="soporte-filtro-departamento"
value={filtroDepartamento}
onChange={(e) => setFiltroDepartamento(e.target.value)}
className={inputClase}
>
{OPCIONES_DEPARTAMENTO.map((o) => (
<option key={o.valor} value={o.valor}>{o.etiqueta}</option>
))}
</select>
</label>
</div>
{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]">
{filtroEstado || filtroDepartamento
? 'Ninguna solicitud coincide con los filtros.'
: '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>
<div className="flex items-center gap-2">
<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>
{puedeGestionar && (
<select
aria-label={`Cambiar estatus de ${s.titulo}`}
value={s.estado}
disabled={ocupado === s.id}
onChange={(e) => void cambiarEstado(s, e.target.value)}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1 text-xs text-[#374151] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
>
{ESTADOS_SOPORTE.map((e) => (
<option key={e} value={e}>{ETIQUETA_ESTADO[e] ?? e}</option>
))}
</select>
)}
</div>
</div>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{s.departamento} · {s.prioridad} · Solicitó {s.solicitante || '—'}
{s.solicitanteEmail ? ` (${s.solicitanteEmail})` : ''} ·{' '}
{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 && (
<ul className="mt-2 flex flex-wrap gap-2">
{s.archivos.map((ruta, i) => (
<li key={`${ruta}-${i}`}>
<button
type="button"
onClick={() => void descargar(s, i)}
disabled={ocupado === `${s.id}:${i}`}
className="rounded-lg border border-[#D1D5DB] bg-[#F9FAFB] px-2.5 py-1 text-xs text-[#374151] transition-colors hover:bg-[#F1F5F9] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
>
⬇ {nombreArchivoSoporte(ruta)}
</button>
</li>
))}
</ul>
)}
</li>
))}
</ul>
)}
</section>
</main>
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-[#64748B] dark:text-[#94A3B8]">
Las solicitudes de soporte y los reportes a otros departamentos ahora se registran en el
formulario oficial. Al abrirlo podrás describir tu solicitud, adjuntar archivos y elegir
el departamento responsable.
</p>
<a
href={URL_FORMULARIO_SOPORTE}
target="_blank"
rel="noopener noreferrer"
className="mt-6 inline-block rounded-lg bg-accent px-6 py-3 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
>
Abrir formulario de soporte ↗
</a>
<p className="mt-4 text-xs text-[#94A3B8]">
Se abre en una pestaña nueva: {URL_FORMULARIO_SOPORTE}
</p>
</div>
</div>
)
}