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:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}>
|
||||
|
||||
21
apps/alta-clientes/src/components/AppHeader.tsx
Normal file
21
apps/alta-clientes/src/components/AppHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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." />
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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)} />
|
||||
))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
46
apps/landing/src/components/Navbar.tsx
Normal file
46
apps/landing/src/components/Navbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">→</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import MiDiaPage from './pages/MiDiaPage'
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<AuthGate>
|
||||
<MiDiaPage />
|
||||
{(accionesSesion) => <MiDiaPage accionesSesion={accionesSesion} />}
|
||||
</AuthGate>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user