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,60 +108,76 @@ 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">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]"> <div className="w-full max-w-lg rounded-xl border border-[#E5E7EB] bg-white p-6 text-center shadow-sm">
Asistente interno <p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
</p> Asistente interno
<p className="max-w-md text-2xl font-light text-[#111827]"> </p>
Pregunta sobre el catálogo DIAGONAL, las propuestas del cotizador, el proceso de venta <p className="mt-3 text-xl font-light leading-relaxed text-[#111827]">
o las políticas de Frasa. Pregunta sobre el catálogo DIAGONAL, las propuestas del cotizador, el proceso de
</p> venta o las políticas de Frasa.
</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">
<form <div className="relative mx-auto w-full max-w-3xl px-4 sm:px-6">
className="mx-auto flex w-full max-w-3xl items-stretch gap-4 px-4 py-4" {escribiendo && (
onSubmit={(e) => { <div className="pointer-events-none absolute -top-11 left-4 sm:left-6">
e.preventDefault() <p className="rounded-full border border-[#E5E7EB] bg-white px-4 py-2 text-xs font-medium text-[#64748B] shadow-md">
void enviar() Escribiendo…
}} </p>
> </div>
<label htmlFor="entrada-chat" className="sr-only"> )}
Escribe tu mensaje <form
</label> className="flex items-stretch gap-3 py-4"
<input onSubmit={(e) => {
id="entrada-chat" e.preventDefault()
type="text" void enviar()
value={entrada} }}
onChange={(e) => setEntrada(e.target.value)}
placeholder="Escribe tu pregunta…"
autoComplete="off"
className="flex-1 rounded-lg border border-[#D1D5DB] bg-white px-4 py-3 text-sm placeholder:text-[#94A3B8] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
/>
<button
type="submit"
disabled={escribiendo || entrada.trim().length === 0}
className="rounded-lg bg-accent px-6 py-3 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-40"
> >
Enviar <label htmlFor="entrada-chat" className="sr-only">
</button> Escribe tu mensaje
</form> </label>
<input
id="entrada-chat"
type="text"
value={entrada}
onChange={(e) => setEntrada(e.target.value)}
placeholder="Escribe tu pregunta…"
autoComplete="off"
className="flex-1 rounded-lg border border-[#D1D5DB] bg-white px-4 py-3 text-sm placeholder:text-[#94A3B8] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
/>
<button
type="submit"
disabled={escribiendo || entrada.trim().length === 0}
className="rounded-lg bg-accent px-6 py-3 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-40"
>
Enviar
</button>
</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,37 +4,84 @@ 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
{pasos.map((nombre, i) => { fillRule="evenodd"
const activo = i === pasoActual 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"
const completado = i < pasoActual clipRule="evenodd"
return ( />
<li key={nombre}> </svg>
<button )
type="button" }
onClick={() => completado && onIrAPaso(i)}
disabled={!completado} /**
aria-current={activo ? 'step' : undefined} * Stepper de progreso del wizard; permite volver a pasos ya visitados.
className={`text-xs font-medium transition-colors duration-150 ${ * En lg se muestra vertical (sidebar sticky en la página); en móvil colapsa
activo * a una barra horizontal de progreso.
? 'font-semibold text-accent' */
: completado export default function Stepper({ pasos, pasoActual, onIrAPaso }: Props) {
? 'text-[#111827] hover:text-accent' const avance = Math.round(((pasoActual + 1) / pasos.length) * 100)
: 'text-[#94A3B8]' return (
}`} <>
title={nombre} {/* Móvil: barra horizontal de progreso */}
> <div className="lg:hidden">
<span className="mr-2">{String(i + 1).padStart(2, '0')}</span> <div className="flex items-baseline justify-between gap-4">
<span className="hidden md:inline">{nombre}</span> <p className="text-sm font-medium text-[#111827]">{pasos[pasoActual]}</p>
</button> <p className="text-xs text-[#64748B]">
</li> Paso {pasoActual + 1} de {pasos.length}
) </p>
})} </div>
</ol> <div className="mt-2 h-1.5 rounded-full bg-[#E5E7EB]">
</nav> <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) => {
const activo = i === pasoActual
const completado = i < pasoActual
return (
<li key={nombre}>
<button
type="button"
onClick={() => completado && onIrAPaso(i)}
disabled={!completado}
aria-current={activo ? 'step' : undefined}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors duration-150 ${
activo
? 'bg-accent/5 font-semibold text-accent'
: completado
? 'text-[#111827] hover:bg-[#F1F5F9]'
: 'text-[#94A3B8]'
}`}
>
<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>
</li>
)
})}
</ol>
</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,34 +280,44 @@ 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-6 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-8 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md" >
> <IconoIniciales iniciales="AA" />
<h2 className="text-lg font-semibold text-[#111827]">Alta Afluencia</h2> <span className="mt-4 block text-lg font-semibold text-[#111827]">Alta Afluencia</span>
<p className="mt-2 text-sm text-[#64748B]"> <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>
</button> <span className="mt-5 inline-flex items-center gap-1.5 text-sm font-medium text-accent">
<button Comenzar
type="button" <IconoFlecha />
onClick={() => setPantalla('picker')} </span>
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" </button>
> <button
<h2 className="text-lg font-semibold text-[#111827]">Alta de Cliente</h2> type="button"
<p className="mt-2 text-sm text-[#64748B]"> onClick={() => setPantalla('picker')}
Formato completo para quien aparta. 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"
</p> >
</button> <IconoIniciales iniciales="AC" />
<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.
</span>
<span className="mt-5 inline-flex items-center gap-1.5 text-sm font-medium text-accent">
Comenzar
<IconoFlecha />
</span>
</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]">
Captura rápida del interesado; entra al embudo de ventas.
</p>
<div className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
<AltaAfluenciaForm
onVolver={() => setPantalla('entrada')}
onPasarAAltaCliente={empezarDesdeLead}
/>
</div>
</div> </div>
</header>
<main className="mx-auto max-w-6xl px-4 py-16">
<AltaAfluenciaForm
onVolver={() => setPantalla('entrada')}
onPasarAAltaCliente={empezarDesdeLead}
/>
</main> </main>
</div> </div>
) )
@@ -305,69 +348,66 @@ 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> {borradores.length > 0 && (
</div> <>
</header> <h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
<main className="mx-auto max-w-6xl px-4 py-16"> Tienes {borradores.length} borrador{borradores.length === 1 ? '' : 'es'} guardado
{borradores.length > 0 && ( {borradores.length === 1 ? '' : 's'}
<> </h2>
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]"> <ul className="mt-6 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
Tienes {borradores.length} borrador{borradores.length === 1 ? '' : 'es'} guardado {borradores.map((b) => (
{borradores.length === 1 ? '' : 's'} <li key={b.id} className="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
</h2> <div className="min-w-0">
<ul className="mt-6 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm"> <p className="truncate text-sm text-[#111827]">{b.nombre}</p>
{borradores.map((b) => ( <p className="mt-1 text-xs text-[#64748B]">
<li key={b.id} className="flex flex-wrap items-center justify-between gap-4 px-4 py-3"> Paso {b.paso + 1} de {PASOS.length} · {formatoFechaHora(b.updatedAt)}
<div className="min-w-0"> </p>
<p className="truncate text-sm text-[#111827]">{b.nombre}</p> </div>
<p className="mt-1 text-xs text-[#64748B]"> <div className="flex shrink-0 gap-2">
Paso {b.paso + 1} de {PASOS.length} · {formatoFechaHora(b.updatedAt)} <button
</p> type="button"
</div> onClick={() => void continuarBorrador(b.id)}
<div className="flex shrink-0 gap-2"> className="rounded-lg border border-accent bg-white px-3 py-1.5 text-xs font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white"
<button >
type="button" Continuar
onClick={() => void continuarBorrador(b.id)} </button>
className="rounded-lg border border-accent bg-white px-3 py-1.5 text-xs font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white" <button
> type="button"
Continuar onClick={() => void borrarBorrador(b.id)}
</button> className="rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 transition-all duration-100 ease-out hover:bg-red-50"
<button >
type="button" Eliminar
onClick={() => void borrarBorrador(b.id)} </button>
className="rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 transition-all duration-100 ease-out hover:bg-red-50" </div>
> </li>
Eliminar ))}
</button> </ul>
</div> </>
</li> )}
))} {errorCarga && <p className="mt-4 text-xs text-red-700">{errorCarga}</p>}
</ul> <button
</> type="button"
)} onClick={empezarNuevo}
{errorCarga && <p className="mt-4 text-xs text-red-700">{errorCarga}</p>} className="mt-8 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"
<button >
type="button" Empezar nuevo
onClick={empezarNuevo} </button>
className="mt-8 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"
>
Empezar nuevo
</button>
<section aria-labelledby="titulo-lead" className="mt-12 border-t border-[#E5E7EB] pt-8"> <section aria-labelledby="titulo-lead" className="mt-12 border-t border-[#E5E7EB] pt-8">
<h3 id="titulo-lead" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]"> <h3 id="titulo-lead" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Desde un lead de Monday Desde un lead de Monday
</h3> </h3>
<p className="mt-2 text-sm text-[#64748B]"> <p className="mt-2 text-sm text-[#64748B]">
Empieza un alta nueva con el nombre, email y celular del lead ya capturados. Empieza un alta nueva con el nombre, email y celular del lead ya capturados.
</p> </p>
<div className="mt-4"> <div className="mt-4">
<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,172 +436,192 @@ export default function AltaClientePage() {
> >
Guardar borrador Guardar borrador
</button> </button>
</>
}
/>
<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} />
</div> </div>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-16"> <div className="w-full max-w-3xl">
<Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPaso} /> {errorCarga && (
<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}
</p>
)}
{errorCarga && ( {paso === 0 && (
<p role="alert" className="mt-8 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"> <TarjetaPaso>
{errorCarga} <DatosGeneralesForm
</p> valores={datos.datosGenerales}
)} onSiguiente={(v) => {
actualizar('datosGenerales', v)
<div className="mt-16"> siguiente()
{paso === 0 && ( guardarAvance({ ...datos, datosGenerales: v }, paso + 1)
<DatosGeneralesForm }}
valores={datos.datosGenerales} />
onSiguiente={(v) => { </TarjetaPaso>
actualizar('datosGenerales', v) )}
siguiente() {paso === 1 && (
guardarAvance({ ...datos, datosGenerales: v }, paso + 1) <TarjetaPaso>
}} <EmpleoForm
/> valores={datos.empleo}
)} onAnterior={anterior}
{paso === 1 && ( onSiguiente={(v) => {
<EmpleoForm actualizar('empleo', v)
valores={datos.empleo} siguiente()
onAnterior={anterior} guardarAvance({ ...datos, empleo: v }, paso + 1)
onSiguiente={(v) => { }}
actualizar('empleo', v) />
siguiente() </TarjetaPaso>
guardarAvance({ ...datos, empleo: v }, paso + 1) )}
}} {paso === 2 && (
/> <TarjetaPaso>
)} <EconomicosForm
{paso === 2 && ( valores={datos.economicos}
<EconomicosForm onAnterior={anterior}
valores={datos.economicos} onSiguiente={(v) => {
onAnterior={anterior} actualizar('economicos', v)
onSiguiente={(v) => { siguiente()
actualizar('economicos', v) guardarAvance({ ...datos, economicos: v }, paso + 1)
siguiente() }}
guardarAvance({ ...datos, economicos: v }, paso + 1) />
}} </TarjetaPaso>
/> )}
)} {paso === 3 && (
{paso === 3 && ( <TarjetaPaso>
<CoacreditadoForm <CoacreditadoForm
valores={datos.coacreditado} valores={datos.coacreditado}
onAnterior={anterior} onAnterior={anterior}
onSiguiente={(v) => { onSiguiente={(v) => {
actualizar('coacreditado', v) actualizar('coacreditado', v)
siguiente() siguiente()
guardarAvance({ ...datos, coacreditado: v }, paso + 1) guardarAvance({ ...datos, coacreditado: v }, paso + 1)
}} }}
/> />
)} </TarjetaPaso>
{paso === 4 && ( )}
<ReferenciasForm {paso === 4 && (
valores={datos.referencias} <TarjetaPaso>
onAnterior={anterior} <ReferenciasForm
onSiguiente={(v) => { valores={datos.referencias}
actualizar('referencias', v) onAnterior={anterior}
siguiente() onSiguiente={(v) => {
guardarAvance({ ...datos, referencias: v }, paso + 1) actualizar('referencias', v)
}} siguiente()
/> guardarAvance({ ...datos, referencias: v }, paso + 1)
)} }}
{paso === 5 && ( />
<ViviendaCreditoForm </TarjetaPaso>
valores={datos.viviendaCredito} )}
onAnterior={anterior} {paso === 5 && (
onSiguiente={(v) => { <TarjetaPaso>
actualizar('viviendaCredito', v) <ViviendaCreditoForm
siguiente() valores={datos.viviendaCredito}
guardarAvance({ ...datos, viviendaCredito: v }, paso + 1) onAnterior={anterior}
}} onSiguiente={(v) => {
/> actualizar('viviendaCredito', v)
)} siguiente()
{paso === 6 && ( guardarAvance({ ...datos, viviendaCredito: v }, paso + 1)
<> }}
{cargoDeBorrador && ( />
<p </TarjetaPaso>
role="note" )}
className="mb-8 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700" {paso === 6 && (
> <>
Retomaste un borrador: los archivos de los documentos no se guardan en el borrador, {cargoDeBorrador && (
vuelve a adjuntarlos. <p
</p> role="note"
)} className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"
<DocumentosForm
documentos={alta.documentos}
firmaDataUrl={alta.firma.firmaDataUrl}
onAgregar={agregarDocumento}
onQuitar={quitarDocumento}
onAplicarDatosGenerales={(campos) =>
actualizar('datosGenerales', { ...datos.datosGenerales, ...campos })
}
onAnterior={anterior}
onSiguiente={() => {
void registrarAlta()
siguiente()
guardarAvance(datos, paso + 1)
}}
/>
</>
)}
{paso === 7 && (
<>
{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">
Alta registrada en el servidor: {registro}
</p>
)}
{sinConexion && (
<p
role="alert"
className="mb-8 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
</p>
)}
{errorValidacion && (
<p
role="alert"
className="mb-8 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700"
>
El servidor rechazó el alta: {errorValidacion}
</p>
)}
{registro && (
<div className="mb-8 rounded-xl border border-[#E5E7EB] bg-white px-4 py-4 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Firma en remoto
</p>
<p className="mt-2 text-sm text-[#64748B]">
Genera una liga pública (vigencia 72 h) para que el cliente firme y suba sus
documentos desde su celular, sin usuario ni contraseña.
</p>
<button
type="button"
onClick={() => void generarLiga()}
className="mt-4 rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
> >
Generar liga para firmar en remoto Retomaste un borrador: los archivos de los documentos no se guardan en el borrador,
</button> vuelve a adjuntarlos.
{ligaUrl && ( </p>
<p className="mt-4 break-all text-xs text-[#111827]"> )}
{ligaUrl} <TarjetaPaso>
{ligaCopiada && ( <DocumentosForm
<span role="status" className="ml-2 font-medium text-accent"> documentos={alta.documentos}
Liga y mensaje de WhatsApp copiados firmaDataUrl={alta.firma.firmaDataUrl}
</span> onAgregar={agregarDocumento}
)} onQuitar={quitarDocumento}
onAplicarDatosGenerales={(campos) =>
actualizar('datosGenerales', { ...datos.datosGenerales, ...campos })
}
onAnterior={anterior}
onSiguiente={() => {
void registrarAlta()
siguiente()
guardarAvance(datos, paso + 1)
}}
/>
</TarjetaPaso>
</>
)}
{paso === 7 && (
<>
{registro && (
<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}
</p>
)}
{sinConexion && (
<p
role="alert"
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
</p>
)}
{errorValidacion && (
<p
role="alert"
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}
</p>
)}
{registro && (
<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]">
Firma en remoto
</p> </p>
)} <p className="mt-2 text-sm text-[#64748B]">
{errorLiga && <p className="mt-2 text-xs text-red-700">{errorLiga}</p>} Genera una liga pública (vigencia 72 h) para que el cliente firme y suba sus
</div> documentos desde su celular, sin usuario ni contraseña.
)} </p>
<FirmasForm <button
alta={alta} type="button"
onCambiarFirma={(f) => actualizar('firma', f)} onClick={() => void generarLiga()}
onAnterior={anterior} className="mt-4 rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
/> >
</> Generar liga para firmar en remoto
)} </button>
{ligaUrl && (
<p className="mt-4 break-all text-xs text-[#111827]">
{ligaUrl}
{ligaCopiada && (
<span role="status" className="ml-2 font-medium text-accent">
Liga y mensaje de WhatsApp copiados
</span>
)}
</p>
)}
{errorLiga && <p className="mt-2 text-xs text-red-700">{errorLiga}</p>}
</div>
)}
<TarjetaPaso>
<FirmasForm
alta={alta}
onCambiarFirma={(f) => actualizar('firma', f)}
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>
{/* Filtros */} {/* Tarjeta unificada: toolbar de filtros/búsqueda + tabla de planes */}
<div className="flex flex-wrap items-center gap-4"> <div className="overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
{/* Filtros */}
<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
@@ -358,10 +360,10 @@ export default function DashboardPage() {
</option> </option>
))} ))}
</select> </select>
</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]">
@@ -417,6 +419,7 @@ export default function DashboardPage() {
)} )}
</tbody> </tbody>
</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,40 +133,24 @@ 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-xl font-semibold text-[#111827]">{plan.clienteNombre}</h1>
<h1 className="mt-2 text-3xl font-light 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> {atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>} </p>
</p> <p className="mt-1 text-sm text-[#64748B]">
<p className="mt-1 text-sm text-[#64748B]"> Tel. {plan.clienteTelefono || '—'}
Tel. {plan.clienteTelefono || '—'} {tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>}
{tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>} </p>
</p>
</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> </div>
{offline && ( {offline && (
@@ -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"
{mora > 0 && ( aria-labelledby="titulo-resumen"
<KpiCard >
titulo="Mora acumulada" <h2 id="titulo-resumen" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
valor={<span className="text-red-700">{formatoMoneda(mora)}</span>} Resumen del plan
detalle={`Tasa ${tasaMora}% mensual`} </h2>
/> <div className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-3">
)} <KpiCard plano titulo="Enganche total" valor={formatoMoneda(plan.engancheTotal)} />
</div> <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 && (
<KpiCard
plano
titulo="Mora acumulada"
valor={<span className="text-red-700">{formatoMoneda(mora)}</span>}
detalle={`Tasa ${tasaMora}% mensual`}
/>
)}
</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">
@@ -299,10 +314,38 @@ export default function PlanDetallePage() {
})} })}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
{/* Bitácora del plan */} {/* Rail derecho: acciones y bitácora */}
<section className="rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm" aria-labelledby="titulo-bitacora"> <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 */}
<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()
@@ -360,7 +403,9 @@ export default function PlanDetallePage() {
</button> </button>
</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]">
{sesion.usuario.nombre} · {sesion.usuario.rol} <span className="font-bold">
</span> FRASA DESARROLLOS<span className="text-[#F97316]">.</span>
<div className="flex items-center gap-4"> </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}
</span>
<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,278 +255,276 @@ 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"> Vigencia: hasta el {formatoFechaLarga(calcularVigencia())}
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] capitalize"> </p>
{formatoFechaLarga(new Date())}
</p>
<p className="mt-1 text-xs text-[#64748B]">
Vigencia: hasta el {formatoFechaLarga(calcularVigencia())}
</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>
)} )}
{cargando ? ( {cargando ? (
<p className="text-sm text-[#64748B]">Cargando unidades…</p> <p className="text-sm text-[#64748B]">Cargando unidades…</p>
) : ( ) : (
<> <>
<div> <div>
<span className={etiquetaCampo}>Desarrollo</span> <span className={etiquetaCampo}>Desarrollo</span>
<div className="flex flex-wrap overflow-hidden rounded-lg border border-[#D1D5DB]" role="group"> <div className="flex flex-wrap overflow-hidden rounded-lg border border-[#D1D5DB]" role="group">
{LISTA_DESARROLLOS.map((id) => { {LISTA_DESARROLLOS.map((id) => {
const activo = id === desarrollo const activo = id === desarrollo
const total = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === id).length const total = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === id).length
const disponibles = unidades.filter( const disponibles = unidades.filter(
(u) => (u.desarrollo ?? 'DIAGONAL') === id && u.status === 'DISPONIBLE', (u) => (u.desarrollo ?? 'DIAGONAL') === id && u.status === 'DISPONIBLE',
).length ).length
return ( return (
<button <button
key={id} key={id}
type="button" type="button"
aria-pressed={activo} aria-pressed={activo}
onClick={() => setDesarrollo(id)} onClick={() => setDesarrollo(id)}
className={`px-4 py-2.5 text-xs font-medium transition-colors duration-100 ${ className={`px-4 py-2.5 text-xs font-medium transition-colors duration-100 ${
activo activo
? 'bg-accent text-white' ? 'bg-accent text-white'
: 'bg-white text-[#4B5563] hover:bg-[#F9FAFB]' : 'bg-white text-[#4B5563] hover:bg-[#F9FAFB]'
}`} }`}
> >
{obtenerDesarrollo(id).nombre} {obtenerDesarrollo(id).nombre}
<span className={`ml-1 font-normal ${activo ? 'text-blue-100' : 'text-[#64748B]'}`}> <span className={`ml-1 font-normal ${activo ? 'text-blue-100' : 'text-[#64748B]'}`}>
{disponibles}/{total} {disponibles}/{total}
</span> </span>
</button> </button>
) )
})} })}
</div>
<p className="mt-2 text-xs text-[#64748B]">
{unidadesDesarrollo.length} unidades · {disponiblesDesarrollo} disponibles
</p>
</div> </div>
<p className="mt-2 text-xs text-[#64748B]"> <UnitSelector
{unidadesDesarrollo.length} unidades · {disponiblesDesarrollo} disponibles unidades={unidadesDesarrollo}
valor={unidad?.codigo ?? ''}
onChange={(codigo) =>
setUnidad(unidadesDesarrollo.find((u) => u.codigo === codigo) ?? null)
}
/>
</>
)}
{!params.precioUnico && (
<div>
<span className={etiquetaCampo}>Cocina y closet</span>
<div className="inline-flex overflow-hidden rounded-lg border border-[#D1D5DB]" role="group">
{(['SI', 'NO'] as const).map((opcion) => {
const activo = (opcion === 'SI') === acabados
return (
<button
key={opcion}
type="button"
aria-pressed={activo}
onClick={() => setAcabados(opcion === 'SI')}
className={`px-5 py-2.5 text-xs font-medium transition-colors duration-100 ${
activo
? 'bg-accent text-white'
: 'bg-white text-[#4B5563] hover:bg-[#F9FAFB]'
}`}
>
{opcion}
</button>
)
})}
</div>
</div>
)}
<div>
<label htmlFor="apartado" className={etiquetaCampo}>
Monto de apartado (MXN)
</label>
<input
id="apartado"
type="number"
min={0}
step={1000}
value={apartado}
onChange={(e) => {
const v = Number(e.target.value)
setApartado(Number.isFinite(v) && v >= 0 ? v : 0)
}}
className={errorApartado ? campoInputError : campoInput}
/>
{errorApartado ? (
<p className="mt-1 text-xs text-red-700">{errorApartado}</p>
) : (
<p className="mt-1 text-xs text-[#64748B]">{formatoMoneda(apartado)}</p>
)}
</div>
<div>
<label htmlFor="nombre" className={etiquetaCampo}>
Nombre del cliente *
</label>
<input
id="nombre"
type="text"
className={errors.nombre ? campoInputError : campoInput}
{...register('nombre')}
/>
{errors.nombre && <p className="mt-1 text-xs text-red-700">{errors.nombre.message}</p>}
</div>
<div>
<label htmlFor="email" className={etiquetaCampo}>
Correo electrónico (opcional)
</label>
<input
id="email"
type="email"
className={errors.email ? campoInputError : campoInput}
{...register('email')}
/>
{errors.email && <p className="mt-1 text-xs text-red-700">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="telefono" className={etiquetaCampo}>
Teléfono (opcional)
</label>
<input
id="telefono"
type="tel"
className={errors.telefono ? campoInputError : campoInput}
{...register('telefono')}
/>
{errors.telefono && (
<p className="mt-1 text-xs text-red-700">{errors.telefono.message}</p>
)}
</div>
{unidad && (
<div className="border-t border-[#E5E7EB] pt-6">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Precio de lista
</p>
<p className="mt-2 text-3xl font-semibold text-[#111827]">
{formatoMoneda(precioLista)}
</p> </p>
</div> </div>
<UnitSelector )}
unidades={unidadesDesarrollo}
valor={unidad?.codigo ?? ''} {unidad && (
onChange={(codigo) => <button
setUnidad(unidadesDesarrollo.find((u) => u.codigo === codigo) ?? null) type="button"
} onClick={handleNueva}
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Nueva cotización
</button>
)}
</section>
{unidad && <UnitInfoCard unidad={unidad} numMeses={numMeses} />}
</aside>
{/* Panel de propuestas */}
<section className="min-w-0">
{!unidad ? (
<div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#D1D5DB] bg-white">
<p className="text-sm text-[#64748B]">
Selecciona una unidad para ver las propuestas comerciales.
</p>
</div>
) : (
<div className="space-y-8">
<div className="grid gap-6 md:grid-cols-2">
{propuestas.map((p, i) => (
<ProposalCard
key={p.tipo}
numero={i + 1}
titulo={p.titulo}
propuesta={p}
onDescuentoChange={(v) => setDescuento(params.propuestas[i].claveDescuento, v)}
/>
))}
</div>
<ComparadorPropuestas
precioLista={precioLista}
apartado={apartadoEfectivo}
propuestas={propuestas}
titulos={propuestas.map((p) => p.titulo)}
/> />
</> <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>
{!params.precioUnico && ( <label htmlFor="propuesta-elegida" className={etiquetaCampo}>
<div> Propuesta elegida
<span className={etiquetaCampo}>Cocina y closet</span> </label>
<div className="inline-flex overflow-hidden rounded-lg border border-[#D1D5DB]" role="group"> <select
{(['SI', 'NO'] as const).map((opcion) => { id="propuesta-elegida"
const activo = (opcion === 'SI') === acabados value={Math.min(propuestaIndex, propuestas.length - 1)}
return ( onChange={(e) => setPropuestaIndex(Number(e.target.value))}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
>
{propuestas.map((p, i) => (
<option key={p.tipo} value={i}>
#{i + 1} {p.titulo}
</option>
))}
</select>
</div>
<button <button
key={opcion}
type="button" type="button"
aria-pressed={activo} onClick={handleCompartir}
onClick={() => setAcabados(opcion === 'SI')} disabled={!puedeGenerarPdf}
className={`px-5 py-2.5 text-xs font-medium transition-colors duration-100 ${ className="rounded-lg border border-accent bg-white px-5 py-2.5 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
activo
? 'bg-accent text-white'
: 'bg-white text-[#4B5563] hover:bg-[#F9FAFB]'
}`}
> >
{opcion} Compartir
</button> </button>
) <button
})} type="button"
onClick={handleGenerarPdf}
disabled={!puedeGenerarPdf}
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 disabled:cursor-not-allowed disabled:opacity-40"
>
{guardando ? 'Guardando…' : 'Guardar y generar PDF'}
</button>
</div>
{aviso && <p className="mt-2 text-right text-xs text-[#64748B]">{aviso}</p>}
{!puedeGenerarPdf && !guardando && (
<p className="mt-2 text-right text-xs text-[#64748B]">
Selecciona una unidad para guardar y generar el PDF.
</p>
)}
</div>
</div> </div>
</div>
)}
<div>
<label htmlFor="apartado" className={etiquetaCampo}>
Monto de apartado (MXN)
</label>
<input
id="apartado"
type="number"
min={0}
step={1000}
value={apartado}
onChange={(e) => {
const v = Number(e.target.value)
setApartado(Number.isFinite(v) && v >= 0 ? v : 0)
}}
className={errorApartado ? campoInputError : campoInput}
/>
{errorApartado ? (
<p className="mt-1 text-xs text-red-700">{errorApartado}</p>
) : (
<p className="mt-1 text-xs text-[#64748B]">{formatoMoneda(apartado)}</p>
)}
</div>
<div>
<label htmlFor="nombre" className={etiquetaCampo}>
Nombre del cliente *
</label>
<input
id="nombre"
type="text"
className={errors.nombre ? campoInputError : campoInput}
{...register('nombre')}
/>
{errors.nombre && <p className="mt-1 text-xs text-red-700">{errors.nombre.message}</p>}
</div>
<div>
<label htmlFor="email" className={etiquetaCampo}>
Correo electrónico (opcional)
</label>
<input
id="email"
type="email"
className={errors.email ? campoInputError : campoInput}
{...register('email')}
/>
{errors.email && <p className="mt-1 text-xs text-red-700">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="telefono" className={etiquetaCampo}>
Teléfono (opcional)
</label>
<input
id="telefono"
type="tel"
className={errors.telefono ? campoInputError : campoInput}
{...register('telefono')}
/>
{errors.telefono && (
<p className="mt-1 text-xs text-red-700">{errors.telefono.message}</p>
)}
</div>
{unidad && (
<div className="border-t border-[#E5E7EB] pt-8">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Precio de lista
</p>
<p className="mt-2 text-3xl font-semibold text-[#111827]">
{formatoMoneda(precioLista)}
</p>
</div>
)}
{unidad && (
<button
type="button"
onClick={handleNueva}
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Nueva cotización
</button>
)} )}
</section> </section>
</div>
{unidad && <UnitInfoCard unidad={unidad} numMeses={numMeses} />} {/* Secciones de ancho completo */}
</aside> <div className="mt-10 space-y-8">
{/* Panel de propuestas */}
<section>
<MisCotizaciones <MisCotizaciones
cotizaciones={misCotizaciones} cotizaciones={misCotizaciones}
cargando={cargandoMias} cargando={cargandoMias}
error={errorMias} error={errorMias}
onRetomar={handleRetomar} onRetomar={handleRetomar}
/> />
{!unidad ? (
<div className="mt-16 flex h-64 items-center justify-center rounded-xl border border-dashed border-[#D1D5DB] bg-white">
<p className="text-sm text-[#64748B]">
Selecciona una unidad para ver las propuestas comerciales.
</p>
</div>
) : (
<div className="mt-16">
<div className="grid gap-8 md:grid-cols-2">
{propuestas.map((p, i) => (
<ProposalCard
key={p.tipo}
numero={i + 1}
titulo={p.titulo}
propuesta={p}
onDescuentoChange={(v) => setDescuento(params.propuestas[i].claveDescuento, v)}
/>
))}
</div>
<ComparadorPropuestas
precioLista={precioLista}
apartado={apartadoEfectivo}
propuestas={propuestas}
titulos={propuestas.map((p) => p.titulo)}
/>
<div className="mt-16 flex flex-wrap items-center justify-end gap-4">
<div>
<label htmlFor="propuesta-elegida" className={etiquetaCampo}>
Propuesta elegida
</label>
<select
id="propuesta-elegida"
value={Math.min(propuestaIndex, propuestas.length - 1)}
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
>
{propuestas.map((p, i) => (
<option key={p.tipo} value={i}>
#{i + 1} {p.titulo}
</option>
))}
</select>
</div>
<button
type="button"
onClick={handleCompartir}
disabled={!puedeGenerarPdf}
className="rounded-lg border border-accent bg-white px-5 py-2.5 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
>
Compartir
</button>
<button
type="button"
onClick={handleGenerarPdf}
disabled={!puedeGenerarPdf}
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 disabled:cursor-not-allowed disabled:opacity-40"
>
{guardando ? 'Guardando…' : 'Guardar y generar PDF'}
</button>
</div>
{aviso && <p className="mt-2 text-right text-xs text-[#64748B]">{aviso}</p>}
{!puedeGenerarPdf && !guardando && (
<p className="mt-2 text-right text-xs text-[#64748B]">
Selecciona una unidad para guardar y generar el PDF.
</p>
)}
</div>
)}
<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,28 +34,22 @@ 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> {resumen && (
<h1 className="mt-2 text-3xl font-light text-[#111827]">Mi tablero</h1> <p className="mt-1 text-sm text-[#64748B]">
{resumen && ( Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
<p className="mt-1 text-xs text-[#64748B]"> </p>
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')} )}
</p>
)}
</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> </div>
{error && ( {error && (
@@ -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,26 +87,27 @@ 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" />
) : ( ) : (
<div className="h-72"> <div className="h-72">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<FunnelChart> <FunnelChart>
<Tooltip /> <Tooltip />
<Funnel dataKey="valor" data={funnel} nameKey="etapa" isAnimationActive={false}> <Funnel dataKey="valor" data={funnel} nameKey="etapa" isAnimationActive={false}>
{funnel.map((etapa, i) => ( {funnel.map((etapa, i) => (
<Cell key={etapa.etapa} fill={COLORES_FUNNEL[i % COLORES_FUNNEL.length]} /> <Cell key={etapa.etapa} fill={COLORES_FUNNEL[i % COLORES_FUNNEL.length]} />
))} ))}
<LabelList position="right" dataKey="etapa" stroke="none" fontSize={11} /> <LabelList position="right" dataKey="etapa" stroke="none" fontSize={11} />
<LabelList position="center" dataKey="valor" stroke="none" fill="#fff" fontSize={12} /> <LabelList position="center" dataKey="valor" stroke="none" fill="#fff" fontSize={12} />
</Funnel> </Funnel>
</FunnelChart> </FunnelChart>
</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,25 +87,14 @@ 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> {resumen && (
<h1 className="mt-2 text-3xl font-light text-[#111827]">Tablero comercial</h1> <p className="mt-1 text-sm text-[#64748B]">
{resumen && ( Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
<p className="mt-1 text-xs text-[#64748B]"> </p>
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')} )}
</p>
)}
</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> </div>
{error && ( {error && (
@@ -151,97 +151,101 @@ 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">
{unidades.length === 0 ? ( <TarjetaGrafica titulo="Unidades por desarrollo y status">
<EstadoVacio mensaje="Sin inventario" /> {unidades.length === 0 ? (
) : ( <EstadoVacio mensaje="Sin inventario" />
<div className="h-72"> ) : (
<ResponsiveContainer width="100%" height="100%"> <div className="h-72">
<BarChart data={unidades} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}> <ResponsiveContainer width="100%" height="100%">
<CartesianGrid strokeDasharray="3 3" vertical={false} /> <BarChart data={unidades} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
<XAxis dataKey="desarrollo" tick={{ fontSize: 11 }} /> <CartesianGrid strokeDasharray="3 3" vertical={false} />
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} /> <XAxis dataKey="desarrollo" tick={{ fontSize: 11 }} />
<Tooltip /> <YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
<Legend wrapperStyle={{ fontSize: 11 }} /> <Tooltip />
{Object.keys(COLORES_STATUS).map((status) => ( <Legend wrapperStyle={{ fontSize: 11 }} />
<Bar key={status} dataKey={status} stackId="unidades" fill={COLORES_STATUS[status]} /> {Object.keys(COLORES_STATUS).map((status) => (
))} <Bar key={status} dataKey={status} stackId="unidades" fill={COLORES_STATUS[status]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</section>
<section aria-label="Pipeline de ventas por etapa" className="mt-12">
<TituloSeccion>Pipeline de ventas por etapa</TituloSeccion>
{sinPipeline ? (
<EstadoVacio mensaje="Sin unidades en el pipeline" />
) : (
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={pipeline} layout="vertical" margin={{ top: 8, right: 24, left: 24, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} />
<YAxis type="category" dataKey="etapa" width={90} tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="valor" name="Unidades" fill="#2563EB" isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</section>
<div className="mt-12 grid gap-12 lg:grid-cols-2">
<section aria-label="Embudo comercial">
<TituloSeccion>Embudo: leads → oportunidades → ganado</TituloSeccion>
{funnel.every((e) => e.valor === 0) ? (
<EstadoVacio mensaje="Sin movimiento comercial" />
) : (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<FunnelChart>
<Tooltip />
<Funnel dataKey="valor" data={funnel} nameKey="etapa" isAnimationActive={false}>
{funnel.map((etapa, i) => (
<Cell key={etapa.etapa} fill={COLORES_FUNNEL[i % COLORES_FUNNEL.length]} />
))} ))}
<LabelList position="right" dataKey="etapa" stroke="none" fontSize={11} /> </BarChart>
<LabelList position="center" dataKey="valor" stroke="none" fill="#fff" fontSize={12} /> </ResponsiveContainer>
</Funnel> </div>
</FunnelChart> )}
</ResponsiveContainer> </TarjetaGrafica>
</div> </section>
)}
<section aria-label="Pipeline de ventas por etapa" className="lg:col-span-2">
<TarjetaGrafica titulo="Pipeline de ventas por etapa">
{sinPipeline ? (
<EstadoVacio mensaje="Sin unidades en el pipeline" />
) : (
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={pipeline} layout="vertical" margin={{ top: 8, right: 24, left: 24, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} />
<YAxis type="category" dataKey="etapa" width={90} tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="valor" name="Unidades" fill="#2563EB" isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</TarjetaGrafica>
</section>
<section aria-label="Embudo comercial">
<TarjetaGrafica titulo="Embudo: leads → oportunidades → ganado">
{funnel.every((e) => e.valor === 0) ? (
<EstadoVacio mensaje="Sin movimiento comercial" />
) : (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<FunnelChart>
<Tooltip />
<Funnel dataKey="valor" data={funnel} nameKey="etapa" isAnimationActive={false}>
{funnel.map((etapa, i) => (
<Cell key={etapa.etapa} fill={COLORES_FUNNEL[i % COLORES_FUNNEL.length]} />
))}
<LabelList position="right" dataKey="etapa" stroke="none" fontSize={11} />
<LabelList position="center" dataKey="valor" stroke="none" fill="#fff" fontSize={12} />
</Funnel>
</FunnelChart>
</ResponsiveContainer>
</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" />
) : ( ) : (
<div className="h-72"> <div className="h-72">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<PieChart> <PieChart>
<Pie <Pie
data={cobranza} data={cobranza}
dataKey="valor" dataKey="valor"
nameKey="nombre" nameKey="nombre"
innerRadius={60} innerRadius={60}
outerRadius={90} outerRadius={90}
isAnimationActive={false} isAnimationActive={false}
label={({ name, value }) => `${name}: ${value}`} label={({ name, value }) => `${name}: ${value}`}
fontSize={11} fontSize={11}
> >
{cobranza.map((rebanada, i) => ( {cobranza.map((rebanada, i) => (
<Cell key={rebanada.nombre} fill={COLORES_COBRANZA[i % COLORES_COBRANZA.length]} /> <Cell key={rebanada.nombre} fill={COLORES_COBRANZA[i % COLORES_COBRANZA.length]} />
))} ))}
</Pie> </Pie>
<Tooltip /> <Tooltip />
</PieChart> </PieChart>
</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,42 +9,70 @@ 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">
{VISTAS.map((v) => ( <div className="flex items-center gap-2.5">
<button <span className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-xs font-bold text-white">
key={v.id} F
type="button" </span>
role="tab" <span className="text-sm font-semibold text-[#111827]">FRASA Desarrollos</span>
aria-selected={vista === v.id} </div>
onClick={() => setVista(v.id)} <div className="flex items-center gap-3">
className={ <div
vista === v.id className="inline-flex rounded-lg border border-[#E5E7EB] bg-[#F1F5F9] p-1"
? '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' role="tablist"
: '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]' aria-label="Vista del tablero"
}
> >
{v.etiqueta} {VISTAS.map((v) => (
<button
key={v.id}
type="button"
role="tab"
aria-selected={vista === v.id}
onClick={() => setVista(v.id)}
className={
vista === v.id
? 'rounded-md bg-white px-3 py-1.5 text-xs font-medium text-[#111827] shadow-sm transition-all duration-100 ease-out'
: 'rounded-md px-3 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:text-[#111827]'
}
>
{v.etiqueta}
</button>
))}
</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> </button>
))} </div>
</div> </div>
</div> </header>
{vista === 'mia' ? <MiTablero /> : <TableroGeneral mostrarEquipo={conEquipo} />} {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
Ecosistema comercial id="titulo-hero"
</h1> className="text-3xl font-semibold tracking-tight text-[#111827] sm:text-4xl"
</header> >
Ecosistema comercial
</h1>
<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>
<ConfiguracionCuenta /> <div className="mt-6">
<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]">
<p className="text-xs text-[#64748B]">Consult (AS) Technology</p> <div className="mx-auto max-w-[1280px] px-6 py-8">
<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} logout()
</span> setSesion(null)
<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()
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,340 +259,387 @@ 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">
FRASA <span aria-hidden="true" className="text-[#F97316]">·</span> Mi día <p className="shrink-0 text-xs font-semibold uppercase tracking-wider text-accent">
</p> FRASA <span aria-hidden="true" className="text-[#F97316]">·</span> Mi día
<h1 className="mt-2 text-3xl font-semibold text-[#111827]">{nombreVendedor}</h1> </p>
<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 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
type="button"
onClick={() => void cargar()}
disabled={cargando}
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'}
</button>
</div>
</div> </div>
<button </header>
type="button"
onClick={() => void cargar()}
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"
>
{cargando ? 'Actualizando…' : 'Actualizar'}
</button>
</div>
{error && ( <main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6">
<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"> <div className="mb-6">
Sin datos del API — {error} <h1 className="text-xl font-semibold text-[#111827]">{nombreVendedor}</h1>
</p> <p className="mt-1 text-sm text-[#64748B]">{formatoFechaCorta(hoy)}</p>
)} </div>
{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">
{aviso}
</p>
)}
{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">
Tu usuario aún no está vinculado a Monday; las acciones siguen funcionando
</p>
)}
{!resumen && cargando && <EstadoVacio mensaje="Cargando…" />} {error && (
<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}
</p>
)}
{aviso && (
<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}
</p>
)}
{resumen?.sinMapeoMonday && (
<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
</p>
)}
{resumen && ( {!resumen && cargando && <EstadoVacio mensaje="Cargando…" />}
<>
<section aria-label="Indicadores del día" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard titulo="Leads asignados" valor={resumen.misLeads.length} />
<KpiCard
titulo="Actividades de hoy"
valor={resumen.misActividadesHoy.length}
detalle={`${resumen.conteos.actividadesAbiertas} abiertas en total`}
/>
<KpiCard titulo="Apartados por vencer" valor={resumen.apartadosPorVencer.length} detalle="próximos 7 días" />
<KpiCard titulo="Leads nuevos esta semana" valor={resumen.conteos.leadsNuevosSemana} />
</section>
<section aria-label="Mis actividades" className="mt-12"> {resumen && (
<TituloSeccion>Mis actividades</TituloSeccion> <>
{resumen.misActividadesHoy.length === 0 ? ( <section aria-label="Indicadores del día" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<EstadoVacio mensaje="Sin actividades para hoy" /> <KpiCard titulo="Leads asignados" valor={resumen.misLeads.length} />
) : ( <KpiCard
<ul className="space-y-2"> titulo="Actividades de hoy"
{resumen.misActividadesHoy.map((a) => ( valor={resumen.misActividadesHoy.length}
<li detalle={`${resumen.conteos.actividadesAbiertas} abiertas en total`}
key={a.id} />
className="flex items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm" <KpiCard titulo="Apartados por vencer" valor={resumen.apartadosPorVencer.length} detalle="próximos 7 días" />
> <KpiCard titulo="Leads nuevos esta semana" valor={resumen.conteos.leadsNuevosSemana} />
<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>
)}
<section aria-label="Mis leads" className="mt-12"> <div className="mt-6 flex flex-col gap-6 lg:grid lg:grid-cols-3 lg:gap-6">
<TituloSeccion>Mis leads</TituloSeccion> <div className="order-2 flex flex-col gap-6 lg:order-1 lg:col-span-2">
{resumen.misLeads.length === 0 ? ( <section aria-label="Mis leads">
<EstadoVacio mensaje="Sin leads asignados" /> <TituloSeccion>Mis leads</TituloSeccion>
) : ( {resumen.misLeads.length === 0 ? (
<ul className="space-y-2"> <EstadoVacio mensaje="Sin leads asignados" />
{resumen.misLeads.map((lead) => ( ) : (
<FilaLead <ul className="space-y-2">
key={lead.id} {resumen.misLeads.map((lead) => (
lead={lead} <FilaLead
enviando={enviando} key={lead.id}
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha), 'No se pudo actualizar el lead')} lead={lead}
onDescartar={() => setDescartando(lead)} enviando={enviando}
/> onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha), 'No se pudo actualizar el lead')}
))} onDescartar={() => setDescartando(lead)}
</ul> />
)} ))}
</section> </ul>
)}
</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" />
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{resumen.misCapturas.map((c) => ( {resumen.misCapturas.map((c) => (
<li <li
key={c.leadId} key={c.leadId}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm" className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
>
<div>
<p className="text-sm font-medium">{c.nombre}</p>
<p className="mt-1 text-xs text-[#64748B]">
{c.telefono || 'Sin teléfono'}
{c.origen ? ` · ${c.origen}` : ''}
</p>
</div>
<div className="flex items-center gap-3">
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
{c.estado || '—'}
</span>
<span className="text-xs font-medium text-[#64748B]">
{formatoFechaCorta(c.capturadoEn.slice(0, 10))}
</span>
</div>
</li>
))}
</ul>
)}
</section>
<section aria-label="Mis clientes">
<TituloSeccion>Mis clientes</TituloSeccion>
{resumen.misClientes.length === 0 ? (
<EstadoVacio mensaje="Aún no registras clientes" />
) : (
<ul className="space-y-2">
{resumen.misClientes.map((c) => (
<li
key={c.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
>
<div>
<p className="text-sm font-medium">{c.nombre}</p>
<p className="mt-1 text-xs text-[#64748B]">
{[c.proyecto, c.unidadCodigo].filter((p) => p !== '').join(' · ') || 'Alta sin proyecto'}
</p>
</div>
<div className="flex items-center gap-3">
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
c.completado
? 'bg-gray-100 text-gray-600'
: 'bg-amber-50 text-amber-700'
}`}
>
{c.completado ? 'Cliente' : 'En captura'}
</span>
<span className="text-xs font-medium text-[#64748B]">
{formatoFechaCorta(c.capturadoEn.slice(0, 10))}
</span>
</div>
</li>
))}
</ul>
)}
</section>
</div>
<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>
<form
onSubmit={enviarCaptura}
className="grid gap-4 rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm"
> >
<div> <div>
<p className="text-sm font-medium">{c.nombre}</p> <label htmlFor="cap-nombre" className={CLASE_LABEL}>
<p className="mt-1 text-xs text-[#64748B]"> Nombre
{c.telefono || 'Sin teléfono'} </label>
{c.origen ? ` · ${c.origen}` : ''} <input
</p> id="cap-nombre"
type="text"
value={capNombre}
onChange={(e) => setCapNombre(e.target.value)}
required
className={CLASE_INPUT}
/>
</div> </div>
<div className="flex items-center gap-3">
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
{c.estado || '—'}
</span>
<span className="text-xs font-medium text-[#64748B]">
{formatoFechaCorta(c.capturadoEn.slice(0, 10))}
</span>
</div>
</li>
))}
</ul>
)}
</section>
<section aria-label="Mis clientes" className="mt-12">
<TituloSeccion>Mis clientes</TituloSeccion>
{resumen.misClientes.length === 0 ? (
<EstadoVacio mensaje="Aún no registras clientes" />
) : (
<ul className="space-y-2">
{resumen.misClientes.map((c) => (
<li
key={c.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
>
<div> <div>
<p className="text-sm font-medium">{c.nombre}</p> <label htmlFor="cap-telefono" className={CLASE_LABEL}>
<p className="mt-1 text-xs text-[#64748B]"> Teléfono
{[c.proyecto, c.unidadCodigo].filter((p) => p !== '').join(' · ') || 'Alta sin proyecto'} </label>
</p> <input
id="cap-telefono"
type="tel"
value={capTelefono}
onChange={(e) => setCapTelefono(e.target.value)}
className={CLASE_INPUT}
/>
</div> </div>
<div className="flex items-center gap-3"> <div>
<span <label htmlFor="cap-email" className={CLASE_LABEL}>
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${ Email
c.completado </label>
? 'bg-gray-100 text-gray-600' <input
: 'bg-amber-50 text-amber-700' id="cap-email"
type="email"
value={capEmail}
onChange={(e) => setCapEmail(e.target.value)}
className={CLASE_INPUT}
/>
</div>
<div>
<label htmlFor="cap-origen" className={CLASE_LABEL}>
Origen
</label>
<select
id="cap-origen"
value={capOrigen}
onChange={(e) => setCapOrigen(e.target.value)}
className={CLASE_INPUT}
>
{ORIGENES_LEAD.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</div>
<div>
<label htmlFor="cap-desarrollo" className={CLASE_LABEL}>
Desarrollo de interés
</label>
<select
id="cap-desarrollo"
value={capDesarrollo}
onChange={(e) => setCapDesarrollo(e.target.value)}
className={CLASE_INPUT}
>
<option value="">Sin especificar</option>
{DESARROLLOS_LEAD.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
</div>
<div>
<label htmlFor="cap-notas" className={CLASE_LABEL}>
Notas
</label>
<input
id="cap-notas"
type="text"
value={capNotas}
onChange={(e) => setCapNotas(e.target.value)}
className={CLASE_INPUT}
/>
</div>
{capMensaje && (
<p
role="status"
className={`rounded-lg border px-4 py-3 text-sm font-medium ${
capMensaje.tipo === 'ok'
? 'border-green-200 bg-green-50 text-green-700'
: 'border-red-200 bg-red-50 text-red-700'
}`} }`}
> >
{c.completado ? 'Cliente' : 'En captura'} {capMensaje.texto}
</span> </p>
<span className="text-xs font-medium text-[#64748B]"> )}
{formatoFechaCorta(c.capturadoEn.slice(0, 10))} <div>
</span> <button
type="submit"
disabled={capEnviando}
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'}
</button>
</div> </div>
</li> </form>
))} </section>
</ul> </aside>
)} </div>
</section>
<section aria-label="Captura rápida de lead" className="mt-12"> {esRolConEquipo(rol) && resumen.equipo && (
<TituloSeccion>Captura rápida de lead</TituloSeccion> <section aria-label="Equipo" className="mt-6">
<form <TituloSeccion>Equipo</TituloSeccion>
onSubmit={enviarCaptura} <p className="mb-4 text-sm text-[#64748B]">
className="grid gap-4 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm sm:grid-cols-2" Leads nuevos sin asignar: <strong>{resumen.equipo.leadsNuevosSinAsignar}</strong>
>
<div>
<label htmlFor="cap-nombre" className={CLASE_LABEL}>
Nombre
</label>
<input
id="cap-nombre"
type="text"
value={capNombre}
onChange={(e) => setCapNombre(e.target.value)}
required
className={CLASE_INPUT}
/>
</div>
<div>
<label htmlFor="cap-telefono" className={CLASE_LABEL}>
Teléfono
</label>
<input
id="cap-telefono"
type="tel"
value={capTelefono}
onChange={(e) => setCapTelefono(e.target.value)}
className={CLASE_INPUT}
/>
</div>
<div>
<label htmlFor="cap-email" className={CLASE_LABEL}>
Email
</label>
<input
id="cap-email"
type="email"
value={capEmail}
onChange={(e) => setCapEmail(e.target.value)}
className={CLASE_INPUT}
/>
</div>
<div>
<label htmlFor="cap-origen" className={CLASE_LABEL}>
Origen
</label>
<select
id="cap-origen"
value={capOrigen}
onChange={(e) => setCapOrigen(e.target.value)}
className={CLASE_INPUT}
>
{ORIGENES_LEAD.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</div>
<div>
<label htmlFor="cap-desarrollo" className={CLASE_LABEL}>
Desarrollo de interés
</label>
<select
id="cap-desarrollo"
value={capDesarrollo}
onChange={(e) => setCapDesarrollo(e.target.value)}
className={CLASE_INPUT}
>
<option value="">Sin especificar</option>
{DESARROLLOS_LEAD.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
</div>
<div>
<label htmlFor="cap-notas" className={CLASE_LABEL}>
Notas
</label>
<input
id="cap-notas"
type="text"
value={capNotas}
onChange={(e) => setCapNotas(e.target.value)}
className={CLASE_INPUT}
/>
</div>
{capMensaje && (
<p
role="status"
className={`rounded-lg border px-4 py-3 text-sm font-medium sm:col-span-2 ${
capMensaje.tipo === 'ok'
? 'border-green-200 bg-green-50 text-green-700'
: 'border-red-200 bg-red-50 text-red-700'
}`}
>
{capMensaje.texto}
</p> </p>
)} {resumen.equipo.porVendedor.length === 0 ? (
<div className="sm:col-span-2"> <EstadoVacio mensaje="Sin leads asignados en el equipo" />
<button ) : (
type="submit" <ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
disabled={capEnviando} {resumen.equipo.porVendedor.map((v) => (
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" <li
> key={v.mondayUserId}
{capEnviando ? 'Registrando…' : 'Registrar lead'} className="flex items-center justify-between rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
</button> >
</div> <p className="text-sm">{v.nombre ?? `Monday #${v.mondayUserId}`}</p>
</form> <span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
</section> {v.leadsAsignados} lead(s)
</span>
{esRolConEquipo(rol) && resumen.equipo && ( </li>
<section aria-label="Equipo" className="mt-12"> ))}
<TituloSeccion>Equipo</TituloSeccion> </ul>
<p className="mb-4 text-sm text-[#64748B]"> )}
Leads nuevos sin asignar: <strong>{resumen.equipo.leadsNuevosSinAsignar}</strong> </section>
</p> )}
{resumen.equipo.porVendedor.length === 0 ? ( </>
<EstadoVacio mensaje="Sin leads asignados en el equipo" /> )}
) : ( </main>
<ul className="space-y-2">
{resumen.equipo.porVendedor.map((v) => (
<li
key={v.mondayUserId}
className="flex items-center justify-between rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
>
<p className="text-sm">{v.nombre ?? `Monday #${v.mondayUserId}`}</p>
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
{v.leadsAsignados} lead(s)
</span>
</li>
))}
</ul>
)}
</section>
)}
</>
)}
{descartando && ( {descartando && (
<ModalDescartar <ModalDescartar
@@ -608,6 +656,6 @@ export default function MiDiaPage() {
}} }}
/> />
)} )}
</main> </>
) )
} }