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
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() {
const [sessionId, setSessionId] = useState<string | null>(null)
const [mensajes, setMensajes] = useState<Mensaje[]>([])
@@ -70,8 +77,8 @@ export default function ChatPage() {
return (
<div className="flex min-h-screen flex-col bg-[#F8FAFC] text-[#111827]">
<header className="border-b border-[#E5E7EB] bg-white">
<div className="mx-auto flex w-full max-w-3xl items-center justify-between px-4 py-4">
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
<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="font-bold">FRASA</span>
<span className="text-[#F97316]"> · </span>
@@ -80,14 +87,14 @@ export default function ChatPage() {
<button
type="button"
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
</button>
</div>
</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 && (
<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
@@ -101,35 +108,50 @@ export default function ChatPage() {
)}
{mensajes.length === 0 && !escribiendo ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center">
<div className="flex flex-1 flex-col items-center justify-center">
<div className="w-full max-w-lg rounded-xl border border-[#E5E7EB] bg-white p-6 text-center shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Asistente interno
</p>
<p className="max-w-md text-2xl font-light text-[#111827]">
Pregunta sobre el catálogo DIAGONAL, las propuestas del cotizador, el proceso de venta
o las políticas de Frasa.
<p className="mt-3 text-xl font-light leading-relaxed text-[#111827]">
Pregunta sobre el catálogo DIAGONAL, las propuestas del cotizador, el proceso de
venta 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 className="flex flex-1 flex-col gap-6">
{mensajes.map((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>
)}
</main>
<footer className="border-t border-[#E5E7EB] bg-white">
<footer className="sticky bottom-0 border-t border-[#E5E7EB] bg-white">
<div className="relative mx-auto w-full max-w-3xl px-4 sm:px-6">
{escribiendo && (
<div className="pointer-events-none absolute -top-11 left-4 sm:left-6">
<p className="rounded-full border border-[#E5E7EB] bg-white px-4 py-2 text-xs font-medium text-[#64748B] shadow-md">
Escribiendo…
</p>
</div>
)}
<form
className="mx-auto flex w-full max-w-3xl items-stretch gap-4 px-4 py-4"
className="flex items-stretch gap-3 py-4"
onSubmit={(e) => {
e.preventDefault()
void enviar()
@@ -155,6 +177,7 @@ export default function ChatPage() {
Enviar
</button>
</form>
</div>
</footer>
</div>
)

View File

@@ -173,7 +173,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
}
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">
<label htmlFor="afluencia-nombre" className="block sm:col-span-2">
<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 (
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
<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]">
{sesion.usuario.nombre} · {sesion.usuario.rol}
</span>

View File

@@ -12,7 +12,7 @@ export default function ChecklistExpediente({ documentos, firmaDataUrl }: Props)
return (
<section
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]">
Expediente: {estado.completos}/{estado.total}

View File

@@ -6,13 +6,13 @@ interface Props {
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) {
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>
{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>
)
}

View File

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

View File

@@ -31,9 +31,7 @@ export default function DocumentosForm({
const ine = documentos.find((d) => d.slot === 'INE')
return (
<div>
<div className="mb-8">
<ChecklistExpediente documentos={documentos} firmaDataUrl={firmaDataUrl} />
</div>
<SectionCard
titulo="Documentos del expediente"
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 (
<div className="space-y-16">
<div>
<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">
<div>
@@ -126,7 +126,7 @@ export default function FirmasForm({ alta, onCambiarFirma, onAnterior }: Props)
</label>
</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">
{avisoExpediente && (
@@ -158,7 +158,7 @@ export default function FirmasForm({ alta, onCambiarFirma, onAnterior }: Props)
</div>
</SectionCard>
<div>
<div className="sticky bottom-0 flex items-center rounded-b-xl border-t border-[#E5E7EB] bg-white px-6 py-4">
<button
type="button"
onClick={onAnterior}

View File

@@ -8,7 +8,7 @@ interface Props {
/** Botones de navegación del wizard */
export default function Navegacion({ onAnterior, onSiguiente, textoSiguiente = 'Siguiente' }: Props) {
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 ? (
<button
type="button"

View File

@@ -43,7 +43,7 @@ export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: Pa
))}
</SectionCard>
{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} />
</form>

View File

@@ -42,7 +42,7 @@ export default function ViviendaCreditoForm({ valores, onAnterior, onSiguiente }
return (
<FormProvider {...metodos}>
<form onSubmit={metodos.handleSubmit(onSiguiente)} noValidate>
<div className="space-y-16">
<div>
<SectionCard titulo="Datos vivienda">
<CampoSelect
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 DatosGeneralesForm from '../forms/DatosGeneralesForm'
import EmpleoForm from '../forms/EmpleoForm'
@@ -40,6 +41,36 @@ function paramCotizacion(): string | null {
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' })
/** 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() {
const {
paso,
@@ -239,7 +270,7 @@ export default function AltaClientePage() {
if (pantalla === 'cargando') {
return (
<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>
</main>
</div>
@@ -249,35 +280,45 @@ export default function AltaClientePage() {
if (pantalla === 'entrada') {
return (
<div className="min-h-screen bg-[#F8FAFC]">
<header className="border-b border-[#E5E7EB] bg-white px-4 py-6">
<div className="mx-auto flex max-w-6xl items-baseline gap-3">
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span>
<h1 className="text-2xl font-semibold text-[#111827]">Alta de Clientes</h1>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-16">
<div className="grid gap-6 sm:grid-cols-2">
<AppHeader titulo="Alta de Clientes" />
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 sm:py-12">
<div className="mx-auto max-w-4xl">
<p className="text-sm text-[#64748B]">
Elige cómo registrar al interesado: captura ligera de afluencia o formato completo de alta.
</p>
<div className="mt-6 grid gap-6 sm:grid-cols-2">
<button
type="button"
onClick={() => setPantalla('afluencia')}
className="rounded-xl border border-[#E5E7EB] bg-white p-8 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
className="rounded-xl border border-[#E5E7EB] bg-white p-6 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
>
<h2 className="text-lg font-semibold text-[#111827]">Alta Afluencia</h2>
<p className="mt-2 text-sm text-[#64748B]">
<IconoIniciales iniciales="AA" />
<span className="mt-4 block text-lg font-semibold text-[#111827]">Alta Afluencia</span>
<span className="mt-2 block text-sm text-[#64748B]">
Captura rápida del interesado; entra al embudo de ventas.
</p>
</span>
<span className="mt-5 inline-flex items-center gap-1.5 text-sm font-medium text-accent">
Comenzar
<IconoFlecha />
</span>
</button>
<button
type="button"
onClick={() => setPantalla('picker')}
className="rounded-xl border border-[#E5E7EB] bg-white p-8 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
className="rounded-xl border border-[#E5E7EB] bg-white p-6 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
>
<h2 className="text-lg font-semibold text-[#111827]">Alta de Cliente</h2>
<p className="mt-2 text-sm text-[#64748B]">
<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.
</p>
</span>
<span className="mt-5 inline-flex items-center gap-1.5 text-sm font-medium text-accent">
Comenzar
<IconoFlecha />
</span>
</button>
</div>
</div>
</main>
</div>
)
@@ -286,17 +327,19 @@ export default function AltaClientePage() {
if (pantalla === 'afluencia') {
return (
<div className="min-h-screen bg-[#F8FAFC]">
<header className="border-b border-[#E5E7EB] bg-white px-4 py-6">
<div className="mx-auto flex max-w-6xl items-baseline gap-3">
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span>
<h1 className="text-2xl font-semibold text-[#111827]">Alta Afluencia</h1>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-16">
<AppHeader titulo="Alta Afluencia" />
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 sm:py-10">
<div className="mx-auto max-w-3xl">
<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>
</main>
</div>
)
@@ -305,13 +348,9 @@ export default function AltaClientePage() {
if (pantalla === 'picker') {
return (
<div className="min-h-screen bg-[#F8FAFC]">
<header className="border-b border-[#E5E7EB] bg-white px-4 py-6">
<div className="mx-auto flex max-w-6xl items-baseline gap-3">
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span>
<h1 className="text-2xl font-semibold text-[#111827]">Alta de Clientes</h1>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-16">
<AppHeader titulo="Alta de Clientes" />
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 sm:py-10">
<div className="max-w-3xl">
{borradores.length > 0 && (
<>
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
@@ -368,6 +407,7 @@ export default function AltaClientePage() {
<BusquedaLead onSeleccion={empezarDesdeLead} />
</div>
</section>
</div>
</main>
</div>
)
@@ -375,11 +415,10 @@ export default function AltaClientePage() {
return (
<div className="min-h-screen bg-[#F8FAFC]">
<header className="border-b border-[#E5E7EB] bg-white px-4 py-6">
<div className="mx-auto flex max-w-6xl flex-wrap items-baseline gap-3">
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span>
<h1 className="text-2xl font-semibold text-[#111827]">Alta de Clientes</h1>
<div className="ml-auto flex items-center gap-3">
<AppHeader
titulo="Alta de Clientes"
acciones={
<>
{avisoBorrador && (
<span role="status" className="text-xs text-[#64748B]">
{avisoBorrador}
@@ -397,21 +436,25 @@ export default function AltaClientePage() {
>
Guardar borrador
</button>
</div>
</div>
</header>
</>
}
/>
<main className="mx-auto max-w-6xl px-4 py-16">
<main className="mx-auto max-w-[1280px] px-4 py-6 sm:px-6 sm:py-8">
<div className="lg:grid lg:grid-cols-[260px_minmax(0,1fr)] lg:gap-8">
<div className="mb-6 lg:sticky lg:top-20 lg:mb-0 lg:self-start">
<Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPaso} />
</div>
<div className="w-full max-w-3xl">
{errorCarga && (
<p role="alert" className="mt-8 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700">
<p role="alert" className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700">
{errorCarga}
</p>
)}
<div className="mt-16">
{paso === 0 && (
<TarjetaPaso>
<DatosGeneralesForm
valores={datos.datosGenerales}
onSiguiente={(v) => {
@@ -420,8 +463,10 @@ export default function AltaClientePage() {
guardarAvance({ ...datos, datosGenerales: v }, paso + 1)
}}
/>
</TarjetaPaso>
)}
{paso === 1 && (
<TarjetaPaso>
<EmpleoForm
valores={datos.empleo}
onAnterior={anterior}
@@ -431,8 +476,10 @@ export default function AltaClientePage() {
guardarAvance({ ...datos, empleo: v }, paso + 1)
}}
/>
</TarjetaPaso>
)}
{paso === 2 && (
<TarjetaPaso>
<EconomicosForm
valores={datos.economicos}
onAnterior={anterior}
@@ -442,8 +489,10 @@ export default function AltaClientePage() {
guardarAvance({ ...datos, economicos: v }, paso + 1)
}}
/>
</TarjetaPaso>
)}
{paso === 3 && (
<TarjetaPaso>
<CoacreditadoForm
valores={datos.coacreditado}
onAnterior={anterior}
@@ -453,8 +502,10 @@ export default function AltaClientePage() {
guardarAvance({ ...datos, coacreditado: v }, paso + 1)
}}
/>
</TarjetaPaso>
)}
{paso === 4 && (
<TarjetaPaso>
<ReferenciasForm
valores={datos.referencias}
onAnterior={anterior}
@@ -464,8 +515,10 @@ export default function AltaClientePage() {
guardarAvance({ ...datos, referencias: v }, paso + 1)
}}
/>
</TarjetaPaso>
)}
{paso === 5 && (
<TarjetaPaso>
<ViviendaCreditoForm
valores={datos.viviendaCredito}
onAnterior={anterior}
@@ -475,18 +528,20 @@ export default function AltaClientePage() {
guardarAvance({ ...datos, viviendaCredito: v }, paso + 1)
}}
/>
</TarjetaPaso>
)}
{paso === 6 && (
<>
{cargoDeBorrador && (
<p
role="note"
className="mb-8 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"
className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"
>
Retomaste un borrador: los archivos de los documentos no se guardan en el borrador,
vuelve a adjuntarlos.
</p>
)}
<TarjetaPaso>
<DocumentosForm
documentos={alta.documentos}
firmaDataUrl={alta.firma.firmaDataUrl}
@@ -502,19 +557,20 @@ export default function AltaClientePage() {
guardarAvance(datos, paso + 1)
}}
/>
</TarjetaPaso>
</>
)}
{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">
<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-8 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"
className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"
>
Sin conexión al servidor — el expediente se generó solo localmente
</p>
@@ -522,13 +578,13 @@ export default function AltaClientePage() {
{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"
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-8 rounded-xl border border-[#E5E7EB] bg-white px-4 py-4 shadow-sm">
<div className="mb-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Firma en remoto
</p>
@@ -556,14 +612,17 @@ export default function AltaClientePage() {
{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>
</main>
</div>
)

View File

@@ -1,8 +1,23 @@
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 (
<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="mt-2 text-3xl font-light text-[#111827]">{valor}</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 }) {
return (
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
<header className="border-b border-[#E5E7EB] bg-white">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
<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">
<span className="font-bold">FRASA</span>
<span className="text-[#F97316]">.</span>{' '}
<span className="font-light text-[#64748B]">COBRANZA</span>
</Link>
<nav className="flex items-center gap-6 text-sm">
<nav className="flex items-center gap-3 text-sm sm:gap-6">
<Link
to="/"
className="text-[#64748B] transition-colors duration-150 hover:text-[#111827]"
@@ -20,20 +20,20 @@ export default function Layout({ children }: { children: ReactNode }) {
</Link>
<Link
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
</Link>
<Link
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
</Link>
</nav>
</div>
</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>
)
}

View File

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

View File

@@ -43,9 +43,9 @@ export default function HoyPage() {
}
return (
<div className="space-y-8">
<div className="space-y-6">
<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]">
Pagos vencidos y vencimientos de los próximos 7 días, en todos los planes activos.
</p>

View File

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

View File

@@ -133,14 +133,15 @@ export default function PlanDetallePage() {
}
}
const avance = avancePlan(plan)
return (
<div className="space-y-8">
<div className="flex flex-wrap items-start justify-between gap-6">
<div className="space-y-6">
<div>
<Link to="/" className="text-sm text-accent hover:underline">
← Volver al dashboard
</Link>
<h1 className="mt-2 text-3xl font-light text-[#111827]">{plan.clienteNombre}</h1>
<h1 className="mt-2 text-xl font-semibold text-[#111827]">{plan.clienteNombre}</h1>
<p className="mt-1 text-sm text-[#64748B]">
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
<span className="font-semibold text-[#111827]">{status}</span>
@@ -151,23 +152,6 @@ export default function PlanDetallePage() {
{tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>}
</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>
{offline && (
<p className="text-xs text-amber-700">
@@ -179,21 +163,52 @@ export default function PlanDetallePage() {
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
)}
{/* KPIs del plan */}
<div className="grid grid-cols-2 gap-6 lg:grid-cols-5">
<KpiCard titulo="Enganche total" valor={formatoMoneda(plan.engancheTotal)} />
<KpiCard titulo="Pagado" valor={formatoMoneda(pagadoTotal(plan))} />
<KpiCard titulo="Saldo" valor={formatoMoneda(saldo)} />
<KpiCard titulo="% avance" valor={formatoPorcentaje(avancePlan(plan))} />
<KpiCard titulo="Mensualidad" valor={formatoMoneda(plan.mensualidad)} detalle={`${plan.numPagos} pagos`} />
{/* En lg: columna principal (flex-1) + rail derecho sticky de ~340px */}
<div className="space-y-6 lg:flex lg:items-start lg:gap-6 lg:space-y-0">
{/* Columna principal */}
<div className="min-w-0 flex-1 space-y-6">
{/* Resumen del plan */}
<section
className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm sm:p-6"
aria-labelledby="titulo-resumen"
>
<h2 id="titulo-resumen" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Resumen del plan
</h2>
<div className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-3">
<KpiCard plano titulo="Enganche total" valor={formatoMoneda(plan.engancheTotal)} />
<KpiCard plano titulo="Pagado" valor={formatoMoneda(pagadoTotal(plan))} />
<KpiCard plano titulo="Saldo" valor={formatoMoneda(saldo)} />
<KpiCard plano titulo="% avance" valor={formatoPorcentaje(avance)} />
<KpiCard
plano
titulo="Mensualidad"
valor={formatoMoneda(plan.mensualidad)}
detalle={`${plan.numPagos} pagos`}
/>
{mora > 0 && (
<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 */}
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
@@ -300,9 +315,37 @@ export default function PlanDetallePage() {
</tbody>
</table>
</div>
</div>
{/* Rail derecho: acciones y bitácora */}
<aside className="space-y-6 lg:sticky lg:top-20 lg:w-[340px] lg:shrink-0">
<section
className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm"
aria-labelledby="titulo-acciones"
>
<h2 id="titulo-acciones" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Acciones
</h2>
<div className="mt-4 space-y-3">
<button
onClick={() => descargar(plan)}
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Estado de cuenta PDF
</button>
{status === 'ACTIVO' && saldo > 0 && (
<button
onClick={cancelar}
className="w-full rounded-lg border border-red-600 bg-white px-5 py-2.5 text-sm font-medium text-red-600 transition-all duration-100 ease-out hover:bg-red-600 hover:text-white"
>
Cancelar plan
</button>
)}
</div>
</section>
{/* Bitácora del plan */}
<section className="rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm" aria-labelledby="titulo-bitacora">
<section className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm" aria-labelledby="titulo-bitacora">
<h2 id="titulo-bitacora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Bitácora
</h2>
@@ -329,7 +372,7 @@ export default function PlanDetallePage() {
</ul>
)}
<form
className="mt-8"
className="mt-6"
onSubmit={(e) => {
e.preventDefault()
void guardarNota()
@@ -361,6 +404,8 @@ export default function PlanDetallePage() {
</div>
</form>
</section>
</aside>
</div>
{status === 'CANCELADO' && (
<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
* app con una barra discreta "nombre · rol", "Contraseña" (modal de cambio)
* y botón "Salir".
* app con un header blanco sticky (marca a la izquierda; "nombre · rol",
* "Contraseña" (modal de cambio) y botón "Salir" a la derecha).
*/
export default function AuthGate({ children }: { children: ReactNode }) {
const [sesion, setSesion] = useState<Sesion | null>(() => getSesion())
@@ -18,16 +18,22 @@ export default function AuthGate({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
<div className="border-b border-[#E5E7EB] bg-white">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
<span className="text-xs font-medium text-[#64748B]">
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
<div className="mx-auto flex h-16 max-w-[1280px] items-center justify-between gap-4 px-4 sm:px-6">
<p className="text-sm text-[#111827]">
<span className="font-bold">
FRASA DESARROLLOS<span className="text-[#F97316]">.</span>
</span>{' '}
<span className="hidden font-normal text-[#64748B] sm:inline">— Cotizador</span>
</p>
<div className="flex items-center gap-3 sm:gap-4">
<span className="hidden text-xs font-medium text-[#64748B] md:inline">
{sesion.usuario.nombre} · {sesion.usuario.rol}
</span>
<div className="flex items-center gap-4">
<button
type="button"
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
</button>
@@ -43,7 +49,7 @@ export default function AuthGate({ children }: { children: ReactNode }) {
</button>
</div>
</div>
</div>
</header>
{children}
{modalPassword && <CambiarPasswordModal onCerrar={() => setModalPassword(false)} />}
</div>

View File

@@ -72,7 +72,7 @@ export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulo
}
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">
<h2
id="comparador-titulo"

View File

@@ -99,7 +99,7 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
)
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">
<h2
id="comparador-unidades-titulo"

View File

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

View File

@@ -19,7 +19,7 @@ export default function AuthGate({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
<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]">
{sesion.usuario.nombre} · {sesion.usuario.rol}
</span>

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { Cell, Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } from 'recharts'
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 { 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.
* 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 [error, setError] = useState<string | null>(null)
const [cargando, setCargando] = useState(true)
@@ -34,29 +34,23 @@ export default function MiTablero() {
void cargar()
}, [cargar])
// Expone "Actualizar" al header sticky de DashboardPage
useEffect(() => {
alCambiarAcciones({ actualizar: () => void cargar(), cargando })
}, [alCambiarAcciones, cargar, cargando])
const funnel = resumen ? funnelMisLeads(resumen.misLeads) : []
return (
<main className="mx-auto max-w-6xl px-4 py-8">
<div className="mb-8 flex items-end justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-accent">FRASA Desarrollos</p>
<h1 className="mt-2 text-3xl font-light text-[#111827]">Mi tablero</h1>
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6">
<div className="mb-8">
<h1 className="text-xl font-semibold text-[#111827]">Mi tablero</h1>
{resumen && (
<p className="mt-1 text-xs text-[#64748B]">
<p className="mt-1 text-sm text-[#64748B]">
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
</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>
{error && (
<p role="alert" className="mb-8 rounded-lg bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700">
@@ -75,7 +69,7 @@ export default function MiTablero() {
{resumen && (
<>
<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
titulo="Mis leads activos"
valor={resumen.misLeads.length}
@@ -93,8 +87,8 @@ export default function MiTablero() {
/>
</section>
<section aria-label="Mis leads por estado" className="mt-12">
<TituloSeccion>Mis leads por estado</TituloSeccion>
<section aria-label="Mis leads por estado" className="mt-10">
<TarjetaGrafica titulo="Mis leads por estado">
{funnel.length === 0 ? (
<EstadoVacio mensaje="Sin leads asignados" />
) : (
@@ -113,6 +107,7 @@ export default function MiTablero() {
</ResponsiveContainer>
</div>
)}
</TarjetaGrafica>
</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> }) {
const rebanadas = rebanadasDe(datos)
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>
{rebanadas.length === 0 ? (
<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'] }) {
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>
<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) => (
<TarjetaDimension key={d.titulo} titulo={d.titulo} datos={d.registro(perfil)} />
))}

View File

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

View File

@@ -1,5 +1,14 @@
// 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 }) {
return (
<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 }) {
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 MiTablero from '../components/MiTablero'
import TableroGeneral from '../components/TableroGeneral'
import type { AccionesTablero } from '../components/comunes'
import { esRolConEquipo } from '../lib/mi-tablero'
import { getSesion } from '../services/auth'
@@ -8,23 +9,37 @@ type Vista = 'mia' | 'general'
const VISTAS: { id: Vista; etiqueta: string }[] = [
{ 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
* 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() {
const rol = getSesion()?.usuario.rol ?? 'vendedor'
const conEquipo = esRolConEquipo(rol)
const [vista, setVista] = useState<Vista>(conEquipo ? 'general' : 'mia')
const [acciones, setAcciones] = useState<AccionesTablero | null>(null)
return (
<div>
<div className="border-b border-[#E5E7EB] bg-white">
<div className="mx-auto flex max-w-6xl gap-2 px-4 py-3" role="tablist" aria-label="Vista del tablero">
<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 justify-between gap-x-4 gap-y-2 px-4 py-2 sm:px-6">
<div className="flex items-center gap-2.5">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-xs font-bold text-white">
F
</span>
<span className="text-sm font-semibold text-[#111827]">FRASA Desarrollos</span>
</div>
<div className="flex items-center gap-3">
<div
className="inline-flex rounded-lg border border-[#E5E7EB] bg-[#F1F5F9] p-1"
role="tablist"
aria-label="Vista del tablero"
>
{VISTAS.map((v) => (
<button
key={v.id}
@@ -34,16 +49,30 @@ export default function DashboardPage() {
onClick={() => setVista(v.id)}
className={
vista === v.id
? 'rounded-lg bg-accent px-5 py-2 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover'
: 'rounded-lg border border-[#D1D5DB] bg-white px-5 py-2 text-xs font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB]'
? 'rounded-md 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>
</div>
{vista === 'mia' ? <MiTablero /> : <TableroGeneral mostrarEquipo={conEquipo} />}
</div>
</header>
{vista === 'mia' ? (
<MiTablero alCambiarAcciones={setAcciones} />
) : (
<TableroGeneral mostrarEquipo={conEquipo} alCambiarAcciones={setAcciones} />
)}
</div>
)
}

View File

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

View File

@@ -95,7 +95,11 @@ export function ConfiguracionCuenta() {
perfil?.mondayUserId != null ? (usuarios?.find((u) => u.id === perfil.mondayUserId) ?? null) : null
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]">
Configuración de cuenta
</h2>
@@ -103,7 +107,7 @@ export function ConfiguracionCuenta() {
{!conSesion ? (
<form
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]">
Inicia sesión para vincular tu usuario de Monday.
@@ -150,7 +154,7 @@ export function ConfiguracionCuenta() {
</button>
</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>}
{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
}
// 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 */
export function TarjetaModulo({ modulo }: Props) {
const iniciales = INICIALES[modulo.id] ?? modulo.nombre.slice(0, 2).toUpperCase()
return (
<li>
<a
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>
<p className="mt-3 text-sm leading-relaxed text-[#64748B]">{modulo.descripcion}</p>
<span className="mt-auto pt-8 text-sm font-medium text-accent transition-colors duration-100 ease-out group-hover:text-accent-hover">
<span
aria-hidden="true"
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>
</span>
</a>

View File

@@ -40,6 +40,12 @@ export function getToken(): string | null {
export function logout(): void {
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 */
@@ -57,6 +63,7 @@ export async function login(username: string, password: string): Promise<Usuario
if (!res.ok) throw new ErrorLogin()
const data = (await res.json()) as Sesion
localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: data.token, usuario: data.usuario }))
notificarCambioSesion()
return data.usuario
}

View File

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

View File

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

View File

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