style: rediseno de layout profesional en las 7 apps

Headers sticky con marca y acciones, contenedores max-w-[1280px],
grids de contenido con rails laterales sticky (cotizador, mi-dia,
detalle de cobranza), stepper vertical en el wizard de alta, toolbar+
tabla unificadas en cobranza, tabs como segmented control en
dashboards, chat con barra de entrada fija y preguntas sugeridas,
landing con navbar + hero + grid de modulos + footer.
This commit is contained in:
2026-09-03 04:28:56 +00:00
parent 599dce2629
commit d59c8c0d7e
39 changed files with 1622 additions and 1235 deletions

View File

@@ -7,6 +7,13 @@ import type { Mensaje } from '../types'
let contadorLocal = 0 let contadorLocal = 0
const idLocal = () => `local-${++contadorLocal}` const idLocal = () => `local-${++contadorLocal}`
const PREGUNTAS_SUGERIDAS = [
'¿Qué incluye la propuesta del cotizador DIAGONAL?',
'¿Cuáles son los pasos del proceso de venta?',
'¿Qué políticas de Frasa debo conocer?',
'¿Qué modelos hay en el catálogo DIAGONAL?',
]
export default function ChatPage() { export default function ChatPage() {
const [sessionId, setSessionId] = useState<string | null>(null) const [sessionId, setSessionId] = useState<string | null>(null)
const [mensajes, setMensajes] = useState<Mensaje[]>([]) const [mensajes, setMensajes] = useState<Mensaje[]>([])
@@ -70,8 +77,8 @@ export default function ChatPage() {
return ( return (
<div className="flex min-h-screen flex-col bg-[#F8FAFC] text-[#111827]"> <div className="flex min-h-screen flex-col bg-[#F8FAFC] text-[#111827]">
<header className="border-b border-[#E5E7EB] bg-white"> <header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
<div className="mx-auto flex w-full max-w-3xl items-center justify-between px-4 py-4"> <div className="mx-auto flex h-14 w-full max-w-3xl items-center justify-between px-4 sm:px-6">
<span className="text-sm tracking-wide"> <span className="text-sm tracking-wide">
<span className="font-bold">FRASA</span> <span className="font-bold">FRASA</span>
<span className="text-[#F97316]"> · </span> <span className="text-[#F97316]"> · </span>
@@ -80,14 +87,14 @@ export default function ChatPage() {
<button <button
type="button" type="button"
onClick={nuevaConversacion} onClick={nuevaConversacion}
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]" className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
> >
Nueva conversación Nueva conversación
</button> </button>
</div> </div>
</header> </header>
<main className="mx-auto flex w-full max-w-3xl flex-1 flex-col px-4 py-8"> <main className="mx-auto flex w-full max-w-3xl flex-1 flex-col px-4 py-8 sm:px-6">
{sinConexion && ( {sinConexion && (
<p className="mb-6 rounded-lg bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700"> <p className="mb-6 rounded-lg bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700">
Sin conexión al API del asistente. Revisa que el servicio esté disponible e inténtalo de Sin conexión al API del asistente. Revisa que el servicio esté disponible e inténtalo de
@@ -101,35 +108,50 @@ export default function ChatPage() {
)} )}
{mensajes.length === 0 && !escribiendo ? ( {mensajes.length === 0 && !escribiendo ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center"> <div className="flex flex-1 flex-col items-center justify-center">
<div className="w-full max-w-lg rounded-xl border border-[#E5E7EB] bg-white p-6 text-center shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]"> <p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Asistente interno Asistente interno
</p> </p>
<p className="max-w-md text-2xl font-light text-[#111827]"> <p className="mt-3 text-xl font-light leading-relaxed text-[#111827]">
Pregunta sobre el catálogo DIAGONAL, las propuestas del cotizador, el proceso de venta Pregunta sobre el catálogo DIAGONAL, las propuestas del cotizador, el proceso de
o las políticas de Frasa. venta o las políticas de Frasa.
</p> </p>
<div className="mt-6 grid gap-2 sm:grid-cols-2">
{PREGUNTAS_SUGERIDAS.map((pregunta) => (
<button
key={pregunta}
type="button"
onClick={() => setEntrada(pregunta)}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-left text-xs font-medium text-[#374151] transition-colors hover:border-accent hover:text-accent"
>
{pregunta}
</button>
))}
</div>
</div>
</div> </div>
) : ( ) : (
<div className="flex flex-1 flex-col gap-6"> <div className="flex flex-1 flex-col gap-6">
{mensajes.map((m) => ( {mensajes.map((m) => (
<Burbuja key={m.id} mensaje={m} /> <Burbuja key={m.id} mensaje={m} />
))} ))}
{escribiendo && (
<div className="flex justify-start">
<p className="rounded-full bg-white px-4 py-2 text-xs font-medium text-[#64748B] shadow-sm">
Escribiendo…
</p>
</div>
)}
<div ref={finalRef} /> <div ref={finalRef} />
</div> </div>
)} )}
</main> </main>
<footer className="border-t border-[#E5E7EB] bg-white"> <footer className="sticky bottom-0 border-t border-[#E5E7EB] bg-white">
<div className="relative mx-auto w-full max-w-3xl px-4 sm:px-6">
{escribiendo && (
<div className="pointer-events-none absolute -top-11 left-4 sm:left-6">
<p className="rounded-full border border-[#E5E7EB] bg-white px-4 py-2 text-xs font-medium text-[#64748B] shadow-md">
Escribiendo…
</p>
</div>
)}
<form <form
className="mx-auto flex w-full max-w-3xl items-stretch gap-4 px-4 py-4" className="flex items-stretch gap-3 py-4"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault() e.preventDefault()
void enviar() void enviar()
@@ -155,6 +177,7 @@ export default function ChatPage() {
Enviar Enviar
</button> </button>
</form> </form>
</div>
</footer> </footer>
</div> </div>
) )

View File

@@ -173,7 +173,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
} }
return ( return (
<form onSubmit={(e) => void enviar(e)} noValidate className="max-w-2xl"> <form onSubmit={(e) => void enviar(e)} noValidate>
<div className="grid gap-6 sm:grid-cols-2"> <div className="grid gap-6 sm:grid-cols-2">
<label htmlFor="afluencia-nombre" className="block sm:col-span-2"> <label htmlFor="afluencia-nombre" className="block sm:col-span-2">
<span className={labelClase}> <span className={labelClase}>

View File

@@ -0,0 +1,21 @@
import type { ReactNode } from 'react'
interface Props {
titulo: string
acciones?: ReactNode
}
/** Barra superior de la app: marca a la izquierda, acciones a la derecha */
export default function AppHeader({ titulo, acciones }: Props) {
return (
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-[1280px] flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2 sm:px-6">
<span className="text-sm font-bold tracking-widest text-[#111827]">
FRASA<span className="text-[#F97316]">.</span>
</span>
<h1 className="text-xl font-semibold text-[#111827]">{titulo}</h1>
{acciones && <div className="ml-auto flex items-center gap-3">{acciones}</div>}
</div>
</header>
)
}

View File

@@ -19,7 +19,7 @@ export default function AuthGate({ children }: { children: ReactNode }) {
return ( return (
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]"> <div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
<div className="border-b border-[#E5E7EB] bg-white"> <div className="border-b border-[#E5E7EB] bg-white">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2"> <div className="mx-auto flex max-w-[1280px] items-center justify-between px-4 py-2 sm:px-6">
<span className="text-xs font-medium text-[#64748B]"> <span className="text-xs font-medium text-[#64748B]">
{sesion.usuario.nombre} · {sesion.usuario.rol} {sesion.usuario.nombre} · {sesion.usuario.rol}
</span> </span>

View File

@@ -12,7 +12,7 @@ export default function ChecklistExpediente({ documentos, firmaDataUrl }: Props)
return ( return (
<section <section
aria-label="Checklist del expediente" aria-label="Checklist del expediente"
className="rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm" className="border-t border-[#E5E7EB] px-6 py-6 first:border-t-0"
> >
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]"> <p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Expediente: {estado.completos}/{estado.total} Expediente: {estado.completos}/{estado.total}

View File

@@ -6,13 +6,13 @@ interface Props {
children: ReactNode children: ReactNode
} }
/** Tarjeta de sección del wizard */ /** Sección interna del paso del wizard (separada por divisor dentro de la tarjeta del paso) */
export default function SectionCard({ titulo, descripcion, children }: Props) { export default function SectionCard({ titulo, descripcion, children }: Props) {
return ( return (
<section className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm"> <section className="border-t border-[#E5E7EB] px-6 py-6 first:border-t-0">
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</h3> <h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</h3>
{descripcion && <p className="mt-2 text-sm text-[#64748B]">{descripcion}</p>} {descripcion && <p className="mt-2 text-sm text-[#64748B]">{descripcion}</p>}
<div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">{children}</div> <div className="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">{children}</div>
</section> </section>
) )
} }

View File

@@ -4,11 +4,47 @@ interface Props {
onIrAPaso: (paso: number) => void onIrAPaso: (paso: number) => void
} }
/** Stepper de progreso del wizard; permite volver a pasos ya visitados */ /** Palomita de paso completado */
export default function Stepper({ pasos, pasoActual, onIrAPaso }: Props) { function IconoCheck() {
return ( return (
<nav aria-label="Progreso"> <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-3.5 w-3.5">
<ol className="flex flex-wrap items-baseline gap-x-6 gap-y-3 border-b border-[#E5E7EB] pb-4"> <path
fillRule="evenodd"
d="M16.704 5.29a1 1 0 010 1.42l-7.2 7.2a1 1 0 01-1.42 0l-3.2-3.2a1 1 0 111.42-1.42l2.49 2.5 6.49-6.5a1 1 0 011.42 0z"
clipRule="evenodd"
/>
</svg>
)
}
/**
* Stepper de progreso del wizard; permite volver a pasos ya visitados.
* En lg se muestra vertical (sidebar sticky en la página); en móvil colapsa
* a una barra horizontal de progreso.
*/
export default function Stepper({ pasos, pasoActual, onIrAPaso }: Props) {
const avance = Math.round(((pasoActual + 1) / pasos.length) * 100)
return (
<>
{/* Móvil: barra horizontal de progreso */}
<div className="lg:hidden">
<div className="flex items-baseline justify-between gap-4">
<p className="text-sm font-medium text-[#111827]">{pasos[pasoActual]}</p>
<p className="text-xs text-[#64748B]">
Paso {pasoActual + 1} de {pasos.length}
</p>
</div>
<div className="mt-2 h-1.5 rounded-full bg-[#E5E7EB]">
<div
className="h-full rounded-full bg-accent transition-all duration-200"
style={{ width: `${avance}%` }}
/>
</div>
</div>
{/* Escritorio: lista vertical con el estado de cada paso */}
<nav aria-label="Progreso" className="hidden lg:block">
<ol className="space-y-1">
{pasos.map((nombre, i) => { {pasos.map((nombre, i) => {
const activo = i === pasoActual const activo = i === pasoActual
const completado = i < pasoActual const completado = i < pasoActual
@@ -19,22 +55,33 @@ export default function Stepper({ pasos, pasoActual, onIrAPaso }: Props) {
onClick={() => completado && onIrAPaso(i)} onClick={() => completado && onIrAPaso(i)}
disabled={!completado} disabled={!completado}
aria-current={activo ? 'step' : undefined} aria-current={activo ? 'step' : undefined}
className={`text-xs font-medium transition-colors duration-150 ${ className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors duration-150 ${
activo activo
? 'font-semibold text-accent' ? 'bg-accent/5 font-semibold text-accent'
: completado : completado
? 'text-[#111827] hover:text-accent' ? 'text-[#111827] hover:bg-[#F1F5F9]'
: 'text-[#94A3B8]' : 'text-[#94A3B8]'
}`} }`}
title={nombre}
> >
<span className="mr-2">{String(i + 1).padStart(2, '0')}</span> <span
<span className="hidden md:inline">{nombre}</span> aria-hidden="true"
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium ${
activo
? 'bg-accent text-white'
: completado
? 'bg-green-100 text-green-700'
: 'border border-[#D1D5DB] bg-white text-[#94A3B8]'
}`}
>
{completado ? <IconoCheck /> : i + 1}
</span>
<span className="truncate">{nombre}</span>
</button> </button>
</li> </li>
) )
})} })}
</ol> </ol>
</nav> </nav>
</>
) )
} }

View File

@@ -20,7 +20,7 @@ export default function CoacreditadoForm({ valores, onAnterior, onSiguiente }: P
return ( return (
<FormProvider {...metodos}> <FormProvider {...metodos}>
<form onSubmit={metodos.handleSubmit(onSiguiente)} noValidate> <form onSubmit={metodos.handleSubmit(onSiguiente)} noValidate>
<label htmlFor="tieneCoacreditado" className="flex items-center gap-3 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm"> <label htmlFor="tieneCoacreditado" className="flex items-center gap-3 px-6 py-5">
<input <input
id="tieneCoacreditado" id="tieneCoacreditado"
type="checkbox" type="checkbox"
@@ -31,7 +31,7 @@ export default function CoacreditadoForm({ valores, onAnterior, onSiguiente }: P
</label> </label>
{tieneCoacreditado && ( {tieneCoacreditado && (
<div className="mt-16 space-y-16"> <div className="border-t border-[#E5E7EB]">
<DatosGeneralesCampos prefix="generales." compacto /> <DatosGeneralesCampos prefix="generales." compacto />
<EmpleoCampos prefix="empleo." /> <EmpleoCampos prefix="empleo." />
<EconomicosCampos prefix="economicos." /> <EconomicosCampos prefix="economicos." />

View File

@@ -31,9 +31,7 @@ export default function DocumentosForm({
const ine = documentos.find((d) => d.slot === 'INE') const ine = documentos.find((d) => d.slot === 'INE')
return ( return (
<div> <div>
<div className="mb-8">
<ChecklistExpediente documentos={documentos} firmaDataUrl={firmaDataUrl} /> <ChecklistExpediente documentos={documentos} firmaDataUrl={firmaDataUrl} />
</div>
<SectionCard <SectionCard
titulo="Documentos del expediente" titulo="Documentos del expediente"
descripcion="Los archivos se conservan en memoria y se enviarán al backend junto con el alta." descripcion="Los archivos se conservan en memoria y se enviarán al backend junto con el alta."

View File

@@ -62,7 +62,7 @@ export default function FirmasForm({ alta, onCambiarFirma, onAnterior }: Props)
} }
return ( return (
<div className="space-y-16"> <div>
<SectionCard titulo="Resumen del alta"> <SectionCard titulo="Resumen del alta">
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 text-sm sm:col-span-2 lg:col-span-3 lg:grid-cols-3"> <dl className="grid grid-cols-2 gap-x-6 gap-y-4 text-sm sm:col-span-2 lg:col-span-3 lg:grid-cols-3">
<div> <div>
@@ -126,7 +126,7 @@ export default function FirmasForm({ alta, onCambiarFirma, onAnterior }: Props)
</label> </label>
</SectionCard> </SectionCard>
{error && <p className="text-xs text-red-700">{error}</p>} {error && <p className="px-6 py-2 text-xs text-red-700">{error}</p>}
<SectionCard titulo="Generar documentos PDF"> <SectionCard titulo="Generar documentos PDF">
{avisoExpediente && ( {avisoExpediente && (
@@ -158,7 +158,7 @@ export default function FirmasForm({ alta, onCambiarFirma, onAnterior }: Props)
</div> </div>
</SectionCard> </SectionCard>
<div> <div className="sticky bottom-0 flex items-center rounded-b-xl border-t border-[#E5E7EB] bg-white px-6 py-4">
<button <button
type="button" type="button"
onClick={onAnterior} onClick={onAnterior}

View File

@@ -8,7 +8,7 @@ interface Props {
/** Botones de navegación del wizard */ /** Botones de navegación del wizard */
export default function Navegacion({ onAnterior, onSiguiente, textoSiguiente = 'Siguiente' }: Props) { export default function Navegacion({ onAnterior, onSiguiente, textoSiguiente = 'Siguiente' }: Props) {
return ( return (
<div className="mt-16 flex items-center justify-between"> <div className="sticky bottom-0 flex items-center justify-between gap-4 rounded-b-xl border-t border-[#E5E7EB] bg-white px-6 py-4">
{onAnterior ? ( {onAnterior ? (
<button <button
type="button" type="button"

View File

@@ -43,7 +43,7 @@ export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: Pa
))} ))}
</SectionCard> </SectionCard>
{errorRaiz && typeof errorRaiz.message === 'string' && ( {errorRaiz && typeof errorRaiz.message === 'string' && (
<p className="mt-2 text-xs font-semibold text-red-600">{errorRaiz.message}</p> <p className="mt-2 px-6 text-xs font-semibold text-red-600">{errorRaiz.message}</p>
)} )}
<Navegacion onAnterior={onAnterior} /> <Navegacion onAnterior={onAnterior} />
</form> </form>

View File

@@ -42,7 +42,7 @@ export default function ViviendaCreditoForm({ valores, onAnterior, onSiguiente }
return ( return (
<FormProvider {...metodos}> <FormProvider {...metodos}>
<form onSubmit={metodos.handleSubmit(onSiguiente)} noValidate> <form onSubmit={metodos.handleSubmit(onSiguiente)} noValidate>
<div className="space-y-16"> <div>
<SectionCard titulo="Datos vivienda"> <SectionCard titulo="Datos vivienda">
<CampoSelect <CampoSelect
nombre="proyecto" nombre="proyecto"

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState, type ReactNode } from 'react'
import AppHeader from '../components/AppHeader'
import Stepper from '../components/Stepper' import Stepper from '../components/Stepper'
import DatosGeneralesForm from '../forms/DatosGeneralesForm' import DatosGeneralesForm from '../forms/DatosGeneralesForm'
import EmpleoForm from '../forms/EmpleoForm' import EmpleoForm from '../forms/EmpleoForm'
@@ -40,6 +41,36 @@ function paramCotizacion(): string | null {
const formatoFechaHora = (iso: string): string => const formatoFechaHora = (iso: string): string =>
new Date(iso).toLocaleString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) new Date(iso).toLocaleString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
/** Tarjeta del paso actual del wizard (el stepper queda fuera, a la izquierda en lg) */
function TarjetaPaso({ children }: { children: ReactNode }) {
return <div className="rounded-xl border border-[#E5E7EB] bg-white shadow-sm">{children}</div>
}
/** Cuadro con las iniciales de la opción (icono de la tarjeta de entrada) */
function IconoIniciales({ iniciales }: { iniciales: string }) {
return (
<span
aria-hidden="true"
className="flex h-11 w-11 items-center justify-center rounded-lg bg-accent/10 text-sm font-semibold text-accent"
>
{iniciales}
</span>
)
}
/** Flecha del CTA de las tarjetas de entrada */
function IconoFlecha() {
return (
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-4 w-4">
<path
fillRule="evenodd"
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
)
}
export default function AltaClientePage() { export default function AltaClientePage() {
const { const {
paso, paso,
@@ -239,7 +270,7 @@ export default function AltaClientePage() {
if (pantalla === 'cargando') { if (pantalla === 'cargando') {
return ( return (
<div className="min-h-screen bg-[#F8FAFC]"> <div className="min-h-screen bg-[#F8FAFC]">
<main className="mx-auto max-w-6xl px-4 py-16"> <main className="mx-auto max-w-[1280px] px-4 py-16 sm:px-6">
<p className="text-sm text-[#64748B]">Cargando…</p> <p className="text-sm text-[#64748B]">Cargando…</p>
</main> </main>
</div> </div>
@@ -249,35 +280,45 @@ export default function AltaClientePage() {
if (pantalla === 'entrada') { if (pantalla === 'entrada') {
return ( return (
<div className="min-h-screen bg-[#F8FAFC]"> <div className="min-h-screen bg-[#F8FAFC]">
<header className="border-b border-[#E5E7EB] bg-white px-4 py-6"> <AppHeader titulo="Alta de Clientes" />
<div className="mx-auto flex max-w-6xl items-baseline gap-3"> <main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 sm:py-12">
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span> <div className="mx-auto max-w-4xl">
<h1 className="text-2xl font-semibold text-[#111827]">Alta de Clientes</h1> <p className="text-sm text-[#64748B]">
</div> Elige cómo registrar al interesado: captura ligera de afluencia o formato completo de alta.
</header> </p>
<main className="mx-auto max-w-6xl px-4 py-16"> <div className="mt-6 grid gap-6 sm:grid-cols-2">
<div className="grid gap-6 sm:grid-cols-2">
<button <button
type="button" type="button"
onClick={() => setPantalla('afluencia')} onClick={() => setPantalla('afluencia')}
className="rounded-xl border border-[#E5E7EB] bg-white p-8 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md" className="rounded-xl border border-[#E5E7EB] bg-white p-6 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
> >
<h2 className="text-lg font-semibold text-[#111827]">Alta Afluencia</h2> <IconoIniciales iniciales="AA" />
<p className="mt-2 text-sm text-[#64748B]"> <span className="mt-4 block text-lg font-semibold text-[#111827]">Alta Afluencia</span>
<span className="mt-2 block text-sm text-[#64748B]">
Captura rápida del interesado; entra al embudo de ventas. Captura rápida del interesado; entra al embudo de ventas.
</p> </span>
<span className="mt-5 inline-flex items-center gap-1.5 text-sm font-medium text-accent">
Comenzar
<IconoFlecha />
</span>
</button> </button>
<button <button
type="button" type="button"
onClick={() => setPantalla('picker')} onClick={() => setPantalla('picker')}
className="rounded-xl border border-[#E5E7EB] bg-white p-8 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md" className="rounded-xl border border-[#E5E7EB] bg-white p-6 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
> >
<h2 className="text-lg font-semibold text-[#111827]">Alta de Cliente</h2> <IconoIniciales iniciales="AC" />
<p className="mt-2 text-sm text-[#64748B]"> <span className="mt-4 block text-lg font-semibold text-[#111827]">Alta de Cliente</span>
<span className="mt-2 block text-sm text-[#64748B]">
Formato completo para quien aparta. Formato completo para quien aparta.
</p> </span>
<span className="mt-5 inline-flex items-center gap-1.5 text-sm font-medium text-accent">
Comenzar
<IconoFlecha />
</span>
</button> </button>
</div> </div>
</div>
</main> </main>
</div> </div>
) )
@@ -286,17 +327,19 @@ export default function AltaClientePage() {
if (pantalla === 'afluencia') { if (pantalla === 'afluencia') {
return ( return (
<div className="min-h-screen bg-[#F8FAFC]"> <div className="min-h-screen bg-[#F8FAFC]">
<header className="border-b border-[#E5E7EB] bg-white px-4 py-6"> <AppHeader titulo="Alta Afluencia" />
<div className="mx-auto flex max-w-6xl items-baseline gap-3"> <main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 sm:py-10">
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span> <div className="mx-auto max-w-3xl">
<h1 className="text-2xl font-semibold text-[#111827]">Alta Afluencia</h1> <p className="text-sm text-[#64748B]">
</div> Captura rápida del interesado; entra al embudo de ventas.
</header> </p>
<main className="mx-auto max-w-6xl px-4 py-16"> <div className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
<AltaAfluenciaForm <AltaAfluenciaForm
onVolver={() => setPantalla('entrada')} onVolver={() => setPantalla('entrada')}
onPasarAAltaCliente={empezarDesdeLead} onPasarAAltaCliente={empezarDesdeLead}
/> />
</div>
</div>
</main> </main>
</div> </div>
) )
@@ -305,13 +348,9 @@ export default function AltaClientePage() {
if (pantalla === 'picker') { if (pantalla === 'picker') {
return ( return (
<div className="min-h-screen bg-[#F8FAFC]"> <div className="min-h-screen bg-[#F8FAFC]">
<header className="border-b border-[#E5E7EB] bg-white px-4 py-6"> <AppHeader titulo="Alta de Clientes" />
<div className="mx-auto flex max-w-6xl items-baseline gap-3"> <main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 sm:py-10">
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span> <div className="max-w-3xl">
<h1 className="text-2xl font-semibold text-[#111827]">Alta de Clientes</h1>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-16">
{borradores.length > 0 && ( {borradores.length > 0 && (
<> <>
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]"> <h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
@@ -368,6 +407,7 @@ export default function AltaClientePage() {
<BusquedaLead onSeleccion={empezarDesdeLead} /> <BusquedaLead onSeleccion={empezarDesdeLead} />
</div> </div>
</section> </section>
</div>
</main> </main>
</div> </div>
) )
@@ -375,11 +415,10 @@ export default function AltaClientePage() {
return ( return (
<div className="min-h-screen bg-[#F8FAFC]"> <div className="min-h-screen bg-[#F8FAFC]">
<header className="border-b border-[#E5E7EB] bg-white px-4 py-6"> <AppHeader
<div className="mx-auto flex max-w-6xl flex-wrap items-baseline gap-3"> titulo="Alta de Clientes"
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span> acciones={
<h1 className="text-2xl font-semibold text-[#111827]">Alta de Clientes</h1> <>
<div className="ml-auto flex items-center gap-3">
{avisoBorrador && ( {avisoBorrador && (
<span role="status" className="text-xs text-[#64748B]"> <span role="status" className="text-xs text-[#64748B]">
{avisoBorrador} {avisoBorrador}
@@ -397,21 +436,25 @@ export default function AltaClientePage() {
> >
Guardar borrador Guardar borrador
</button> </button>
</div> </>
</div> }
</header> />
<main className="mx-auto max-w-6xl px-4 py-16"> <main className="mx-auto max-w-[1280px] px-4 py-6 sm:px-6 sm:py-8">
<div className="lg:grid lg:grid-cols-[260px_minmax(0,1fr)] lg:gap-8">
<div className="mb-6 lg:sticky lg:top-20 lg:mb-0 lg:self-start">
<Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPaso} /> <Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPaso} />
</div>
<div className="w-full max-w-3xl">
{errorCarga && ( {errorCarga && (
<p role="alert" className="mt-8 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"> <p role="alert" className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700">
{errorCarga} {errorCarga}
</p> </p>
)} )}
<div className="mt-16">
{paso === 0 && ( {paso === 0 && (
<TarjetaPaso>
<DatosGeneralesForm <DatosGeneralesForm
valores={datos.datosGenerales} valores={datos.datosGenerales}
onSiguiente={(v) => { onSiguiente={(v) => {
@@ -420,8 +463,10 @@ export default function AltaClientePage() {
guardarAvance({ ...datos, datosGenerales: v }, paso + 1) guardarAvance({ ...datos, datosGenerales: v }, paso + 1)
}} }}
/> />
</TarjetaPaso>
)} )}
{paso === 1 && ( {paso === 1 && (
<TarjetaPaso>
<EmpleoForm <EmpleoForm
valores={datos.empleo} valores={datos.empleo}
onAnterior={anterior} onAnterior={anterior}
@@ -431,8 +476,10 @@ export default function AltaClientePage() {
guardarAvance({ ...datos, empleo: v }, paso + 1) guardarAvance({ ...datos, empleo: v }, paso + 1)
}} }}
/> />
</TarjetaPaso>
)} )}
{paso === 2 && ( {paso === 2 && (
<TarjetaPaso>
<EconomicosForm <EconomicosForm
valores={datos.economicos} valores={datos.economicos}
onAnterior={anterior} onAnterior={anterior}
@@ -442,8 +489,10 @@ export default function AltaClientePage() {
guardarAvance({ ...datos, economicos: v }, paso + 1) guardarAvance({ ...datos, economicos: v }, paso + 1)
}} }}
/> />
</TarjetaPaso>
)} )}
{paso === 3 && ( {paso === 3 && (
<TarjetaPaso>
<CoacreditadoForm <CoacreditadoForm
valores={datos.coacreditado} valores={datos.coacreditado}
onAnterior={anterior} onAnterior={anterior}
@@ -453,8 +502,10 @@ export default function AltaClientePage() {
guardarAvance({ ...datos, coacreditado: v }, paso + 1) guardarAvance({ ...datos, coacreditado: v }, paso + 1)
}} }}
/> />
</TarjetaPaso>
)} )}
{paso === 4 && ( {paso === 4 && (
<TarjetaPaso>
<ReferenciasForm <ReferenciasForm
valores={datos.referencias} valores={datos.referencias}
onAnterior={anterior} onAnterior={anterior}
@@ -464,8 +515,10 @@ export default function AltaClientePage() {
guardarAvance({ ...datos, referencias: v }, paso + 1) guardarAvance({ ...datos, referencias: v }, paso + 1)
}} }}
/> />
</TarjetaPaso>
)} )}
{paso === 5 && ( {paso === 5 && (
<TarjetaPaso>
<ViviendaCreditoForm <ViviendaCreditoForm
valores={datos.viviendaCredito} valores={datos.viviendaCredito}
onAnterior={anterior} onAnterior={anterior}
@@ -475,18 +528,20 @@ export default function AltaClientePage() {
guardarAvance({ ...datos, viviendaCredito: v }, paso + 1) guardarAvance({ ...datos, viviendaCredito: v }, paso + 1)
}} }}
/> />
</TarjetaPaso>
)} )}
{paso === 6 && ( {paso === 6 && (
<> <>
{cargoDeBorrador && ( {cargoDeBorrador && (
<p <p
role="note" role="note"
className="mb-8 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700" className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"
> >
Retomaste un borrador: los archivos de los documentos no se guardan en el borrador, Retomaste un borrador: los archivos de los documentos no se guardan en el borrador,
vuelve a adjuntarlos. vuelve a adjuntarlos.
</p> </p>
)} )}
<TarjetaPaso>
<DocumentosForm <DocumentosForm
documentos={alta.documentos} documentos={alta.documentos}
firmaDataUrl={alta.firma.firmaDataUrl} firmaDataUrl={alta.firma.firmaDataUrl}
@@ -502,19 +557,20 @@ export default function AltaClientePage() {
guardarAvance(datos, paso + 1) guardarAvance(datos, paso + 1)
}} }}
/> />
</TarjetaPaso>
</> </>
)} )}
{paso === 7 && ( {paso === 7 && (
<> <>
{registro && ( {registro && (
<p className="mb-8 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700"> <p className="mb-6 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700">
Alta registrada en el servidor: {registro} Alta registrada en el servidor: {registro}
</p> </p>
)} )}
{sinConexion && ( {sinConexion && (
<p <p
role="alert" role="alert"
className="mb-8 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700" className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"
> >
Sin conexión al servidor — el expediente se generó solo localmente Sin conexión al servidor — el expediente se generó solo localmente
</p> </p>
@@ -522,13 +578,13 @@ export default function AltaClientePage() {
{errorValidacion && ( {errorValidacion && (
<p <p
role="alert" role="alert"
className="mb-8 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700" className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700"
> >
El servidor rechazó el alta: {errorValidacion} El servidor rechazó el alta: {errorValidacion}
</p> </p>
)} )}
{registro && ( {registro && (
<div className="mb-8 rounded-xl border border-[#E5E7EB] bg-white px-4 py-4 shadow-sm"> <div className="mb-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]"> <p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Firma en remoto Firma en remoto
</p> </p>
@@ -556,14 +612,17 @@ export default function AltaClientePage() {
{errorLiga && <p className="mt-2 text-xs text-red-700">{errorLiga}</p>} {errorLiga && <p className="mt-2 text-xs text-red-700">{errorLiga}</p>}
</div> </div>
)} )}
<TarjetaPaso>
<FirmasForm <FirmasForm
alta={alta} alta={alta}
onCambiarFirma={(f) => actualizar('firma', f)} onCambiarFirma={(f) => actualizar('firma', f)}
onAnterior={anterior} onAnterior={anterior}
/> />
</TarjetaPaso>
</> </>
)} )}
</div> </div>
</div>
</main> </main>
</div> </div>
) )

View File

@@ -1,8 +1,23 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
export default function KpiCard({ titulo, valor, detalle }: { titulo: string; valor: ReactNode; detalle?: string }) { export default function KpiCard({
titulo,
valor,
detalle,
plano = false,
}: {
titulo: string
valor: ReactNode
detalle?: string
/** `plano` quita sombra/hover para anidar la tarjeta dentro de otra tarjeta */
plano?: boolean
}) {
return ( return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-md"> <div
className={`rounded-xl border border-[#E5E7EB] bg-white p-6 ${
plano ? '' : 'shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-md'
}`}
>
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</p> <p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</p>
<p className="mt-2 text-3xl font-light text-[#111827]">{valor}</p> <p className="mt-2 text-3xl font-light text-[#111827]">{valor}</p>
{detalle && <p className="mt-2 text-xs text-[#64748B]">{detalle}</p>} {detalle && <p className="mt-2 text-xs text-[#64748B]">{detalle}</p>}

View File

@@ -4,14 +4,14 @@ import { Link } from 'react-router-dom'
export default function Layout({ children }: { children: ReactNode }) { export default function Layout({ children }: { children: ReactNode }) {
return ( return (
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]"> <div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
<header className="border-b border-[#E5E7EB] bg-white"> <header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4"> <div className="mx-auto flex h-16 max-w-[1280px] items-center justify-between px-4 sm:px-6">
<Link to="/" className="text-sm tracking-widest"> <Link to="/" className="text-sm tracking-widest">
<span className="font-bold">FRASA</span> <span className="font-bold">FRASA</span>
<span className="text-[#F97316]">.</span>{' '} <span className="text-[#F97316]">.</span>{' '}
<span className="font-light text-[#64748B]">COBRANZA</span> <span className="font-light text-[#64748B]">COBRANZA</span>
</Link> </Link>
<nav className="flex items-center gap-6 text-sm"> <nav className="flex items-center gap-3 text-sm sm:gap-6">
<Link <Link
to="/" to="/"
className="text-[#64748B] transition-colors duration-150 hover:text-[#111827]" className="text-[#64748B] transition-colors duration-150 hover:text-[#111827]"
@@ -20,20 +20,20 @@ export default function Layout({ children }: { children: ReactNode }) {
</Link> </Link>
<Link <Link
to="/hoy" to="/hoy"
className="rounded-lg border border-accent px-5 py-2.5 text-sm font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white" className="rounded-lg border border-accent px-3 py-2 text-sm font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white sm:px-5 sm:py-2.5"
> >
Cobranza del día Cobranza del día
</Link> </Link>
<Link <Link
to="/nuevo" to="/nuevo"
className="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" className="rounded-lg bg-accent px-3 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover sm:px-5 sm:py-2.5"
> >
Nuevo plan Nuevo plan
</Link> </Link>
</nav> </nav>
</div> </div>
</header> </header>
<main className="mx-auto max-w-6xl px-4 py-10">{children}</main> <main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6">{children}</main>
</div> </div>
) )
} }

View File

@@ -214,9 +214,9 @@ export default function DashboardPage() {
) )
return ( return (
<div className="space-y-8"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-4">
<h1 className="text-3xl font-light text-[#111827]">Módulo Cobranza — Planes de enganche</h1> <h1 className="text-xl font-semibold text-[#111827]">Módulo Cobranza — Planes de enganche</h1>
<Link <Link
to="/nuevo" to="/nuevo"
className="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" className="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"
@@ -292,8 +292,10 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{/* Tarjeta unificada: toolbar de filtros/búsqueda + tabla de planes */}
<div className="overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
{/* Filtros */} {/* Filtros */}
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-3 border-b border-[#E5E7EB] p-4 sm:gap-4 sm:px-5">
{/* Toggle Mis planes / Todos (?mios=1 filtra en el API por el vendedor del token) */} {/* Toggle Mis planes / Todos (?mios=1 filtra en el API por el vendedor del token) */}
<div className="flex rounded-lg border border-[#D1D5DB] bg-white" role="group" aria-label="Alcance de planes"> <div className="flex rounded-lg border border-[#D1D5DB] bg-white" role="group" aria-label="Alcance de planes">
<button <button
@@ -361,7 +363,7 @@ export default function DashboardPage() {
</div> </div>
{/* Tabla de planes */} {/* Tabla de planes */}
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm"> <div className="overflow-x-auto">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead> <thead>
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B]"> <tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B]">
@@ -419,5 +421,6 @@ export default function DashboardPage() {
</table> </table>
</div> </div>
</div> </div>
</div>
) )
} }

View File

@@ -43,9 +43,9 @@ export default function HoyPage() {
} }
return ( return (
<div className="space-y-8"> <div className="space-y-6">
<div> <div>
<h1 className="text-3xl font-light text-[#111827]">Cobranza del día</h1> <h1 className="text-xl font-semibold text-[#111827]">Cobranza del día</h1>
<p className="mt-1 text-sm text-[#64748B]"> <p className="mt-1 text-sm text-[#64748B]">
Pagos vencidos y vencimientos de los próximos 7 días, en todos los planes activos. Pagos vencidos y vencimientos de los próximos 7 días, en todos los planes activos.
</p> </p>

View File

@@ -88,8 +88,8 @@ export default function NuevoPlanPage() {
} }
return ( return (
<div className="mx-auto max-w-3xl space-y-8"> <div className="mx-auto max-w-3xl space-y-6">
<h1 className="text-3xl font-light text-[#111827]">Nuevo plan de enganche</h1> <h1 className="text-xl font-semibold text-[#111827]">Nuevo plan de enganche</h1>
<form <form
className="rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm" className="rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm"

View File

@@ -133,14 +133,15 @@ export default function PlanDetallePage() {
} }
} }
const avance = avancePlan(plan)
return ( return (
<div className="space-y-8"> <div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-6">
<div> <div>
<Link to="/" className="text-sm text-accent hover:underline"> <Link to="/" className="text-sm text-accent hover:underline">
← Volver al dashboard ← Volver al dashboard
</Link> </Link>
<h1 className="mt-2 text-3xl font-light text-[#111827]">{plan.clienteNombre}</h1> <h1 className="mt-2 text-xl font-semibold text-[#111827]">{plan.clienteNombre}</h1>
<p className="mt-1 text-sm text-[#64748B]"> <p className="mt-1 text-sm text-[#64748B]">
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '} {plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
<span className="font-semibold text-[#111827]">{status}</span> <span className="font-semibold text-[#111827]">{status}</span>
@@ -151,23 +152,6 @@ export default function PlanDetallePage() {
{tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>} {tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>}
</p> </p>
</div> </div>
<div className="flex gap-3">
<button
onClick={() => descargar(plan)}
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Estado de cuenta PDF
</button>
{status === 'ACTIVO' && saldo > 0 && (
<button
onClick={cancelar}
className="rounded-lg border border-red-600 bg-white px-5 py-2.5 text-sm font-medium text-red-600 transition-all duration-100 ease-out hover:bg-red-600 hover:text-white"
>
Cancelar plan
</button>
)}
</div>
</div>
{offline && ( {offline && (
<p className="text-xs text-amber-700"> <p className="text-xs text-amber-700">
@@ -179,21 +163,52 @@ export default function PlanDetallePage() {
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
)} )}
{/* KPIs del plan */} {/* En lg: columna principal (flex-1) + rail derecho sticky de ~340px */}
<div className="grid grid-cols-2 gap-6 lg:grid-cols-5"> <div className="space-y-6 lg:flex lg:items-start lg:gap-6 lg:space-y-0">
<KpiCard titulo="Enganche total" valor={formatoMoneda(plan.engancheTotal)} /> {/* Columna principal */}
<KpiCard titulo="Pagado" valor={formatoMoneda(pagadoTotal(plan))} /> <div className="min-w-0 flex-1 space-y-6">
<KpiCard titulo="Saldo" valor={formatoMoneda(saldo)} /> {/* Resumen del plan */}
<KpiCard titulo="% avance" valor={formatoPorcentaje(avancePlan(plan))} /> <section
<KpiCard titulo="Mensualidad" valor={formatoMoneda(plan.mensualidad)} detalle={`${plan.numPagos} pagos`} /> className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm sm:p-6"
aria-labelledby="titulo-resumen"
>
<h2 id="titulo-resumen" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Resumen del plan
</h2>
<div className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-3">
<KpiCard plano titulo="Enganche total" valor={formatoMoneda(plan.engancheTotal)} />
<KpiCard plano titulo="Pagado" valor={formatoMoneda(pagadoTotal(plan))} />
<KpiCard plano titulo="Saldo" valor={formatoMoneda(saldo)} />
<KpiCard plano titulo="% avance" valor={formatoPorcentaje(avance)} />
<KpiCard
plano
titulo="Mensualidad"
valor={formatoMoneda(plan.mensualidad)}
detalle={`${plan.numPagos} pagos`}
/>
{mora > 0 && ( {mora > 0 && (
<KpiCard <KpiCard
plano
titulo="Mora acumulada" titulo="Mora acumulada"
valor={<span className="text-red-700">{formatoMoneda(mora)}</span>} valor={<span className="text-red-700">{formatoMoneda(mora)}</span>}
detalle={`Tasa ${tasaMora}% mensual`} detalle={`Tasa ${tasaMora}% mensual`}
/> />
)} )}
</div> </div>
{/* Barra de avance */}
<div className="mt-6">
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-[#64748B]">Avance del plan</span>
<span className="font-semibold text-[#111827]">{formatoPorcentaje(avance)}</span>
</div>
<div className="mt-2 h-2 rounded-full bg-[#E5E7EB]">
<div
className="h-full rounded-full bg-accent"
style={{ width: `${Math.min(100, avance * 100)}%` }}
/>
</div>
</div>
</section>
{/* Tabla de pagos */} {/* Tabla de pagos */}
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm"> <div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
@@ -300,9 +315,37 @@ export default function PlanDetallePage() {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
{/* Rail derecho: acciones y bitácora */}
<aside className="space-y-6 lg:sticky lg:top-20 lg:w-[340px] lg:shrink-0">
<section
className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm"
aria-labelledby="titulo-acciones"
>
<h2 id="titulo-acciones" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Acciones
</h2>
<div className="mt-4 space-y-3">
<button
onClick={() => descargar(plan)}
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Estado de cuenta PDF
</button>
{status === 'ACTIVO' && saldo > 0 && (
<button
onClick={cancelar}
className="w-full rounded-lg border border-red-600 bg-white px-5 py-2.5 text-sm font-medium text-red-600 transition-all duration-100 ease-out hover:bg-red-600 hover:text-white"
>
Cancelar plan
</button>
)}
</div>
</section>
{/* Bitácora del plan */} {/* Bitácora del plan */}
<section className="rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm" aria-labelledby="titulo-bitacora"> <section className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm" aria-labelledby="titulo-bitacora">
<h2 id="titulo-bitacora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]"> <h2 id="titulo-bitacora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Bitácora Bitácora
</h2> </h2>
@@ -329,7 +372,7 @@ export default function PlanDetallePage() {
</ul> </ul>
)} )}
<form <form
className="mt-8" className="mt-6"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault() e.preventDefault()
void guardarNota() void guardarNota()
@@ -361,6 +404,8 @@ export default function PlanDetallePage() {
</div> </div>
</form> </form>
</section> </section>
</aside>
</div>
{status === 'CANCELADO' && ( {status === 'CANCELADO' && (
<button <button

View File

@@ -5,8 +5,8 @@ import LoginScreen from './LoginScreen'
/** /**
* Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la * Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la
* app con una barra discreta "nombre · rol", "Contraseña" (modal de cambio) * app con un header blanco sticky (marca a la izquierda; "nombre · rol",
* y botón "Salir". * "Contraseña" (modal de cambio) y botón "Salir" a la derecha).
*/ */
export default function AuthGate({ children }: { children: ReactNode }) { export default function AuthGate({ children }: { children: ReactNode }) {
const [sesion, setSesion] = useState<Sesion | null>(() => getSesion()) const [sesion, setSesion] = useState<Sesion | null>(() => getSesion())
@@ -18,16 +18,22 @@ export default function AuthGate({ children }: { children: ReactNode }) {
return ( return (
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]"> <div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
<div className="border-b border-[#E5E7EB] bg-white"> <header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2"> <div className="mx-auto flex h-16 max-w-[1280px] items-center justify-between gap-4 px-4 sm:px-6">
<span className="text-xs font-medium text-[#64748B]"> <p className="text-sm text-[#111827]">
<span className="font-bold">
FRASA DESARROLLOS<span className="text-[#F97316]">.</span>
</span>{' '}
<span className="hidden font-normal text-[#64748B] sm:inline">— Cotizador</span>
</p>
<div className="flex items-center gap-3 sm:gap-4">
<span className="hidden text-xs font-medium text-[#64748B] md:inline">
{sesion.usuario.nombre} · {sesion.usuario.rol} {sesion.usuario.nombre} · {sesion.usuario.rol}
</span> </span>
<div className="flex items-center gap-4">
<button <button
type="button" type="button"
onClick={() => setModalPassword(true)} onClick={() => setModalPassword(true)}
className="text-xs font-medium text-[#64748B] transition-colors duration-100 hover:text-[#111827]" className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
> >
Contraseña Contraseña
</button> </button>
@@ -43,7 +49,7 @@ export default function AuthGate({ children }: { children: ReactNode }) {
</button> </button>
</div> </div>
</div> </div>
</div> </header>
{children} {children}
{modalPassword && <CambiarPasswordModal onCerrar={() => setModalPassword(false)} />} {modalPassword && <CambiarPasswordModal onCerrar={() => setModalPassword(false)} />}
</div> </div>

View File

@@ -72,7 +72,7 @@ export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulo
} }
return ( return (
<section className="mt-16" aria-labelledby="comparador-titulo"> <section aria-labelledby="comparador-titulo">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
<h2 <h2
id="comparador-titulo" id="comparador-titulo"

View File

@@ -99,7 +99,7 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
) )
return ( return (
<section className="mt-16" aria-labelledby="comparador-unidades-titulo"> <section aria-labelledby="comparador-unidades-titulo">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
<h2 <h2
id="comparador-unidades-titulo" id="comparador-unidades-titulo"

View File

@@ -255,27 +255,20 @@ export default function CotizadorPage() {
return ( return (
<div className="min-h-screen bg-[#F8FAFC]"> <div className="min-h-screen bg-[#F8FAFC]">
<header className="border-b border-[#E5E7EB] bg-white"> <main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 lg:py-10">
<div className="mx-auto flex max-w-7xl flex-wrap items-baseline justify-between gap-2 px-4 py-6"> {/* Encabezado de página */}
<h1 className="text-lg text-[#111827]"> <div className="mb-8">
<span className="font-bold">FRASA DESARROLLOS<span className="text-[#F97316]">.</span></span>{' '} <h1 className="text-xl font-semibold text-[#111827]">Cotizador</h1>
<span className="font-normal text-[#64748B]">— Cotizador</span> <p className="mt-1 text-sm text-[#64748B] capitalize">{formatoFechaLarga(new Date())}</p>
</h1> <p className="text-sm text-[#64748B]">
<div className="text-right">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] capitalize">
{formatoFechaLarga(new Date())}
</p>
<p className="mt-1 text-xs text-[#64748B]">
Vigencia: hasta el {formatoFechaLarga(calcularVigencia())} Vigencia: hasta el {formatoFechaLarga(calcularVigencia())}
</p> </p>
</div> </div>
</div>
</header>
<main className="mx-auto grid max-w-7xl gap-x-8 gap-y-16 px-4 py-16 lg:grid-cols-[minmax(320px,380px)_1fr]"> <div className="grid items-start gap-6 lg:grid-cols-[380px_minmax(0,1fr)] lg:gap-8">
{/* Panel de configuración */} {/* Panel de configuración */}
<aside className="space-y-16"> <aside className="space-y-6 lg:sticky lg:top-24">
<section className="space-y-8 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm"> <section className="space-y-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
{error && <p className="text-xs text-red-700">{error}</p>} {error && <p className="text-xs text-red-700">{error}</p>}
{usandoSeed && !cargando && ( {usandoSeed && !cargando && (
<p className="text-xs text-amber-700">Datos de demostración (sin conexión al servidor)</p> <p className="text-xs text-amber-700">Datos de demostración (sin conexión al servidor)</p>
@@ -418,7 +411,7 @@ export default function CotizadorPage() {
</div> </div>
{unidad && ( {unidad && (
<div className="border-t border-[#E5E7EB] pt-8"> <div className="border-t border-[#E5E7EB] pt-6">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]"> <p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Precio de lista Precio de lista
</p> </p>
@@ -443,23 +436,16 @@ export default function CotizadorPage() {
</aside> </aside>
{/* Panel de propuestas */} {/* Panel de propuestas */}
<section> <section className="min-w-0">
<MisCotizaciones
cotizaciones={misCotizaciones}
cargando={cargandoMias}
error={errorMias}
onRetomar={handleRetomar}
/>
{!unidad ? ( {!unidad ? (
<div className="mt-16 flex h-64 items-center justify-center rounded-xl border border-dashed border-[#D1D5DB] bg-white"> <div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#D1D5DB] bg-white">
<p className="text-sm text-[#64748B]"> <p className="text-sm text-[#64748B]">
Selecciona una unidad para ver las propuestas comerciales. Selecciona una unidad para ver las propuestas comerciales.
</p> </p>
</div> </div>
) : ( ) : (
<div className="mt-16"> <div className="space-y-8">
<div className="grid gap-8 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
{propuestas.map((p, i) => ( {propuestas.map((p, i) => (
<ProposalCard <ProposalCard
key={p.tipo} key={p.tipo}
@@ -476,7 +462,8 @@ export default function CotizadorPage() {
propuestas={propuestas} propuestas={propuestas}
titulos={propuestas.map((p) => p.titulo)} titulos={propuestas.map((p) => p.titulo)}
/> />
<div className="mt-16 flex flex-wrap items-center justify-end gap-4"> <div className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-center justify-end gap-4">
<div> <div>
<label htmlFor="propuesta-elegida" className={etiquetaCampo}> <label htmlFor="propuesta-elegida" className={etiquetaCampo}>
Propuesta elegida Propuesta elegida
@@ -518,15 +505,26 @@ export default function CotizadorPage() {
</p> </p>
)} )}
</div> </div>
</div>
)} )}
</section>
</div>
{/* Secciones de ancho completo */}
<div className="mt-10 space-y-8">
<MisCotizaciones
cotizaciones={misCotizaciones}
cargando={cargandoMias}
error={errorMias}
onRetomar={handleRetomar}
/>
<ComparadorUnidades <ComparadorUnidades
unidades={unidades} unidades={unidades}
tipo={propuestaActiva?.tipo ?? 'CREDITO_BANCARIO'} tipo={propuestaActiva?.tipo ?? 'CREDITO_BANCARIO'}
apartado={apartadoEfectivo} apartado={apartadoEfectivo}
acabados={acabados} acabados={acabados}
/> />
</section> </div>
</main> </main>
<ModalCliente <ModalCliente

View File

@@ -19,7 +19,7 @@ export default function AuthGate({ children }: { children: ReactNode }) {
return ( return (
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]"> <div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
<div className="border-b border-[#E5E7EB] bg-white"> <div className="border-b border-[#E5E7EB] bg-white">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2"> <div className="mx-auto flex max-w-[1280px] items-center justify-between px-4 py-2 sm:px-6">
<span className="text-xs font-medium text-[#4B5563]"> <span className="text-xs font-medium text-[#4B5563]">
{sesion.usuario.nombre} · {sesion.usuario.rol} {sesion.usuario.nombre} · {sesion.usuario.rol}
</span> </span>

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Cell, Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } from 'recharts' import { Cell, Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } from 'recharts'
import KpiCard from './KpiCard' import KpiCard from './KpiCard'
import { EstadoVacio, TituloSeccion } from './comunes' import { EstadoVacio, TarjetaGrafica, type AccionesTablero } from './comunes'
import { funnelMisLeads, type ResumenMiDia } from '../lib/mi-tablero' import { funnelMisLeads, type ResumenMiDia } from '../lib/mi-tablero'
import { fetchResumenMiDia } from '../services/mi-dia' import { fetchResumenMiDia } from '../services/mi-dia'
@@ -13,7 +13,7 @@ const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB']
* apartados por vencer) y un funnel simple de sus leads por estado. * apartados por vencer) y un funnel simple de sus leads por estado.
* Fuente: GET /api/mi-dia/resumen (filtra por su mondayUserId en el API). * Fuente: GET /api/mi-dia/resumen (filtra por su mondayUserId en el API).
*/ */
export default function MiTablero() { export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (acciones: AccionesTablero) => void }) {
const [resumen, setResumen] = useState<ResumenMiDia | null>(null) const [resumen, setResumen] = useState<ResumenMiDia | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [cargando, setCargando] = useState(true) const [cargando, setCargando] = useState(true)
@@ -34,29 +34,23 @@ export default function MiTablero() {
void cargar() void cargar()
}, [cargar]) }, [cargar])
// Expone "Actualizar" al header sticky de DashboardPage
useEffect(() => {
alCambiarAcciones({ actualizar: () => void cargar(), cargando })
}, [alCambiarAcciones, cargar, cargando])
const funnel = resumen ? funnelMisLeads(resumen.misLeads) : [] const funnel = resumen ? funnelMisLeads(resumen.misLeads) : []
return ( return (
<main className="mx-auto max-w-6xl px-4 py-8"> <main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6">
<div className="mb-8 flex items-end justify-between"> <div className="mb-8">
<div> <h1 className="text-xl font-semibold text-[#111827]">Mi tablero</h1>
<p className="text-xs font-semibold uppercase tracking-wider text-accent">FRASA Desarrollos</p>
<h1 className="mt-2 text-3xl font-light text-[#111827]">Mi tablero</h1>
{resumen && ( {resumen && (
<p className="mt-1 text-xs text-[#64748B]"> <p className="mt-1 text-sm text-[#64748B]">
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')} Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
</p> </p>
)} )}
</div> </div>
<button
type="button"
onClick={() => void cargar()}
disabled={cargando}
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 disabled:opacity-50"
>
{cargando ? 'Actualizando…' : 'Actualizar'}
</button>
</div>
{error && ( {error && (
<p role="alert" className="mb-8 rounded-lg bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700"> <p role="alert" className="mb-8 rounded-lg bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700">
@@ -75,7 +69,7 @@ export default function MiTablero() {
{resumen && ( {resumen && (
<> <>
<section aria-label="Mis indicadores" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <section aria-label="Mis indicadores" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard <KpiCard
titulo="Mis leads activos" titulo="Mis leads activos"
valor={resumen.misLeads.length} valor={resumen.misLeads.length}
@@ -93,8 +87,8 @@ export default function MiTablero() {
/> />
</section> </section>
<section aria-label="Mis leads por estado" className="mt-12"> <section aria-label="Mis leads por estado" className="mt-10">
<TituloSeccion>Mis leads por estado</TituloSeccion> <TarjetaGrafica titulo="Mis leads por estado">
{funnel.length === 0 ? ( {funnel.length === 0 ? (
<EstadoVacio mensaje="Sin leads asignados" /> <EstadoVacio mensaje="Sin leads asignados" />
) : ( ) : (
@@ -113,6 +107,7 @@ export default function MiTablero() {
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
)} )}
</TarjetaGrafica>
</section> </section>
</> </>
)} )}

View File

@@ -20,7 +20,7 @@ const DIMENSIONES: { titulo: string; registro: (perfil: ResumenReportes['leads']
function TarjetaDimension({ titulo, datos }: { titulo: string; datos: Record<string, number> }) { function TarjetaDimension({ titulo, datos }: { titulo: string; datos: Record<string, number> }) {
const rebanadas = rebanadasDe(datos) const rebanadas = rebanadasDe(datos)
return ( return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm transition-shadow duration-200 hover:shadow-md"> <div className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm transition-shadow duration-200 hover:shadow-md">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</p> <p className="mb-2 text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</p>
{rebanadas.length === 0 ? ( {rebanadas.length === 0 ? (
<EstadoVacio mensaje="Sin datos" /> <EstadoVacio mensaje="Sin datos" />
@@ -47,9 +47,9 @@ function TarjetaDimension({ titulo, datos }: { titulo: string; datos: Record<str
*/ */
export default function Perfilamiento({ perfil }: { perfil: ResumenReportes['leads']['perfil'] }) { export default function Perfilamiento({ perfil }: { perfil: ResumenReportes['leads']['perfil'] }) {
return ( return (
<section aria-label="Perfilamiento de la afluencia" className="mt-12"> <section aria-label="Perfilamiento de la afluencia" className="mt-10">
<TituloSeccion>Perfilamiento de la afluencia</TituloSeccion> <TituloSeccion>Perfilamiento de la afluencia</TituloSeccion>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{DIMENSIONES.map((d) => ( {DIMENSIONES.map((d) => (
<TarjetaDimension key={d.titulo} titulo={d.titulo} datos={d.registro(perfil)} /> <TarjetaDimension key={d.titulo} titulo={d.titulo} datos={d.registro(perfil)} />
))} ))}

View File

@@ -30,7 +30,7 @@ export default function SeccionEquipo() {
}, [cargar]) }, [cargar])
return ( return (
<section aria-label="Equipo" className="mt-12"> <section aria-label="Equipo" className="mt-10">
<TituloSeccion>Equipo: leads por vendedor</TituloSeccion> <TituloSeccion>Equipo: leads por vendedor</TituloSeccion>
{error && ( {error && (
<p role="alert" className="rounded-lg bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700"> <p role="alert" className="rounded-lg bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700">

View File

@@ -18,7 +18,7 @@ import {
import KpiCard from './KpiCard' import KpiCard from './KpiCard'
import Perfilamiento from './Perfilamiento' import Perfilamiento from './Perfilamiento'
import SeccionEquipo from './SeccionEquipo' import SeccionEquipo from './SeccionEquipo'
import { EstadoVacio, TituloSeccion } from './comunes' import { EstadoVacio, TarjetaGrafica, type AccionesTablero } from './comunes'
import { import {
datosCobranza, datosCobranza,
datosFunnel, datosFunnel,
@@ -47,7 +47,13 @@ const COLORES_COBRANZA = ['#2563EB', '#F59E0B']
* pipeline, funnel y cobranza). Con `mostrarEquipo` (coordinador/corporativo/ * pipeline, funnel y cobranza). Con `mostrarEquipo` (coordinador/corporativo/
* admin) agrega la sección Equipo con leads por vendedor. * admin) agrega la sección Equipo con leads por vendedor.
*/ */
export default function TableroGeneral({ mostrarEquipo }: { mostrarEquipo: boolean }) { export default function TableroGeneral({
mostrarEquipo,
alCambiarAcciones,
}: {
mostrarEquipo: boolean
alCambiarAcciones: (acciones: AccionesTablero) => void
}) {
const [resumen, setResumen] = useState<ResumenReportes | null>(null) const [resumen, setResumen] = useState<ResumenReportes | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [cargando, setCargando] = useState(true) const [cargando, setCargando] = useState(true)
@@ -68,6 +74,11 @@ export default function TableroGeneral({ mostrarEquipo }: { mostrarEquipo: boole
void cargar() void cargar()
}, [cargar]) }, [cargar])
// Expone "Actualizar" al header sticky de DashboardPage
useEffect(() => {
alCambiarAcciones({ actualizar: () => void cargar(), cargando })
}, [alCambiarAcciones, cargar, cargando])
const unidades = resumen ? unidadesApiladas(resumen) : [] const unidades = resumen ? unidadesApiladas(resumen) : []
const funnel = resumen ? datosFunnel(resumen) : [] const funnel = resumen ? datosFunnel(resumen) : []
const cobranza = resumen ? datosCobranza(resumen) : [] const cobranza = resumen ? datosCobranza(resumen) : []
@@ -76,26 +87,15 @@ export default function TableroGeneral({ mostrarEquipo }: { mostrarEquipo: boole
const sinCobranza = resumen !== null && resumen.cobranza.planesAlCorriente + resumen.cobranza.planesConVencidos === 0 const sinCobranza = resumen !== null && resumen.cobranza.planesAlCorriente + resumen.cobranza.planesConVencidos === 0
return ( return (
<main className="mx-auto max-w-6xl px-4 py-8"> <main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6">
<div className="mb-8 flex items-end justify-between"> <div className="mb-8">
<div> <h1 className="text-xl font-semibold text-[#111827]">Tablero comercial</h1>
<p className="text-xs font-semibold uppercase tracking-wider text-accent">FRASA Desarrollos</p>
<h1 className="mt-2 text-3xl font-light text-[#111827]">Tablero comercial</h1>
{resumen && ( {resumen && (
<p className="mt-1 text-xs text-[#64748B]"> <p className="mt-1 text-sm text-[#64748B]">
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')} Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
</p> </p>
)} )}
</div> </div>
<button
type="button"
onClick={() => void cargar()}
disabled={cargando}
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 disabled:opacity-50"
>
{cargando ? 'Actualizando…' : 'Actualizar'}
</button>
</div>
{error && ( {error && (
<p role="alert" className="mb-8 rounded-lg bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700"> <p role="alert" className="mb-8 rounded-lg bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700">
@@ -151,8 +151,9 @@ export default function TableroGeneral({ mostrarEquipo }: { mostrarEquipo: boole
/> />
</section> </section>
<section aria-label="Inventario por desarrollo" className="mt-12"> <div className="mt-10 grid gap-6 sm:grid-cols-1 lg:grid-cols-2">
<TituloSeccion>Unidades por desarrollo y status</TituloSeccion> <section aria-label="Inventario por desarrollo" className="lg:col-span-2">
<TarjetaGrafica titulo="Unidades por desarrollo y status">
{unidades.length === 0 ? ( {unidades.length === 0 ? (
<EstadoVacio mensaje="Sin inventario" /> <EstadoVacio mensaje="Sin inventario" />
) : ( ) : (
@@ -171,10 +172,11 @@ export default function TableroGeneral({ mostrarEquipo }: { mostrarEquipo: boole
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
)} )}
</TarjetaGrafica>
</section> </section>
<section aria-label="Pipeline de ventas por etapa" className="mt-12"> <section aria-label="Pipeline de ventas por etapa" className="lg:col-span-2">
<TituloSeccion>Pipeline de ventas por etapa</TituloSeccion> <TarjetaGrafica titulo="Pipeline de ventas por etapa">
{sinPipeline ? ( {sinPipeline ? (
<EstadoVacio mensaje="Sin unidades en el pipeline" /> <EstadoVacio mensaje="Sin unidades en el pipeline" />
) : ( ) : (
@@ -190,11 +192,11 @@ export default function TableroGeneral({ mostrarEquipo }: { mostrarEquipo: boole
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
)} )}
</TarjetaGrafica>
</section> </section>
<div className="mt-12 grid gap-12 lg:grid-cols-2">
<section aria-label="Embudo comercial"> <section aria-label="Embudo comercial">
<TituloSeccion>Embudo: leads → oportunidades → ganado</TituloSeccion> <TarjetaGrafica titulo="Embudo: leads → oportunidades → ganado">
{funnel.every((e) => e.valor === 0) ? ( {funnel.every((e) => e.valor === 0) ? (
<EstadoVacio mensaje="Sin movimiento comercial" /> <EstadoVacio mensaje="Sin movimiento comercial" />
) : ( ) : (
@@ -213,10 +215,11 @@ export default function TableroGeneral({ mostrarEquipo }: { mostrarEquipo: boole
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
)} )}
</TarjetaGrafica>
</section> </section>
<section aria-label="Estado de la cobranza"> <section aria-label="Estado de la cobranza">
<TituloSeccion>Cobranza: al corriente vs vencidos</TituloSeccion> <TarjetaGrafica titulo="Cobranza: al corriente vs vencidos">
{sinCobranza ? ( {sinCobranza ? (
<EstadoVacio mensaje="Sin planes activos" /> <EstadoVacio mensaje="Sin planes activos" />
) : ( ) : (
@@ -242,6 +245,7 @@ export default function TableroGeneral({ mostrarEquipo }: { mostrarEquipo: boole
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
)} )}
</TarjetaGrafica>
</section> </section>
</div> </div>

View File

@@ -1,5 +1,14 @@
// Piezas visuales compartidas por las vistas del tablero (NovaSpark) // Piezas visuales compartidas por las vistas del tablero (NovaSpark)
import type { ReactNode } from 'react'
/**
* Acciones que cada vista del tablero expone al header de DashboardPage:
* el botón "Actualizar" vive en el header sticky y dispara la recarga de la
* vista activa (sin cambiar fuentes de datos ni consultas).
*/
export type AccionesTablero = { actualizar: () => void; cargando: boolean }
export function EstadoVacio({ mensaje }: { mensaje: string }) { export function EstadoVacio({ mensaje }: { mensaje: string }) {
return ( return (
<div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#E5E7EB] bg-white"> <div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#E5E7EB] bg-white">
@@ -11,3 +20,19 @@ export function EstadoVacio({ mensaje }: { mensaje: string }) {
export function TituloSeccion({ children }: { children: string }) { export function TituloSeccion({ children }: { children: string }) {
return <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B]">{children}</h2> return <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B]">{children}</h2>
} }
/** Tarjeta contenedora de gráficas: título interno + padding consistente. */
export function TarjetaGrafica({
titulo,
children,
}: {
titulo: string
children: ReactNode
}) {
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm">
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</h3>
{children}
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import MiTablero from '../components/MiTablero' import MiTablero from '../components/MiTablero'
import TableroGeneral from '../components/TableroGeneral' import TableroGeneral from '../components/TableroGeneral'
import type { AccionesTablero } from '../components/comunes'
import { esRolConEquipo } from '../lib/mi-tablero' import { esRolConEquipo } from '../lib/mi-tablero'
import { getSesion } from '../services/auth' import { getSesion } from '../services/auth'
@@ -8,23 +9,37 @@ type Vista = 'mia' | 'general'
const VISTAS: { id: Vista; etiqueta: string }[] = [ const VISTAS: { id: Vista; etiqueta: string }[] = [
{ id: 'mia', etiqueta: 'Mi tablero' }, { id: 'mia', etiqueta: 'Mi tablero' },
{ id: 'general', etiqueta: 'General' }, { id: 'general', etiqueta: 'Tablero general' },
] ]
/** /**
* Tableros con vista por rol: el vendedor entra a "Mi tablero" (sus KPIs y el * Tableros con vista por rol: el vendedor entra a "Mi tablero" (sus KPIs y el
* funnel de sus leads); coordinador/corporativo/admin entran a "General" con * funnel de sus leads); coordinador/corporativo/admin entran a "General" con
* la sección Equipo. El toggle permite cambiar de vista en cualquier momento. * la sección Equipo. El header sticky concentra la marca, el segmented control
* de vistas y el botón "Actualizar" de la vista activa.
*/ */
export default function DashboardPage() { export default function DashboardPage() {
const rol = getSesion()?.usuario.rol ?? 'vendedor' const rol = getSesion()?.usuario.rol ?? 'vendedor'
const conEquipo = esRolConEquipo(rol) const conEquipo = esRolConEquipo(rol)
const [vista, setVista] = useState<Vista>(conEquipo ? 'general' : 'mia') const [vista, setVista] = useState<Vista>(conEquipo ? 'general' : 'mia')
const [acciones, setAcciones] = useState<AccionesTablero | null>(null)
return ( return (
<div> <div>
<div className="border-b border-[#E5E7EB] bg-white"> <header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl gap-2 px-4 py-3" role="tablist" aria-label="Vista del tablero"> <div className="mx-auto flex min-h-14 max-w-[1280px] flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-2 sm:px-6">
<div className="flex items-center gap-2.5">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-xs font-bold text-white">
F
</span>
<span className="text-sm font-semibold text-[#111827]">FRASA Desarrollos</span>
</div>
<div className="flex items-center gap-3">
<div
className="inline-flex rounded-lg border border-[#E5E7EB] bg-[#F1F5F9] p-1"
role="tablist"
aria-label="Vista del tablero"
>
{VISTAS.map((v) => ( {VISTAS.map((v) => (
<button <button
key={v.id} key={v.id}
@@ -34,16 +49,30 @@ export default function DashboardPage() {
onClick={() => setVista(v.id)} onClick={() => setVista(v.id)}
className={ className={
vista === v.id vista === v.id
? 'rounded-lg bg-accent px-5 py-2 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover' ? 'rounded-md bg-white px-3 py-1.5 text-xs font-medium text-[#111827] shadow-sm transition-all duration-100 ease-out'
: 'rounded-lg border border-[#D1D5DB] bg-white px-5 py-2 text-xs font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB]' : 'rounded-md px-3 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:text-[#111827]'
} }
> >
{v.etiqueta} {v.etiqueta}
</button> </button>
))} ))}
</div> </div>
<button
type="button"
onClick={() => acciones?.actualizar()}
disabled={!acciones || acciones.cargando}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 disabled:opacity-50"
>
{acciones?.cargando ? 'Actualizando…' : 'Actualizar'}
</button>
</div> </div>
{vista === 'mia' ? <MiTablero /> : <TableroGeneral mostrarEquipo={conEquipo} />} </div>
</header>
{vista === 'mia' ? (
<MiTablero alCambiarAcciones={setAcciones} />
) : (
<TableroGeneral mostrarEquipo={conEquipo} alCambiarAcciones={setAcciones} />
)}
</div> </div>
) )
} }

View File

@@ -1,33 +1,44 @@
import { MODULOS } from './data/modulos' import { MODULOS } from './data/modulos'
import { Navbar } from './components/Navbar'
import { TarjetaModulo } from './components/TarjetaModulo' import { TarjetaModulo } from './components/TarjetaModulo'
import { ConfiguracionCuenta } from './components/ConfiguracionCuenta' import { ConfiguracionCuenta } from './components/ConfiguracionCuenta'
export default function App() { export default function App() {
return ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
<header className="border-b border-[#E5E7EB] bg-white px-6 pt-20 pb-16 sm:px-12 lg:px-24"> <Navbar />
<p className="text-xs font-semibold tracking-wider text-accent uppercase">
FRASA Desarrollos <span aria-hidden="true" className="text-[#F97316]">●</span> <main className="mx-auto w-full max-w-[1280px] flex-1 px-6">
</p> <section aria-labelledby="titulo-hero" className="py-12 sm:py-16">
<h1 className="mt-4 text-4xl font-light text-[#111827] sm:text-5xl"> <h1
id="titulo-hero"
className="text-3xl font-semibold tracking-tight text-[#111827] sm:text-4xl"
>
Ecosistema comercial Ecosistema comercial
</h1> </h1>
</header> <p className="mt-3 max-w-2xl text-sm leading-relaxed text-[#64748B] sm:text-base">
Las herramientas del equipo comercial de Frasa Desarrollos en un solo lugar:
cotizaciones, alta de clientes, cobranza, tableros y asistente IA.
</p>
</section>
<main className="flex-1 px-6 sm:px-12 lg:px-24">
<nav aria-label="Módulos del ecosistema"> <nav aria-label="Módulos del ecosistema">
<ul className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3"> <ul className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{MODULOS.map((modulo) => ( {MODULOS.map((modulo) => (
<TarjetaModulo key={modulo.id} modulo={modulo} /> <TarjetaModulo key={modulo.id} modulo={modulo} />
))} ))}
</ul> </ul>
</nav> </nav>
<div className="mt-6">
<ConfiguracionCuenta /> <ConfiguracionCuenta />
</div>
</main> </main>
<footer className="px-6 pt-24 pb-10 sm:px-12 lg:px-24"> <footer className="mt-16 border-t border-[#E5E7EB]">
<div className="mx-auto max-w-[1280px] px-6 py-8">
<p className="text-xs text-[#64748B]">Consult (AS) Technology</p> <p className="text-xs text-[#64748B]">Consult (AS) Technology</p>
</div>
</footer> </footer>
</div> </div>
) )

View File

@@ -95,7 +95,11 @@ export function ConfiguracionCuenta() {
perfil?.mondayUserId != null ? (usuarios?.find((u) => u.id === perfil.mondayUserId) ?? null) : null perfil?.mondayUserId != null ? (usuarios?.find((u) => u.id === perfil.mondayUserId) ?? null) : null
return ( return (
<section aria-labelledby="titulo-cuenta" className="mt-24 border-t border-[#E5E7EB] pt-16"> <section
id="configuracion"
aria-labelledby="titulo-cuenta"
className="scroll-mt-20 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm sm:p-8"
>
<h2 id="titulo-cuenta" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]"> <h2 id="titulo-cuenta" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Configuración de cuenta Configuración de cuenta
</h2> </h2>
@@ -103,7 +107,7 @@ export function ConfiguracionCuenta() {
{!conSesion ? ( {!conSesion ? (
<form <form
onSubmit={(e) => void entrar(e)} onSubmit={(e) => void entrar(e)}
className="mt-8 max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm" className="mt-6 max-w-sm"
> >
<p className="text-sm leading-relaxed text-[#64748B]"> <p className="text-sm leading-relaxed text-[#64748B]">
Inicia sesión para vincular tu usuario de Monday. Inicia sesión para vincular tu usuario de Monday.
@@ -150,7 +154,7 @@ export function ConfiguracionCuenta() {
</button> </button>
</form> </form>
) : ( ) : (
<div className="mt-8 max-w-md rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm"> <div className="mt-6 max-w-md">
{cargandoPerfil && <p className="text-sm text-[#64748B]">Cargando tu perfil…</p>} {cargandoPerfil && <p className="text-sm text-[#64748B]">Cargando tu perfil…</p>}
{perfil && ( {perfil && (

View File

@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react'
import { getSesion } from '../services/auth'
/** Barra superior: marca a la izquierda, estado de sesión y acceso a configuración a la derecha */
export function Navbar() {
const [conSesion, setConSesion] = useState(() => getSesion() !== null)
useEffect(() => {
const actualizar = () => setConSesion(getSesion() !== null)
window.addEventListener('frasa:sesion', actualizar)
window.addEventListener('storage', actualizar)
return () => {
window.removeEventListener('frasa:sesion', actualizar)
window.removeEventListener('storage', actualizar)
}
}, [])
return (
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
<div className="mx-auto flex h-14 max-w-[1280px] items-center justify-between px-6">
<span className="text-base font-semibold tracking-tight text-[#111827]">
FRASA<span className="text-accent">.</span>
</span>
<div className="flex items-center gap-4">
<span
className={`hidden items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium sm:inline-flex ${
conSesion ? 'bg-green-50 text-green-700' : 'bg-[#F1F5F9] text-[#64748B]'
}`}
>
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full ${conSesion ? 'bg-green-500' : 'bg-[#94A3B8]'}`}
/>
{conSesion ? 'Sesión activa' : 'Sin sesión'}
</span>
<a
href="#configuracion"
className="text-sm font-medium text-[#64748B] transition-colors hover:text-accent"
>
Configuración
</a>
</div>
</div>
</header>
)
}

View File

@@ -4,17 +4,34 @@ interface Props {
modulo: Modulo modulo: Modulo
} }
// Iniciales del "icono" de cada tarjeta; fallback: primeras 2 letras del nombre
const INICIALES: Record<string, string> = {
cotizador: 'CO',
'alta-clientes': 'AC',
cobranza: 'CB',
'agente-ia': 'IA',
tableros: 'TB',
'mi-dia': 'MD',
}
/** Tarjeta de acceso a un módulo del ecosistema */ /** Tarjeta de acceso a un módulo del ecosistema */
export function TarjetaModulo({ modulo }: Props) { export function TarjetaModulo({ modulo }: Props) {
const iniciales = INICIALES[modulo.id] ?? modulo.nombre.slice(0, 2).toUpperCase()
return ( return (
<li> <li>
<a <a
href={modulo.url} href={modulo.url}
className="group flex h-full flex-col rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm transition-all duration-200 ease-in-out hover:shadow-md" className="group flex h-full flex-col rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm transition-all duration-200 ease-in-out hover:shadow-md"
> >
<h2 className="text-2xl font-light text-[#111827]">{modulo.nombre}</h2> <span
<p className="mt-3 text-sm leading-relaxed text-[#64748B]">{modulo.descripcion}</p> aria-hidden="true"
<span className="mt-auto pt-8 text-sm font-medium text-accent transition-colors duration-100 ease-out group-hover:text-accent-hover"> className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-sm font-semibold text-accent"
>
{iniciales}
</span>
<h2 className="mt-4 text-base font-semibold text-[#111827]">{modulo.nombre}</h2>
<p className="mt-1 text-sm leading-relaxed text-[#64748B]">{modulo.descripcion}</p>
<span className="mt-auto pt-6 text-sm font-medium text-accent transition-colors duration-100 ease-out group-hover:text-accent-hover">
Entrar <span aria-hidden="true">&rarr;</span> Entrar <span aria-hidden="true">&rarr;</span>
</span> </span>
</a> </a>

View File

@@ -40,6 +40,12 @@ export function getToken(): string | null {
export function logout(): void { export function logout(): void {
localStorage.removeItem(STORAGE_KEY) localStorage.removeItem(STORAGE_KEY)
notificarCambioSesion()
}
/** Avisa a la UI (p. ej. el navbar) que cambió el estado de sesión */
function notificarCambioSesion(): void {
if (typeof window !== 'undefined') window.dispatchEvent(new Event('frasa:sesion'))
} }
/** POST /auth/login; guarda token + usuario en localStorage */ /** POST /auth/login; guarda token + usuario en localStorage */
@@ -57,6 +63,7 @@ export async function login(username: string, password: string): Promise<Usuario
if (!res.ok) throw new ErrorLogin() if (!res.ok) throw new ErrorLogin()
const data = (await res.json()) as Sesion const data = (await res.json()) as Sesion
localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: data.token, usuario: data.usuario })) localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: data.token, usuario: data.usuario }))
notificarCambioSesion()
return data.usuario return data.usuario
} }

View File

@@ -3,12 +3,18 @@ import { getSesion, logout, type Sesion } from '../services/auth'
import CambiarPasswordModal from './CambiarPasswordModal' import CambiarPasswordModal from './CambiarPasswordModal'
import LoginScreen from './LoginScreen' import LoginScreen from './LoginScreen'
/** Acciones de sesión que la página ubica en el header ("Contraseña" y "Salir") */
export interface AccionesSesion {
onCambiarPassword: () => void
onLogout: () => void
}
/** /**
* Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la * Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la
* app con una barra discreta "nombre · rol", "Contraseña" (modal de cambio) * app. La página recibe las acciones de sesión como render prop para
* y botón "Salir". * integrarlas en su propio header.
*/ */
export default function AuthGate({ children }: { children: ReactNode }) { export default function AuthGate({ children }: { children: (acciones: AccionesSesion) => ReactNode }) {
const [sesion, setSesion] = useState<Sesion | null>(() => getSesion()) const [sesion, setSesion] = useState<Sesion | null>(() => getSesion())
const [modalPassword, setModalPassword] = useState(false) const [modalPassword, setModalPassword] = useState(false)
@@ -18,33 +24,13 @@ export default function AuthGate({ children }: { children: ReactNode }) {
return ( return (
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]"> <div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
<div className="border-b border-[#E5E7EB] bg-white"> {children({
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2"> onCambiarPassword: () => setModalPassword(true),
<span className="text-xs font-medium text-[#64748B]"> onLogout: () => {
{sesion.usuario.nombre} · {sesion.usuario.rol}
</span>
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => setModalPassword(true)}
className="rounded-md px-2 py-1.5 text-xs font-medium text-[#4B5563] transition-all duration-100 ease-out hover:text-[#111827]"
>
Contraseña
</button>
<button
type="button"
onClick={() => {
logout() logout()
setSesion(null) setSesion(null)
}} },
className="rounded-md px-2 py-1.5 text-xs font-medium text-accent transition-all duration-100 ease-out hover:text-accent-hover" })}
>
Salir
</button>
</div>
</div>
</div>
{children}
{modalPassword && <CambiarPasswordModal onCerrar={() => setModalPassword(false)} />} {modalPassword && <CambiarPasswordModal onCerrar={() => setModalPassword(false)} />}
</div> </div>
) )

View File

@@ -11,7 +11,7 @@ import MiDiaPage from './pages/MiDiaPage'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<AuthGate> <AuthGate>
<MiDiaPage /> {(accionesSesion) => <MiDiaPage accionesSesion={accionesSesion} />}
</AuthGate> </AuthGate>
</StrictMode>, </StrictMode>,
) )

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react' import { useCallback, useEffect, useState, type FormEvent } from 'react'
import KpiCard from '../components/KpiCard' import KpiCard from '../components/KpiCard'
import type { AccionesSesion } from '../components/AuthGate'
import { import {
esActividadVencida, esActividadVencida,
esRolConEquipo, esRolConEquipo,
@@ -179,7 +180,7 @@ function FilaLead({
) )
} }
export default function MiDiaPage() { export default function MiDiaPage({ accionesSesion }: { accionesSesion: AccionesSesion }) {
const nombreVendedor = getSesion()?.usuario.nombre ?? '' const nombreVendedor = getSesion()?.usuario.nombre ?? ''
const rol = getSesion()?.usuario.rol ?? 'vendedor' const rol = getSesion()?.usuario.rol ?? 'vendedor'
const [resumen, setResumen] = useState<ResumenMiDia | null>(null) const [resumen, setResumen] = useState<ResumenMiDia | null>(null)
@@ -258,38 +259,78 @@ export default function MiDiaPage() {
} }
const hoy = hoyLocal() const hoy = hoyLocal()
const iniciales = nombreVendedor
.split(' ')
.filter((p) => p !== '')
.map((p) => p.charAt(0))
.slice(0, 2)
.join('')
.toUpperCase()
return ( return (
<main className="mx-auto max-w-6xl px-4 py-8"> <>
<div className="mb-8 flex items-end justify-between"> <header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
<div> <div className="mx-auto flex h-14 max-w-[1280px] items-center justify-between gap-4 px-4 sm:px-6">
<p className="text-xs font-semibold uppercase tracking-wider text-accent"> <div className="flex min-w-0 items-center gap-3">
<p className="shrink-0 text-xs font-semibold uppercase tracking-wider text-accent">
FRASA <span aria-hidden="true" className="text-[#F97316]">·</span> Mi día FRASA <span aria-hidden="true" className="text-[#F97316]">·</span> Mi día
</p> </p>
<h1 className="mt-2 text-3xl font-semibold text-[#111827]">{nombreVendedor}</h1> <span aria-hidden="true" className="hidden h-4 w-px bg-[#E5E7EB] sm:block" />
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden="true"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-accent/10 text-xs font-semibold text-accent"
>
{iniciales}
</span>
<span className="hidden truncate text-sm font-medium text-[#111827] sm:block">{nombreVendedor}</span>
</div> </div>
</div>
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
<button
type="button"
onClick={accionesSesion.onCambiarPassword}
className="rounded-md px-2 py-1.5 text-xs font-medium text-[#4B5563] transition-all duration-100 ease-out hover:text-[#111827]"
>
Contraseña
</button>
<button
type="button"
onClick={accionesSesion.onLogout}
className="rounded-md px-2 py-1.5 text-xs font-medium text-accent transition-all duration-100 ease-out hover:text-accent-hover"
>
Salir
</button>
<button <button
type="button" type="button"
onClick={() => void cargar()} onClick={() => void cargar()}
disabled={cargando} disabled={cargando}
className="rounded-lg bg-accent px-5 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50" className="rounded-lg bg-accent px-4 py-2 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50 sm:text-sm"
> >
{cargando ? 'Actualizando…' : 'Actualizar'} {cargando ? 'Actualizando…' : 'Actualizar'}
</button> </button>
</div> </div>
</div>
</header>
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6">
<div className="mb-6">
<h1 className="text-xl font-semibold text-[#111827]">{nombreVendedor}</h1>
<p className="mt-1 text-sm text-[#64748B]">{formatoFechaCorta(hoy)}</p>
</div>
{error && ( {error && (
<p role="alert" className="mb-8 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-700"> <p role="alert" className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-700">
Sin datos del API — {error} Sin datos del API — {error}
</p> </p>
)} )}
{aviso && ( {aviso && (
<p role="alert" className="mb-8 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700"> <p role="alert" className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">
{aviso} {aviso}
</p> </p>
)} )}
{resumen?.sinMapeoMonday && ( {resumen?.sinMapeoMonday && (
<p className="mb-8 rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-sm font-medium text-[#64748B] shadow-sm"> <p className="mb-6 rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-sm font-medium text-[#64748B] shadow-sm">
Tu usuario aún no está vinculado a Monday; las acciones siguen funcionando Tu usuario aún no está vinculado a Monday; las acciones siguen funcionando
</p> </p>
)} )}
@@ -309,61 +350,9 @@ export default function MiDiaPage() {
<KpiCard titulo="Leads nuevos esta semana" valor={resumen.conteos.leadsNuevosSemana} /> <KpiCard titulo="Leads nuevos esta semana" valor={resumen.conteos.leadsNuevosSemana} />
</section> </section>
<section aria-label="Mis actividades" className="mt-12"> <div className="mt-6 flex flex-col gap-6 lg:grid lg:grid-cols-3 lg:gap-6">
<TituloSeccion>Mis actividades</TituloSeccion> <div className="order-2 flex flex-col gap-6 lg:order-1 lg:col-span-2">
{resumen.misActividadesHoy.length === 0 ? ( <section aria-label="Mis leads">
<EstadoVacio mensaje="Sin actividades para hoy" />
) : (
<ul className="space-y-2">
{resumen.misActividadesHoy.map((a) => (
<li
key={a.id}
className="flex items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
>
<p className="text-sm">
{a.nombre}
{a.tipo ? <span className="ml-2 text-xs uppercase tracking-wider text-[#94A3B8]">{a.tipo}</span> : null}
</p>
<span
className={`text-xs font-medium ${
esActividadVencida(a.fecha, hoy) ? 'text-red-600' : 'text-[#64748B]'
}`}
>
{formatoFechaCorta(a.fecha)}
</span>
</li>
))}
</ul>
)}
</section>
{resumen.apartadosPorVencer.length > 0 && (
<section aria-label="Apartados por vencer" className="mt-12">
<TituloSeccion>Apartados por vencer</TituloSeccion>
<ul className="space-y-2">
{resumen.apartadosPorVencer.map((a) => (
<li
key={a.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
>
<p className="text-sm">
{a.nombre}
{a.unidadCodigo ? <span className="ml-2 text-xs text-[#94A3B8]">{a.unidadCodigo}</span> : null}
</p>
<span
className={`text-xs font-medium ${
a.diasRestantes <= 1 ? 'text-red-600' : 'text-amber-600'
}`}
>
{textoDiasRestantes(a.diasRestantes)} · {formatoFechaCorta(a.fechaLimite)}
</span>
</li>
))}
</ul>
</section>
)}
<section aria-label="Mis leads" className="mt-12">
<TituloSeccion>Mis leads</TituloSeccion> <TituloSeccion>Mis leads</TituloSeccion>
{resumen.misLeads.length === 0 ? ( {resumen.misLeads.length === 0 ? (
<EstadoVacio mensaje="Sin leads asignados" /> <EstadoVacio mensaje="Sin leads asignados" />
@@ -382,7 +371,7 @@ export default function MiDiaPage() {
)} )}
</section> </section>
<section aria-label="Mis capturas de afluencia" className="mt-12"> <section aria-label="Mis capturas de afluencia">
<TituloSeccion>Mis capturas de afluencia</TituloSeccion> <TituloSeccion>Mis capturas de afluencia</TituloSeccion>
{resumen.misCapturas.length === 0 ? ( {resumen.misCapturas.length === 0 ? (
<EstadoVacio mensaje="Aún no registras afluencias" /> <EstadoVacio mensaje="Aún no registras afluencias" />
@@ -414,7 +403,7 @@ export default function MiDiaPage() {
)} )}
</section> </section>
<section aria-label="Mis clientes" className="mt-12"> <section aria-label="Mis clientes">
<TituloSeccion>Mis clientes</TituloSeccion> <TituloSeccion>Mis clientes</TituloSeccion>
{resumen.misClientes.length === 0 ? ( {resumen.misClientes.length === 0 ? (
<EstadoVacio mensaje="Aún no registras clientes" /> <EstadoVacio mensaje="Aún no registras clientes" />
@@ -450,12 +439,68 @@ export default function MiDiaPage() {
</ul> </ul>
)} )}
</section> </section>
</div>
<section aria-label="Captura rápida de lead" className="mt-12"> <aside className="order-1 flex flex-col gap-6 lg:order-2 lg:sticky lg:top-20 lg:self-start">
<section aria-label="Mis actividades">
<TituloSeccion>Mis actividades</TituloSeccion>
{resumen.misActividadesHoy.length === 0 ? (
<EstadoVacio mensaje="Sin actividades para hoy" />
) : (
<ul className="space-y-2">
{resumen.misActividadesHoy.map((a) => (
<li
key={a.id}
className="flex items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
>
<p className="text-sm">
{a.nombre}
{a.tipo ? <span className="ml-2 text-xs uppercase tracking-wider text-[#94A3B8]">{a.tipo}</span> : null}
</p>
<span
className={`text-xs font-medium ${
esActividadVencida(a.fecha, hoy) ? 'text-red-600' : 'text-[#64748B]'
}`}
>
{formatoFechaCorta(a.fecha)}
</span>
</li>
))}
</ul>
)}
</section>
{resumen.apartadosPorVencer.length > 0 && (
<section aria-label="Apartados por vencer">
<TituloSeccion>Apartados por vencer</TituloSeccion>
<ul className="space-y-2">
{resumen.apartadosPorVencer.map((a) => (
<li
key={a.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
>
<p className="text-sm">
{a.nombre}
{a.unidadCodigo ? <span className="ml-2 text-xs text-[#94A3B8]">{a.unidadCodigo}</span> : null}
</p>
<span
className={`text-xs font-medium ${
a.diasRestantes <= 1 ? 'text-red-600' : 'text-amber-600'
}`}
>
{textoDiasRestantes(a.diasRestantes)} · {formatoFechaCorta(a.fechaLimite)}
</span>
</li>
))}
</ul>
</section>
)}
<section aria-label="Captura rápida de lead">
<TituloSeccion>Captura rápida de lead</TituloSeccion> <TituloSeccion>Captura rápida de lead</TituloSeccion>
<form <form
onSubmit={enviarCaptura} onSubmit={enviarCaptura}
className="grid gap-4 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm sm:grid-cols-2" className="grid gap-4 rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm"
> >
<div> <div>
<label htmlFor="cap-nombre" className={CLASE_LABEL}> <label htmlFor="cap-nombre" className={CLASE_LABEL}>
@@ -544,7 +589,7 @@ export default function MiDiaPage() {
{capMensaje && ( {capMensaje && (
<p <p
role="status" role="status"
className={`rounded-lg border px-4 py-3 text-sm font-medium sm:col-span-2 ${ className={`rounded-lg border px-4 py-3 text-sm font-medium ${
capMensaje.tipo === 'ok' capMensaje.tipo === 'ok'
? 'border-green-200 bg-green-50 text-green-700' ? 'border-green-200 bg-green-50 text-green-700'
: 'border-red-200 bg-red-50 text-red-700' : 'border-red-200 bg-red-50 text-red-700'
@@ -553,20 +598,22 @@ export default function MiDiaPage() {
{capMensaje.texto} {capMensaje.texto}
</p> </p>
)} )}
<div className="sm:col-span-2"> <div>
<button <button
type="submit" type="submit"
disabled={capEnviando} disabled={capEnviando}
className="rounded-lg bg-accent px-5 py-3 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50" className="w-full 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"
> >
{capEnviando ? 'Registrando…' : 'Registrar lead'} {capEnviando ? 'Registrando…' : 'Registrar lead'}
</button> </button>
</div> </div>
</form> </form>
</section> </section>
</aside>
</div>
{esRolConEquipo(rol) && resumen.equipo && ( {esRolConEquipo(rol) && resumen.equipo && (
<section aria-label="Equipo" className="mt-12"> <section aria-label="Equipo" className="mt-6">
<TituloSeccion>Equipo</TituloSeccion> <TituloSeccion>Equipo</TituloSeccion>
<p className="mb-4 text-sm text-[#64748B]"> <p className="mb-4 text-sm text-[#64748B]">
Leads nuevos sin asignar: <strong>{resumen.equipo.leadsNuevosSinAsignar}</strong> Leads nuevos sin asignar: <strong>{resumen.equipo.leadsNuevosSinAsignar}</strong>
@@ -574,7 +621,7 @@ export default function MiDiaPage() {
{resumen.equipo.porVendedor.length === 0 ? ( {resumen.equipo.porVendedor.length === 0 ? (
<EstadoVacio mensaje="Sin leads asignados en el equipo" /> <EstadoVacio mensaje="Sin leads asignados en el equipo" />
) : ( ) : (
<ul className="space-y-2"> <ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{resumen.equipo.porVendedor.map((v) => ( {resumen.equipo.porVendedor.map((v) => (
<li <li
key={v.mondayUserId} key={v.mondayUserId}
@@ -592,6 +639,7 @@ export default function MiDiaPage() {
)} )}
</> </>
)} )}
</main>
{descartando && ( {descartando && (
<ModalDescartar <ModalDescartar
@@ -608,6 +656,6 @@ export default function MiDiaPage() {
}} }}
/> />
)} )}
</main> </>
) )
} }