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
|
let contadorLocal = 0
|
||||||
const idLocal = () => `local-${++contadorLocal}`
|
const idLocal = () => `local-${++contadorLocal}`
|
||||||
|
|
||||||
|
const PREGUNTAS_SUGERIDAS = [
|
||||||
|
'¿Qué incluye la propuesta del cotizador DIAGONAL?',
|
||||||
|
'¿Cuáles son los pasos del proceso de venta?',
|
||||||
|
'¿Qué políticas de Frasa debo conocer?',
|
||||||
|
'¿Qué modelos hay en el catálogo DIAGONAL?',
|
||||||
|
]
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null)
|
const [sessionId, setSessionId] = useState<string | null>(null)
|
||||||
const [mensajes, setMensajes] = useState<Mensaje[]>([])
|
const [mensajes, setMensajes] = useState<Mensaje[]>([])
|
||||||
@@ -70,8 +77,8 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-[#F8FAFC] text-[#111827]">
|
<div className="flex min-h-screen flex-col bg-[#F8FAFC] text-[#111827]">
|
||||||
<header className="border-b border-[#E5E7EB] bg-white">
|
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||||
<div className="mx-auto flex w-full max-w-3xl items-center justify-between px-4 py-4">
|
<div className="mx-auto flex h-14 w-full max-w-3xl items-center justify-between px-4 sm:px-6">
|
||||||
<span className="text-sm tracking-wide">
|
<span className="text-sm tracking-wide">
|
||||||
<span className="font-bold">FRASA</span>
|
<span className="font-bold">FRASA</span>
|
||||||
<span className="text-[#F97316]"> · </span>
|
<span className="text-[#F97316]"> · </span>
|
||||||
@@ -80,14 +87,14 @@ export default function ChatPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={nuevaConversacion}
|
onClick={nuevaConversacion}
|
||||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||||
>
|
>
|
||||||
Nueva conversación
|
Nueva conversación
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="mx-auto flex w-full max-w-3xl flex-1 flex-col px-4 py-8">
|
<main className="mx-auto flex w-full max-w-3xl flex-1 flex-col px-4 py-8 sm:px-6">
|
||||||
{sinConexion && (
|
{sinConexion && (
|
||||||
<p className="mb-6 rounded-lg bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700">
|
<p className="mb-6 rounded-lg bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700">
|
||||||
Sin conexión al API del asistente. Revisa que el servicio esté disponible e inténtalo de
|
Sin conexión al API del asistente. Revisa que el servicio esté disponible e inténtalo de
|
||||||
@@ -101,60 +108,76 @@ export default function ChatPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{mensajes.length === 0 && !escribiendo ? (
|
{mensajes.length === 0 && !escribiendo ? (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center">
|
<div className="flex flex-1 flex-col items-center justify-center">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
<div className="w-full max-w-lg rounded-xl border border-[#E5E7EB] bg-white p-6 text-center shadow-sm">
|
||||||
Asistente interno
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||||
</p>
|
Asistente interno
|
||||||
<p className="max-w-md text-2xl font-light text-[#111827]">
|
</p>
|
||||||
Pregunta sobre el catálogo DIAGONAL, las propuestas del cotizador, el proceso de venta
|
<p className="mt-3 text-xl font-light leading-relaxed text-[#111827]">
|
||||||
o las políticas de Frasa.
|
Pregunta sobre el catálogo DIAGONAL, las propuestas del cotizador, el proceso de
|
||||||
</p>
|
venta o las políticas de Frasa.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 grid gap-2 sm:grid-cols-2">
|
||||||
|
{PREGUNTAS_SUGERIDAS.map((pregunta) => (
|
||||||
|
<button
|
||||||
|
key={pregunta}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEntrada(pregunta)}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-left text-xs font-medium text-[#374151] transition-colors hover:border-accent hover:text-accent"
|
||||||
|
>
|
||||||
|
{pregunta}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 flex-col gap-6">
|
<div className="flex flex-1 flex-col gap-6">
|
||||||
{mensajes.map((m) => (
|
{mensajes.map((m) => (
|
||||||
<Burbuja key={m.id} mensaje={m} />
|
<Burbuja key={m.id} mensaje={m} />
|
||||||
))}
|
))}
|
||||||
{escribiendo && (
|
|
||||||
<div className="flex justify-start">
|
|
||||||
<p className="rounded-full bg-white px-4 py-2 text-xs font-medium text-[#64748B] shadow-sm">
|
|
||||||
Escribiendo…
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div ref={finalRef} />
|
<div ref={finalRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="border-t border-[#E5E7EB] bg-white">
|
<footer className="sticky bottom-0 border-t border-[#E5E7EB] bg-white">
|
||||||
<form
|
<div className="relative mx-auto w-full max-w-3xl px-4 sm:px-6">
|
||||||
className="mx-auto flex w-full max-w-3xl items-stretch gap-4 px-4 py-4"
|
{escribiendo && (
|
||||||
onSubmit={(e) => {
|
<div className="pointer-events-none absolute -top-11 left-4 sm:left-6">
|
||||||
e.preventDefault()
|
<p className="rounded-full border border-[#E5E7EB] bg-white px-4 py-2 text-xs font-medium text-[#64748B] shadow-md">
|
||||||
void enviar()
|
Escribiendo…
|
||||||
}}
|
</p>
|
||||||
>
|
</div>
|
||||||
<label htmlFor="entrada-chat" className="sr-only">
|
)}
|
||||||
Escribe tu mensaje
|
<form
|
||||||
</label>
|
className="flex items-stretch gap-3 py-4"
|
||||||
<input
|
onSubmit={(e) => {
|
||||||
id="entrada-chat"
|
e.preventDefault()
|
||||||
type="text"
|
void enviar()
|
||||||
value={entrada}
|
}}
|
||||||
onChange={(e) => setEntrada(e.target.value)}
|
|
||||||
placeholder="Escribe tu pregunta…"
|
|
||||||
autoComplete="off"
|
|
||||||
className="flex-1 rounded-lg border border-[#D1D5DB] bg-white px-4 py-3 text-sm placeholder:text-[#94A3B8] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={escribiendo || entrada.trim().length === 0}
|
|
||||||
className="rounded-lg bg-accent px-6 py-3 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-40"
|
|
||||||
>
|
>
|
||||||
Enviar
|
<label htmlFor="entrada-chat" className="sr-only">
|
||||||
</button>
|
Escribe tu mensaje
|
||||||
</form>
|
</label>
|
||||||
|
<input
|
||||||
|
id="entrada-chat"
|
||||||
|
type="text"
|
||||||
|
value={entrada}
|
||||||
|
onChange={(e) => setEntrada(e.target.value)}
|
||||||
|
placeholder="Escribe tu pregunta…"
|
||||||
|
autoComplete="off"
|
||||||
|
className="flex-1 rounded-lg border border-[#D1D5DB] bg-white px-4 py-3 text-sm placeholder:text-[#94A3B8] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={escribiendo || entrada.trim().length === 0}
|
||||||
|
className="rounded-lg bg-accent px-6 py-3 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Enviar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => void enviar(e)} noValidate className="max-w-2xl">
|
<form onSubmit={(e) => void enviar(e)} noValidate>
|
||||||
<div className="grid gap-6 sm:grid-cols-2">
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
<label htmlFor="afluencia-nombre" className="block sm:col-span-2">
|
<label htmlFor="afluencia-nombre" className="block sm:col-span-2">
|
||||||
<span className={labelClase}>
|
<span className={labelClase}>
|
||||||
|
|||||||
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 (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||||
<div className="border-b border-[#E5E7EB] bg-white">
|
<div className="border-b border-[#E5E7EB] bg-white">
|
||||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
|
<div className="mx-auto flex max-w-[1280px] items-center justify-between px-4 py-2 sm:px-6">
|
||||||
<span className="text-xs font-medium text-[#64748B]">
|
<span className="text-xs font-medium text-[#64748B]">
|
||||||
{sesion.usuario.nombre} · {sesion.usuario.rol}
|
{sesion.usuario.nombre} · {sesion.usuario.rol}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default function ChecklistExpediente({ documentos, firmaDataUrl }: Props)
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-label="Checklist del expediente"
|
aria-label="Checklist del expediente"
|
||||||
className="rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
|
className="border-t border-[#E5E7EB] px-6 py-6 first:border-t-0"
|
||||||
>
|
>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||||
Expediente: {estado.completos}/{estado.total}
|
Expediente: {estado.completos}/{estado.total}
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ interface Props {
|
|||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tarjeta de sección del wizard */
|
/** Sección interna del paso del wizard (separada por divisor dentro de la tarjeta del paso) */
|
||||||
export default function SectionCard({ titulo, descripcion, children }: Props) {
|
export default function SectionCard({ titulo, descripcion, children }: Props) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
<section className="border-t border-[#E5E7EB] px-6 py-6 first:border-t-0">
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</h3>
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</h3>
|
||||||
{descripcion && <p className="mt-2 text-sm text-[#64748B]">{descripcion}</p>}
|
{descripcion && <p className="mt-2 text-sm text-[#64748B]">{descripcion}</p>}
|
||||||
<div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">{children}</div>
|
<div className="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">{children}</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,37 +4,84 @@ interface Props {
|
|||||||
onIrAPaso: (paso: number) => void
|
onIrAPaso: (paso: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stepper de progreso del wizard; permite volver a pasos ya visitados */
|
/** Palomita de paso completado */
|
||||||
export default function Stepper({ pasos, pasoActual, onIrAPaso }: Props) {
|
function IconoCheck() {
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Progreso">
|
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-3.5 w-3.5">
|
||||||
<ol className="flex flex-wrap items-baseline gap-x-6 gap-y-3 border-b border-[#E5E7EB] pb-4">
|
<path
|
||||||
{pasos.map((nombre, i) => {
|
fillRule="evenodd"
|
||||||
const activo = i === pasoActual
|
d="M16.704 5.29a1 1 0 010 1.42l-7.2 7.2a1 1 0 01-1.42 0l-3.2-3.2a1 1 0 111.42-1.42l2.49 2.5 6.49-6.5a1 1 0 011.42 0z"
|
||||||
const completado = i < pasoActual
|
clipRule="evenodd"
|
||||||
return (
|
/>
|
||||||
<li key={nombre}>
|
</svg>
|
||||||
<button
|
)
|
||||||
type="button"
|
}
|
||||||
onClick={() => completado && onIrAPaso(i)}
|
|
||||||
disabled={!completado}
|
/**
|
||||||
aria-current={activo ? 'step' : undefined}
|
* Stepper de progreso del wizard; permite volver a pasos ya visitados.
|
||||||
className={`text-xs font-medium transition-colors duration-150 ${
|
* En lg se muestra vertical (sidebar sticky en la página); en móvil colapsa
|
||||||
activo
|
* a una barra horizontal de progreso.
|
||||||
? 'font-semibold text-accent'
|
*/
|
||||||
: completado
|
export default function Stepper({ pasos, pasoActual, onIrAPaso }: Props) {
|
||||||
? 'text-[#111827] hover:text-accent'
|
const avance = Math.round(((pasoActual + 1) / pasos.length) * 100)
|
||||||
: 'text-[#94A3B8]'
|
return (
|
||||||
}`}
|
<>
|
||||||
title={nombre}
|
{/* Móvil: barra horizontal de progreso */}
|
||||||
>
|
<div className="lg:hidden">
|
||||||
<span className="mr-2">{String(i + 1).padStart(2, '0')}</span>
|
<div className="flex items-baseline justify-between gap-4">
|
||||||
<span className="hidden md:inline">{nombre}</span>
|
<p className="text-sm font-medium text-[#111827]">{pasos[pasoActual]}</p>
|
||||||
</button>
|
<p className="text-xs text-[#64748B]">
|
||||||
</li>
|
Paso {pasoActual + 1} de {pasos.length}
|
||||||
)
|
</p>
|
||||||
})}
|
</div>
|
||||||
</ol>
|
<div className="mt-2 h-1.5 rounded-full bg-[#E5E7EB]">
|
||||||
</nav>
|
<div
|
||||||
|
className="h-full rounded-full bg-accent transition-all duration-200"
|
||||||
|
style={{ width: `${avance}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Escritorio: lista vertical con el estado de cada paso */}
|
||||||
|
<nav aria-label="Progreso" className="hidden lg:block">
|
||||||
|
<ol className="space-y-1">
|
||||||
|
{pasos.map((nombre, i) => {
|
||||||
|
const activo = i === pasoActual
|
||||||
|
const completado = i < pasoActual
|
||||||
|
return (
|
||||||
|
<li key={nombre}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => completado && onIrAPaso(i)}
|
||||||
|
disabled={!completado}
|
||||||
|
aria-current={activo ? 'step' : undefined}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors duration-150 ${
|
||||||
|
activo
|
||||||
|
? 'bg-accent/5 font-semibold text-accent'
|
||||||
|
: completado
|
||||||
|
? 'text-[#111827] hover:bg-[#F1F5F9]'
|
||||||
|
: 'text-[#94A3B8]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium ${
|
||||||
|
activo
|
||||||
|
? 'bg-accent text-white'
|
||||||
|
: completado
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'border border-[#D1D5DB] bg-white text-[#94A3B8]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{completado ? <IconoCheck /> : i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{nombre}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function CoacreditadoForm({ valores, onAnterior, onSiguiente }: P
|
|||||||
return (
|
return (
|
||||||
<FormProvider {...metodos}>
|
<FormProvider {...metodos}>
|
||||||
<form onSubmit={metodos.handleSubmit(onSiguiente)} noValidate>
|
<form onSubmit={metodos.handleSubmit(onSiguiente)} noValidate>
|
||||||
<label htmlFor="tieneCoacreditado" className="flex items-center gap-3 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
<label htmlFor="tieneCoacreditado" className="flex items-center gap-3 px-6 py-5">
|
||||||
<input
|
<input
|
||||||
id="tieneCoacreditado"
|
id="tieneCoacreditado"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -31,7 +31,7 @@ export default function CoacreditadoForm({ valores, onAnterior, onSiguiente }: P
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
{tieneCoacreditado && (
|
{tieneCoacreditado && (
|
||||||
<div className="mt-16 space-y-16">
|
<div className="border-t border-[#E5E7EB]">
|
||||||
<DatosGeneralesCampos prefix="generales." compacto />
|
<DatosGeneralesCampos prefix="generales." compacto />
|
||||||
<EmpleoCampos prefix="empleo." />
|
<EmpleoCampos prefix="empleo." />
|
||||||
<EconomicosCampos prefix="economicos." />
|
<EconomicosCampos prefix="economicos." />
|
||||||
|
|||||||
@@ -31,9 +31,7 @@ export default function DocumentosForm({
|
|||||||
const ine = documentos.find((d) => d.slot === 'INE')
|
const ine = documentos.find((d) => d.slot === 'INE')
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-8">
|
<ChecklistExpediente documentos={documentos} firmaDataUrl={firmaDataUrl} />
|
||||||
<ChecklistExpediente documentos={documentos} firmaDataUrl={firmaDataUrl} />
|
|
||||||
</div>
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
titulo="Documentos del expediente"
|
titulo="Documentos del expediente"
|
||||||
descripcion="Los archivos se conservan en memoria y se enviarán al backend junto con el alta."
|
descripcion="Los archivos se conservan en memoria y se enviarán al backend junto con el alta."
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function FirmasForm({ alta, onCambiarFirma, onAnterior }: Props)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-16">
|
<div>
|
||||||
<SectionCard titulo="Resumen del alta">
|
<SectionCard titulo="Resumen del alta">
|
||||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 text-sm sm:col-span-2 lg:col-span-3 lg:grid-cols-3">
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 text-sm sm:col-span-2 lg:col-span-3 lg:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -126,7 +126,7 @@ export default function FirmasForm({ alta, onCambiarFirma, onAnterior }: Props)
|
|||||||
</label>
|
</label>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{error && <p className="text-xs text-red-700">{error}</p>}
|
{error && <p className="px-6 py-2 text-xs text-red-700">{error}</p>}
|
||||||
|
|
||||||
<SectionCard titulo="Generar documentos PDF">
|
<SectionCard titulo="Generar documentos PDF">
|
||||||
{avisoExpediente && (
|
{avisoExpediente && (
|
||||||
@@ -158,7 +158,7 @@ export default function FirmasForm({ alta, onCambiarFirma, onAnterior }: Props)
|
|||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<div>
|
<div className="sticky bottom-0 flex items-center rounded-b-xl border-t border-[#E5E7EB] bg-white px-6 py-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onAnterior}
|
onClick={onAnterior}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface Props {
|
|||||||
/** Botones de navegación del wizard */
|
/** Botones de navegación del wizard */
|
||||||
export default function Navegacion({ onAnterior, onSiguiente, textoSiguiente = 'Siguiente' }: Props) {
|
export default function Navegacion({ onAnterior, onSiguiente, textoSiguiente = 'Siguiente' }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-16 flex items-center justify-between">
|
<div className="sticky bottom-0 flex items-center justify-between gap-4 rounded-b-xl border-t border-[#E5E7EB] bg-white px-6 py-4">
|
||||||
{onAnterior ? (
|
{onAnterior ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: Pa
|
|||||||
))}
|
))}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
{errorRaiz && typeof errorRaiz.message === 'string' && (
|
{errorRaiz && typeof errorRaiz.message === 'string' && (
|
||||||
<p className="mt-2 text-xs font-semibold text-red-600">{errorRaiz.message}</p>
|
<p className="mt-2 px-6 text-xs font-semibold text-red-600">{errorRaiz.message}</p>
|
||||||
)}
|
)}
|
||||||
<Navegacion onAnterior={onAnterior} />
|
<Navegacion onAnterior={onAnterior} />
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function ViviendaCreditoForm({ valores, onAnterior, onSiguiente }
|
|||||||
return (
|
return (
|
||||||
<FormProvider {...metodos}>
|
<FormProvider {...metodos}>
|
||||||
<form onSubmit={metodos.handleSubmit(onSiguiente)} noValidate>
|
<form onSubmit={metodos.handleSubmit(onSiguiente)} noValidate>
|
||||||
<div className="space-y-16">
|
<div>
|
||||||
<SectionCard titulo="Datos vivienda">
|
<SectionCard titulo="Datos vivienda">
|
||||||
<CampoSelect
|
<CampoSelect
|
||||||
nombre="proyecto"
|
nombre="proyecto"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
|
import AppHeader from '../components/AppHeader'
|
||||||
import Stepper from '../components/Stepper'
|
import Stepper from '../components/Stepper'
|
||||||
import DatosGeneralesForm from '../forms/DatosGeneralesForm'
|
import DatosGeneralesForm from '../forms/DatosGeneralesForm'
|
||||||
import EmpleoForm from '../forms/EmpleoForm'
|
import EmpleoForm from '../forms/EmpleoForm'
|
||||||
@@ -40,6 +41,36 @@ function paramCotizacion(): string | null {
|
|||||||
const formatoFechaHora = (iso: string): string =>
|
const formatoFechaHora = (iso: string): string =>
|
||||||
new Date(iso).toLocaleString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
new Date(iso).toLocaleString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
|
|
||||||
|
/** Tarjeta del paso actual del wizard (el stepper queda fuera, a la izquierda en lg) */
|
||||||
|
function TarjetaPaso({ children }: { children: ReactNode }) {
|
||||||
|
return <div className="rounded-xl border border-[#E5E7EB] bg-white shadow-sm">{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cuadro con las iniciales de la opción (icono de la tarjeta de entrada) */
|
||||||
|
function IconoIniciales({ iniciales }: { iniciales: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex h-11 w-11 items-center justify-center rounded-lg bg-accent/10 text-sm font-semibold text-accent"
|
||||||
|
>
|
||||||
|
{iniciales}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flecha del CTA de las tarjetas de entrada */
|
||||||
|
function IconoFlecha() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-4 w-4">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function AltaClientePage() {
|
export default function AltaClientePage() {
|
||||||
const {
|
const {
|
||||||
paso,
|
paso,
|
||||||
@@ -239,7 +270,7 @@ export default function AltaClientePage() {
|
|||||||
if (pantalla === 'cargando') {
|
if (pantalla === 'cargando') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8FAFC]">
|
<div className="min-h-screen bg-[#F8FAFC]">
|
||||||
<main className="mx-auto max-w-6xl px-4 py-16">
|
<main className="mx-auto max-w-[1280px] px-4 py-16 sm:px-6">
|
||||||
<p className="text-sm text-[#64748B]">Cargando…</p>
|
<p className="text-sm text-[#64748B]">Cargando…</p>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,34 +280,44 @@ export default function AltaClientePage() {
|
|||||||
if (pantalla === 'entrada') {
|
if (pantalla === 'entrada') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8FAFC]">
|
<div className="min-h-screen bg-[#F8FAFC]">
|
||||||
<header className="border-b border-[#E5E7EB] bg-white px-4 py-6">
|
<AppHeader titulo="Alta de Clientes" />
|
||||||
<div className="mx-auto flex max-w-6xl items-baseline gap-3">
|
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 sm:py-12">
|
||||||
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span>
|
<div className="mx-auto max-w-4xl">
|
||||||
<h1 className="text-2xl font-semibold text-[#111827]">Alta de Clientes</h1>
|
<p className="text-sm text-[#64748B]">
|
||||||
</div>
|
Elige cómo registrar al interesado: captura ligera de afluencia o formato completo de alta.
|
||||||
</header>
|
</p>
|
||||||
<main className="mx-auto max-w-6xl px-4 py-16">
|
<div className="mt-6 grid gap-6 sm:grid-cols-2">
|
||||||
<div className="grid gap-6 sm:grid-cols-2">
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => setPantalla('afluencia')}
|
||||||
onClick={() => setPantalla('afluencia')}
|
className="rounded-xl border border-[#E5E7EB] bg-white p-6 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
|
||||||
className="rounded-xl border border-[#E5E7EB] bg-white p-8 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
|
>
|
||||||
>
|
<IconoIniciales iniciales="AA" />
|
||||||
<h2 className="text-lg font-semibold text-[#111827]">Alta Afluencia</h2>
|
<span className="mt-4 block text-lg font-semibold text-[#111827]">Alta Afluencia</span>
|
||||||
<p className="mt-2 text-sm text-[#64748B]">
|
<span className="mt-2 block text-sm text-[#64748B]">
|
||||||
Captura rápida del interesado; entra al embudo de ventas.
|
Captura rápida del interesado; entra al embudo de ventas.
|
||||||
</p>
|
</span>
|
||||||
</button>
|
<span className="mt-5 inline-flex items-center gap-1.5 text-sm font-medium text-accent">
|
||||||
<button
|
Comenzar
|
||||||
type="button"
|
<IconoFlecha />
|
||||||
onClick={() => setPantalla('picker')}
|
</span>
|
||||||
className="rounded-xl border border-[#E5E7EB] bg-white p-8 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
|
</button>
|
||||||
>
|
<button
|
||||||
<h2 className="text-lg font-semibold text-[#111827]">Alta de Cliente</h2>
|
type="button"
|
||||||
<p className="mt-2 text-sm text-[#64748B]">
|
onClick={() => setPantalla('picker')}
|
||||||
Formato completo para quien aparta.
|
className="rounded-xl border border-[#E5E7EB] bg-white p-6 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
|
||||||
</p>
|
>
|
||||||
</button>
|
<IconoIniciales iniciales="AC" />
|
||||||
|
<span className="mt-4 block text-lg font-semibold text-[#111827]">Alta de Cliente</span>
|
||||||
|
<span className="mt-2 block text-sm text-[#64748B]">
|
||||||
|
Formato completo para quien aparta.
|
||||||
|
</span>
|
||||||
|
<span className="mt-5 inline-flex items-center gap-1.5 text-sm font-medium text-accent">
|
||||||
|
Comenzar
|
||||||
|
<IconoFlecha />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,17 +327,19 @@ export default function AltaClientePage() {
|
|||||||
if (pantalla === 'afluencia') {
|
if (pantalla === 'afluencia') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8FAFC]">
|
<div className="min-h-screen bg-[#F8FAFC]">
|
||||||
<header className="border-b border-[#E5E7EB] bg-white px-4 py-6">
|
<AppHeader titulo="Alta Afluencia" />
|
||||||
<div className="mx-auto flex max-w-6xl items-baseline gap-3">
|
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 sm:py-10">
|
||||||
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span>
|
<div className="mx-auto max-w-3xl">
|
||||||
<h1 className="text-2xl font-semibold text-[#111827]">Alta Afluencia</h1>
|
<p className="text-sm text-[#64748B]">
|
||||||
|
Captura rápida del interesado; entra al embudo de ventas.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||||
|
<AltaAfluenciaForm
|
||||||
|
onVolver={() => setPantalla('entrada')}
|
||||||
|
onPasarAAltaCliente={empezarDesdeLead}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
<main className="mx-auto max-w-6xl px-4 py-16">
|
|
||||||
<AltaAfluenciaForm
|
|
||||||
onVolver={() => setPantalla('entrada')}
|
|
||||||
onPasarAAltaCliente={empezarDesdeLead}
|
|
||||||
/>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -305,69 +348,66 @@ export default function AltaClientePage() {
|
|||||||
if (pantalla === 'picker') {
|
if (pantalla === 'picker') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8FAFC]">
|
<div className="min-h-screen bg-[#F8FAFC]">
|
||||||
<header className="border-b border-[#E5E7EB] bg-white px-4 py-6">
|
<AppHeader titulo="Alta de Clientes" />
|
||||||
<div className="mx-auto flex max-w-6xl items-baseline gap-3">
|
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 sm:py-10">
|
||||||
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span>
|
<div className="max-w-3xl">
|
||||||
<h1 className="text-2xl font-semibold text-[#111827]">Alta de Clientes</h1>
|
{borradores.length > 0 && (
|
||||||
</div>
|
<>
|
||||||
</header>
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||||
<main className="mx-auto max-w-6xl px-4 py-16">
|
Tienes {borradores.length} borrador{borradores.length === 1 ? '' : 'es'} guardado
|
||||||
{borradores.length > 0 && (
|
{borradores.length === 1 ? '' : 's'}
|
||||||
<>
|
</h2>
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
<ul className="mt-6 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||||
Tienes {borradores.length} borrador{borradores.length === 1 ? '' : 'es'} guardado
|
{borradores.map((b) => (
|
||||||
{borradores.length === 1 ? '' : 's'}
|
<li key={b.id} className="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
|
||||||
</h2>
|
<div className="min-w-0">
|
||||||
<ul className="mt-6 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
<p className="truncate text-sm text-[#111827]">{b.nombre}</p>
|
||||||
{borradores.map((b) => (
|
<p className="mt-1 text-xs text-[#64748B]">
|
||||||
<li key={b.id} className="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
|
Paso {b.paso + 1} de {PASOS.length} · {formatoFechaHora(b.updatedAt)}
|
||||||
<div className="min-w-0">
|
</p>
|
||||||
<p className="truncate text-sm text-[#111827]">{b.nombre}</p>
|
</div>
|
||||||
<p className="mt-1 text-xs text-[#64748B]">
|
<div className="flex shrink-0 gap-2">
|
||||||
Paso {b.paso + 1} de {PASOS.length} · {formatoFechaHora(b.updatedAt)}
|
<button
|
||||||
</p>
|
type="button"
|
||||||
</div>
|
onClick={() => void continuarBorrador(b.id)}
|
||||||
<div className="flex shrink-0 gap-2">
|
className="rounded-lg border border-accent bg-white px-3 py-1.5 text-xs font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white"
|
||||||
<button
|
>
|
||||||
type="button"
|
Continuar
|
||||||
onClick={() => void continuarBorrador(b.id)}
|
</button>
|
||||||
className="rounded-lg border border-accent bg-white px-3 py-1.5 text-xs font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white"
|
<button
|
||||||
>
|
type="button"
|
||||||
Continuar
|
onClick={() => void borrarBorrador(b.id)}
|
||||||
</button>
|
className="rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 transition-all duration-100 ease-out hover:bg-red-50"
|
||||||
<button
|
>
|
||||||
type="button"
|
Eliminar
|
||||||
onClick={() => void borrarBorrador(b.id)}
|
</button>
|
||||||
className="rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 transition-all duration-100 ease-out hover:bg-red-50"
|
</div>
|
||||||
>
|
</li>
|
||||||
Eliminar
|
))}
|
||||||
</button>
|
</ul>
|
||||||
</div>
|
</>
|
||||||
</li>
|
)}
|
||||||
))}
|
{errorCarga && <p className="mt-4 text-xs text-red-700">{errorCarga}</p>}
|
||||||
</ul>
|
<button
|
||||||
</>
|
type="button"
|
||||||
)}
|
onClick={empezarNuevo}
|
||||||
{errorCarga && <p className="mt-4 text-xs text-red-700">{errorCarga}</p>}
|
className="mt-8 rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
|
||||||
<button
|
>
|
||||||
type="button"
|
Empezar nuevo
|
||||||
onClick={empezarNuevo}
|
</button>
|
||||||
className="mt-8 rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
|
|
||||||
>
|
|
||||||
Empezar nuevo
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<section aria-labelledby="titulo-lead" className="mt-12 border-t border-[#E5E7EB] pt-8">
|
<section aria-labelledby="titulo-lead" className="mt-12 border-t border-[#E5E7EB] pt-8">
|
||||||
<h3 id="titulo-lead" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
<h3 id="titulo-lead" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||||
Desde un lead de Monday
|
Desde un lead de Monday
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 text-sm text-[#64748B]">
|
<p className="mt-2 text-sm text-[#64748B]">
|
||||||
Empieza un alta nueva con el nombre, email y celular del lead ya capturados.
|
Empieza un alta nueva con el nombre, email y celular del lead ya capturados.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<BusquedaLead onSeleccion={empezarDesdeLead} />
|
<BusquedaLead onSeleccion={empezarDesdeLead} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -375,11 +415,10 @@ export default function AltaClientePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8FAFC]">
|
<div className="min-h-screen bg-[#F8FAFC]">
|
||||||
<header className="border-b border-[#E5E7EB] bg-white px-4 py-6">
|
<AppHeader
|
||||||
<div className="mx-auto flex max-w-6xl flex-wrap items-baseline gap-3">
|
titulo="Alta de Clientes"
|
||||||
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span>
|
acciones={
|
||||||
<h1 className="text-2xl font-semibold text-[#111827]">Alta de Clientes</h1>
|
<>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
|
||||||
{avisoBorrador && (
|
{avisoBorrador && (
|
||||||
<span role="status" className="text-xs text-[#64748B]">
|
<span role="status" className="text-xs text-[#64748B]">
|
||||||
{avisoBorrador}
|
{avisoBorrador}
|
||||||
@@ -397,172 +436,192 @@ export default function AltaClientePage() {
|
|||||||
>
|
>
|
||||||
Guardar borrador
|
Guardar borrador
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-[1280px] px-4 py-6 sm:px-6 sm:py-8">
|
||||||
|
<div className="lg:grid lg:grid-cols-[260px_minmax(0,1fr)] lg:gap-8">
|
||||||
|
<div className="mb-6 lg:sticky lg:top-20 lg:mb-0 lg:self-start">
|
||||||
|
<Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPaso} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="mx-auto max-w-6xl px-4 py-16">
|
<div className="w-full max-w-3xl">
|
||||||
<Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPaso} />
|
{errorCarga && (
|
||||||
|
<p role="alert" className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700">
|
||||||
|
{errorCarga}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{errorCarga && (
|
{paso === 0 && (
|
||||||
<p role="alert" className="mt-8 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700">
|
<TarjetaPaso>
|
||||||
{errorCarga}
|
<DatosGeneralesForm
|
||||||
</p>
|
valores={datos.datosGenerales}
|
||||||
)}
|
onSiguiente={(v) => {
|
||||||
|
actualizar('datosGenerales', v)
|
||||||
<div className="mt-16">
|
siguiente()
|
||||||
{paso === 0 && (
|
guardarAvance({ ...datos, datosGenerales: v }, paso + 1)
|
||||||
<DatosGeneralesForm
|
}}
|
||||||
valores={datos.datosGenerales}
|
/>
|
||||||
onSiguiente={(v) => {
|
</TarjetaPaso>
|
||||||
actualizar('datosGenerales', v)
|
)}
|
||||||
siguiente()
|
{paso === 1 && (
|
||||||
guardarAvance({ ...datos, datosGenerales: v }, paso + 1)
|
<TarjetaPaso>
|
||||||
}}
|
<EmpleoForm
|
||||||
/>
|
valores={datos.empleo}
|
||||||
)}
|
onAnterior={anterior}
|
||||||
{paso === 1 && (
|
onSiguiente={(v) => {
|
||||||
<EmpleoForm
|
actualizar('empleo', v)
|
||||||
valores={datos.empleo}
|
siguiente()
|
||||||
onAnterior={anterior}
|
guardarAvance({ ...datos, empleo: v }, paso + 1)
|
||||||
onSiguiente={(v) => {
|
}}
|
||||||
actualizar('empleo', v)
|
/>
|
||||||
siguiente()
|
</TarjetaPaso>
|
||||||
guardarAvance({ ...datos, empleo: v }, paso + 1)
|
)}
|
||||||
}}
|
{paso === 2 && (
|
||||||
/>
|
<TarjetaPaso>
|
||||||
)}
|
<EconomicosForm
|
||||||
{paso === 2 && (
|
valores={datos.economicos}
|
||||||
<EconomicosForm
|
onAnterior={anterior}
|
||||||
valores={datos.economicos}
|
onSiguiente={(v) => {
|
||||||
onAnterior={anterior}
|
actualizar('economicos', v)
|
||||||
onSiguiente={(v) => {
|
siguiente()
|
||||||
actualizar('economicos', v)
|
guardarAvance({ ...datos, economicos: v }, paso + 1)
|
||||||
siguiente()
|
}}
|
||||||
guardarAvance({ ...datos, economicos: v }, paso + 1)
|
/>
|
||||||
}}
|
</TarjetaPaso>
|
||||||
/>
|
)}
|
||||||
)}
|
{paso === 3 && (
|
||||||
{paso === 3 && (
|
<TarjetaPaso>
|
||||||
<CoacreditadoForm
|
<CoacreditadoForm
|
||||||
valores={datos.coacreditado}
|
valores={datos.coacreditado}
|
||||||
onAnterior={anterior}
|
onAnterior={anterior}
|
||||||
onSiguiente={(v) => {
|
onSiguiente={(v) => {
|
||||||
actualizar('coacreditado', v)
|
actualizar('coacreditado', v)
|
||||||
siguiente()
|
siguiente()
|
||||||
guardarAvance({ ...datos, coacreditado: v }, paso + 1)
|
guardarAvance({ ...datos, coacreditado: v }, paso + 1)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
</TarjetaPaso>
|
||||||
{paso === 4 && (
|
)}
|
||||||
<ReferenciasForm
|
{paso === 4 && (
|
||||||
valores={datos.referencias}
|
<TarjetaPaso>
|
||||||
onAnterior={anterior}
|
<ReferenciasForm
|
||||||
onSiguiente={(v) => {
|
valores={datos.referencias}
|
||||||
actualizar('referencias', v)
|
onAnterior={anterior}
|
||||||
siguiente()
|
onSiguiente={(v) => {
|
||||||
guardarAvance({ ...datos, referencias: v }, paso + 1)
|
actualizar('referencias', v)
|
||||||
}}
|
siguiente()
|
||||||
/>
|
guardarAvance({ ...datos, referencias: v }, paso + 1)
|
||||||
)}
|
}}
|
||||||
{paso === 5 && (
|
/>
|
||||||
<ViviendaCreditoForm
|
</TarjetaPaso>
|
||||||
valores={datos.viviendaCredito}
|
)}
|
||||||
onAnterior={anterior}
|
{paso === 5 && (
|
||||||
onSiguiente={(v) => {
|
<TarjetaPaso>
|
||||||
actualizar('viviendaCredito', v)
|
<ViviendaCreditoForm
|
||||||
siguiente()
|
valores={datos.viviendaCredito}
|
||||||
guardarAvance({ ...datos, viviendaCredito: v }, paso + 1)
|
onAnterior={anterior}
|
||||||
}}
|
onSiguiente={(v) => {
|
||||||
/>
|
actualizar('viviendaCredito', v)
|
||||||
)}
|
siguiente()
|
||||||
{paso === 6 && (
|
guardarAvance({ ...datos, viviendaCredito: v }, paso + 1)
|
||||||
<>
|
}}
|
||||||
{cargoDeBorrador && (
|
/>
|
||||||
<p
|
</TarjetaPaso>
|
||||||
role="note"
|
)}
|
||||||
className="mb-8 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"
|
{paso === 6 && (
|
||||||
>
|
<>
|
||||||
Retomaste un borrador: los archivos de los documentos no se guardan en el borrador,
|
{cargoDeBorrador && (
|
||||||
vuelve a adjuntarlos.
|
<p
|
||||||
</p>
|
role="note"
|
||||||
)}
|
className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"
|
||||||
<DocumentosForm
|
|
||||||
documentos={alta.documentos}
|
|
||||||
firmaDataUrl={alta.firma.firmaDataUrl}
|
|
||||||
onAgregar={agregarDocumento}
|
|
||||||
onQuitar={quitarDocumento}
|
|
||||||
onAplicarDatosGenerales={(campos) =>
|
|
||||||
actualizar('datosGenerales', { ...datos.datosGenerales, ...campos })
|
|
||||||
}
|
|
||||||
onAnterior={anterior}
|
|
||||||
onSiguiente={() => {
|
|
||||||
void registrarAlta()
|
|
||||||
siguiente()
|
|
||||||
guardarAvance(datos, paso + 1)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{paso === 7 && (
|
|
||||||
<>
|
|
||||||
{registro && (
|
|
||||||
<p className="mb-8 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700">
|
|
||||||
Alta registrada en el servidor: {registro}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{sinConexion && (
|
|
||||||
<p
|
|
||||||
role="alert"
|
|
||||||
className="mb-8 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"
|
|
||||||
>
|
|
||||||
Sin conexión al servidor — el expediente se generó solo localmente
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{errorValidacion && (
|
|
||||||
<p
|
|
||||||
role="alert"
|
|
||||||
className="mb-8 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700"
|
|
||||||
>
|
|
||||||
El servidor rechazó el alta: {errorValidacion}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{registro && (
|
|
||||||
<div className="mb-8 rounded-xl border border-[#E5E7EB] bg-white px-4 py-4 shadow-sm">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
|
||||||
Firma en remoto
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm text-[#64748B]">
|
|
||||||
Genera una liga pública (vigencia 72 h) para que el cliente firme y suba sus
|
|
||||||
documentos desde su celular, sin usuario ni contraseña.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void generarLiga()}
|
|
||||||
className="mt-4 rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
|
||||||
>
|
>
|
||||||
Generar liga para firmar en remoto
|
Retomaste un borrador: los archivos de los documentos no se guardan en el borrador,
|
||||||
</button>
|
vuelve a adjuntarlos.
|
||||||
{ligaUrl && (
|
</p>
|
||||||
<p className="mt-4 break-all text-xs text-[#111827]">
|
)}
|
||||||
{ligaUrl}
|
<TarjetaPaso>
|
||||||
{ligaCopiada && (
|
<DocumentosForm
|
||||||
<span role="status" className="ml-2 font-medium text-accent">
|
documentos={alta.documentos}
|
||||||
Liga y mensaje de WhatsApp copiados
|
firmaDataUrl={alta.firma.firmaDataUrl}
|
||||||
</span>
|
onAgregar={agregarDocumento}
|
||||||
)}
|
onQuitar={quitarDocumento}
|
||||||
|
onAplicarDatosGenerales={(campos) =>
|
||||||
|
actualizar('datosGenerales', { ...datos.datosGenerales, ...campos })
|
||||||
|
}
|
||||||
|
onAnterior={anterior}
|
||||||
|
onSiguiente={() => {
|
||||||
|
void registrarAlta()
|
||||||
|
siguiente()
|
||||||
|
guardarAvance(datos, paso + 1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TarjetaPaso>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{paso === 7 && (
|
||||||
|
<>
|
||||||
|
{registro && (
|
||||||
|
<p className="mb-6 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700">
|
||||||
|
Alta registrada en el servidor: {registro}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{sinConexion && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"
|
||||||
|
>
|
||||||
|
Sin conexión al servidor — el expediente se generó solo localmente
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{errorValidacion && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700"
|
||||||
|
>
|
||||||
|
El servidor rechazó el alta: {errorValidacion}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{registro && (
|
||||||
|
<div className="mb-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||||
|
Firma en remoto
|
||||||
</p>
|
</p>
|
||||||
)}
|
<p className="mt-2 text-sm text-[#64748B]">
|
||||||
{errorLiga && <p className="mt-2 text-xs text-red-700">{errorLiga}</p>}
|
Genera una liga pública (vigencia 72 h) para que el cliente firme y suba sus
|
||||||
</div>
|
documentos desde su celular, sin usuario ni contraseña.
|
||||||
)}
|
</p>
|
||||||
<FirmasForm
|
<button
|
||||||
alta={alta}
|
type="button"
|
||||||
onCambiarFirma={(f) => actualizar('firma', f)}
|
onClick={() => void generarLiga()}
|
||||||
onAnterior={anterior}
|
className="mt-4 rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||||
/>
|
>
|
||||||
</>
|
Generar liga para firmar en remoto
|
||||||
)}
|
</button>
|
||||||
|
{ligaUrl && (
|
||||||
|
<p className="mt-4 break-all text-xs text-[#111827]">
|
||||||
|
{ligaUrl}
|
||||||
|
{ligaCopiada && (
|
||||||
|
<span role="status" className="ml-2 font-medium text-accent">
|
||||||
|
Liga y mensaje de WhatsApp copiados
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{errorLiga && <p className="mt-2 text-xs text-red-700">{errorLiga}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<TarjetaPaso>
|
||||||
|
<FirmasForm
|
||||||
|
alta={alta}
|
||||||
|
onCambiarFirma={(f) => actualizar('firma', f)}
|
||||||
|
onAnterior={anterior}
|
||||||
|
/>
|
||||||
|
</TarjetaPaso>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
export default function KpiCard({ titulo, valor, detalle }: { titulo: string; valor: ReactNode; detalle?: string }) {
|
export default function KpiCard({
|
||||||
|
titulo,
|
||||||
|
valor,
|
||||||
|
detalle,
|
||||||
|
plano = false,
|
||||||
|
}: {
|
||||||
|
titulo: string
|
||||||
|
valor: ReactNode
|
||||||
|
detalle?: string
|
||||||
|
/** `plano` quita sombra/hover para anidar la tarjeta dentro de otra tarjeta */
|
||||||
|
plano?: boolean
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-md">
|
<div
|
||||||
|
className={`rounded-xl border border-[#E5E7EB] bg-white p-6 ${
|
||||||
|
plano ? '' : 'shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-md'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</p>
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</p>
|
||||||
<p className="mt-2 text-3xl font-light text-[#111827]">{valor}</p>
|
<p className="mt-2 text-3xl font-light text-[#111827]">{valor}</p>
|
||||||
{detalle && <p className="mt-2 text-xs text-[#64748B]">{detalle}</p>}
|
{detalle && <p className="mt-2 text-xs text-[#64748B]">{detalle}</p>}
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { Link } from 'react-router-dom'
|
|||||||
export default function Layout({ children }: { children: ReactNode }) {
|
export default function Layout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||||
<header className="border-b border-[#E5E7EB] bg-white">
|
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
|
<div className="mx-auto flex h-16 max-w-[1280px] items-center justify-between px-4 sm:px-6">
|
||||||
<Link to="/" className="text-sm tracking-widest">
|
<Link to="/" className="text-sm tracking-widest">
|
||||||
<span className="font-bold">FRASA</span>
|
<span className="font-bold">FRASA</span>
|
||||||
<span className="text-[#F97316]">.</span>{' '}
|
<span className="text-[#F97316]">.</span>{' '}
|
||||||
<span className="font-light text-[#64748B]">COBRANZA</span>
|
<span className="font-light text-[#64748B]">COBRANZA</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-6 text-sm">
|
<nav className="flex items-center gap-3 text-sm sm:gap-6">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="text-[#64748B] transition-colors duration-150 hover:text-[#111827]"
|
className="text-[#64748B] transition-colors duration-150 hover:text-[#111827]"
|
||||||
@@ -20,20 +20,20 @@ export default function Layout({ children }: { children: ReactNode }) {
|
|||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/hoy"
|
to="/hoy"
|
||||||
className="rounded-lg border border-accent px-5 py-2.5 text-sm font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white"
|
className="rounded-lg border border-accent px-3 py-2 text-sm font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white sm:px-5 sm:py-2.5"
|
||||||
>
|
>
|
||||||
Cobranza del día
|
Cobranza del día
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/nuevo"
|
to="/nuevo"
|
||||||
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
|
className="rounded-lg bg-accent px-3 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover sm:px-5 sm:py-2.5"
|
||||||
>
|
>
|
||||||
Nuevo plan
|
Nuevo plan
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto max-w-6xl px-4 py-10">{children}</main>
|
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,9 +214,9 @@ export default function DashboardPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<h1 className="text-3xl font-light text-[#111827]">Módulo Cobranza — Planes de enganche</h1>
|
<h1 className="text-xl font-semibold text-[#111827]">Módulo Cobranza — Planes de enganche</h1>
|
||||||
<Link
|
<Link
|
||||||
to="/nuevo"
|
to="/nuevo"
|
||||||
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
|
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
|
||||||
@@ -292,8 +292,10 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filtros */}
|
{/* Tarjeta unificada: toolbar de filtros/búsqueda + tabla de planes */}
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||||
|
{/* Filtros */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 border-b border-[#E5E7EB] p-4 sm:gap-4 sm:px-5">
|
||||||
{/* Toggle Mis planes / Todos (?mios=1 filtra en el API por el vendedor del token) */}
|
{/* Toggle Mis planes / Todos (?mios=1 filtra en el API por el vendedor del token) */}
|
||||||
<div className="flex rounded-lg border border-[#D1D5DB] bg-white" role="group" aria-label="Alcance de planes">
|
<div className="flex rounded-lg border border-[#D1D5DB] bg-white" role="group" aria-label="Alcance de planes">
|
||||||
<button
|
<button
|
||||||
@@ -358,10 +360,10 @@ export default function DashboardPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabla de planes */}
|
{/* Tabla de planes */}
|
||||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||||
@@ -417,6 +419,7 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ export default function HoyPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-light text-[#111827]">Cobranza del día</h1>
|
<h1 className="text-xl font-semibold text-[#111827]">Cobranza del día</h1>
|
||||||
<p className="mt-1 text-sm text-[#64748B]">
|
<p className="mt-1 text-sm text-[#64748B]">
|
||||||
Pagos vencidos y vencimientos de los próximos 7 días, en todos los planes activos.
|
Pagos vencidos y vencimientos de los próximos 7 días, en todos los planes activos.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ export default function NuevoPlanPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl space-y-8">
|
<div className="mx-auto max-w-3xl space-y-6">
|
||||||
<h1 className="text-3xl font-light text-[#111827]">Nuevo plan de enganche</h1>
|
<h1 className="text-xl font-semibold text-[#111827]">Nuevo plan de enganche</h1>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm"
|
className="rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm"
|
||||||
|
|||||||
@@ -133,40 +133,24 @@ export default function PlanDetallePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const avance = avancePlan(plan)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-6">
|
<div>
|
||||||
<div>
|
<Link to="/" className="text-sm text-accent hover:underline">
|
||||||
<Link to="/" className="text-sm text-accent hover:underline">
|
← Volver al dashboard
|
||||||
← Volver al dashboard
|
</Link>
|
||||||
</Link>
|
<h1 className="mt-2 text-xl font-semibold text-[#111827]">{plan.clienteNombre}</h1>
|
||||||
<h1 className="mt-2 text-3xl font-light text-[#111827]">{plan.clienteNombre}</h1>
|
<p className="mt-1 text-sm text-[#64748B]">
|
||||||
<p className="mt-1 text-sm text-[#64748B]">
|
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
|
||||||
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
|
<span className="font-semibold text-[#111827]">{status}</span>
|
||||||
<span className="font-semibold text-[#111827]">{status}</span>
|
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
|
||||||
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
|
</p>
|
||||||
</p>
|
<p className="mt-1 text-sm text-[#64748B]">
|
||||||
<p className="mt-1 text-sm text-[#64748B]">
|
Tel. {plan.clienteTelefono || '—'}
|
||||||
Tel. {plan.clienteTelefono || '—'}
|
{tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>}
|
||||||
{tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>}
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => descargar(plan)}
|
|
||||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
|
||||||
>
|
|
||||||
Estado de cuenta PDF
|
|
||||||
</button>
|
|
||||||
{status === 'ACTIVO' && saldo > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={cancelar}
|
|
||||||
className="rounded-lg border border-red-600 bg-white px-5 py-2.5 text-sm font-medium text-red-600 transition-all duration-100 ease-out hover:bg-red-600 hover:text-white"
|
|
||||||
>
|
|
||||||
Cancelar plan
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{offline && (
|
{offline && (
|
||||||
@@ -179,21 +163,52 @@ export default function PlanDetallePage() {
|
|||||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* KPIs del plan */}
|
{/* En lg: columna principal (flex-1) + rail derecho sticky de ~340px */}
|
||||||
<div className="grid grid-cols-2 gap-6 lg:grid-cols-5">
|
<div className="space-y-6 lg:flex lg:items-start lg:gap-6 lg:space-y-0">
|
||||||
<KpiCard titulo="Enganche total" valor={formatoMoneda(plan.engancheTotal)} />
|
{/* Columna principal */}
|
||||||
<KpiCard titulo="Pagado" valor={formatoMoneda(pagadoTotal(plan))} />
|
<div className="min-w-0 flex-1 space-y-6">
|
||||||
<KpiCard titulo="Saldo" valor={formatoMoneda(saldo)} />
|
{/* Resumen del plan */}
|
||||||
<KpiCard titulo="% avance" valor={formatoPorcentaje(avancePlan(plan))} />
|
<section
|
||||||
<KpiCard titulo="Mensualidad" valor={formatoMoneda(plan.mensualidad)} detalle={`${plan.numPagos} pagos`} />
|
className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm sm:p-6"
|
||||||
{mora > 0 && (
|
aria-labelledby="titulo-resumen"
|
||||||
<KpiCard
|
>
|
||||||
titulo="Mora acumulada"
|
<h2 id="titulo-resumen" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||||
valor={<span className="text-red-700">{formatoMoneda(mora)}</span>}
|
Resumen del plan
|
||||||
detalle={`Tasa ${tasaMora}% mensual`}
|
</h2>
|
||||||
/>
|
<div className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||||
)}
|
<KpiCard plano titulo="Enganche total" valor={formatoMoneda(plan.engancheTotal)} />
|
||||||
</div>
|
<KpiCard plano titulo="Pagado" valor={formatoMoneda(pagadoTotal(plan))} />
|
||||||
|
<KpiCard plano titulo="Saldo" valor={formatoMoneda(saldo)} />
|
||||||
|
<KpiCard plano titulo="% avance" valor={formatoPorcentaje(avance)} />
|
||||||
|
<KpiCard
|
||||||
|
plano
|
||||||
|
titulo="Mensualidad"
|
||||||
|
valor={formatoMoneda(plan.mensualidad)}
|
||||||
|
detalle={`${plan.numPagos} pagos`}
|
||||||
|
/>
|
||||||
|
{mora > 0 && (
|
||||||
|
<KpiCard
|
||||||
|
plano
|
||||||
|
titulo="Mora acumulada"
|
||||||
|
valor={<span className="text-red-700">{formatoMoneda(mora)}</span>}
|
||||||
|
detalle={`Tasa ${tasaMora}% mensual`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Barra de avance */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="font-medium text-[#64748B]">Avance del plan</span>
|
||||||
|
<span className="font-semibold text-[#111827]">{formatoPorcentaje(avance)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-2 rounded-full bg-[#E5E7EB]">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-accent"
|
||||||
|
style={{ width: `${Math.min(100, avance * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Tabla de pagos */}
|
{/* Tabla de pagos */}
|
||||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||||
@@ -299,10 +314,38 @@ export default function PlanDetallePage() {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bitácora del plan */}
|
{/* Rail derecho: acciones y bitácora */}
|
||||||
<section className="rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm" aria-labelledby="titulo-bitacora">
|
<aside className="space-y-6 lg:sticky lg:top-20 lg:w-[340px] lg:shrink-0">
|
||||||
|
<section
|
||||||
|
className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm"
|
||||||
|
aria-labelledby="titulo-acciones"
|
||||||
|
>
|
||||||
|
<h2 id="titulo-acciones" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||||
|
Acciones
|
||||||
|
</h2>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={() => descargar(plan)}
|
||||||
|
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||||
|
>
|
||||||
|
Estado de cuenta PDF
|
||||||
|
</button>
|
||||||
|
{status === 'ACTIVO' && saldo > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={cancelar}
|
||||||
|
className="w-full rounded-lg border border-red-600 bg-white px-5 py-2.5 text-sm font-medium text-red-600 transition-all duration-100 ease-out hover:bg-red-600 hover:text-white"
|
||||||
|
>
|
||||||
|
Cancelar plan
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Bitácora del plan */}
|
||||||
|
<section className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm" aria-labelledby="titulo-bitacora">
|
||||||
<h2 id="titulo-bitacora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
<h2 id="titulo-bitacora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||||
Bitácora
|
Bitácora
|
||||||
</h2>
|
</h2>
|
||||||
@@ -329,7 +372,7 @@ export default function PlanDetallePage() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<form
|
<form
|
||||||
className="mt-8"
|
className="mt-6"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void guardarNota()
|
void guardarNota()
|
||||||
@@ -360,7 +403,9 @@ export default function PlanDetallePage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
{status === 'CANCELADO' && (
|
{status === 'CANCELADO' && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import LoginScreen from './LoginScreen'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la
|
* Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la
|
||||||
* app con una barra discreta "nombre · rol", "Contraseña" (modal de cambio)
|
* app con un header blanco sticky (marca a la izquierda; "nombre · rol",
|
||||||
* y botón "Salir".
|
* "Contraseña" (modal de cambio) y botón "Salir" a la derecha).
|
||||||
*/
|
*/
|
||||||
export default function AuthGate({ children }: { children: ReactNode }) {
|
export default function AuthGate({ children }: { children: ReactNode }) {
|
||||||
const [sesion, setSesion] = useState<Sesion | null>(() => getSesion())
|
const [sesion, setSesion] = useState<Sesion | null>(() => getSesion())
|
||||||
@@ -18,16 +18,22 @@ export default function AuthGate({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||||
<div className="border-b border-[#E5E7EB] bg-white">
|
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
|
<div className="mx-auto flex h-16 max-w-[1280px] items-center justify-between gap-4 px-4 sm:px-6">
|
||||||
<span className="text-xs font-medium text-[#64748B]">
|
<p className="text-sm text-[#111827]">
|
||||||
{sesion.usuario.nombre} · {sesion.usuario.rol}
|
<span className="font-bold">
|
||||||
</span>
|
FRASA DESARROLLOS<span className="text-[#F97316]">.</span>
|
||||||
<div className="flex items-center gap-4">
|
</span>{' '}
|
||||||
|
<span className="hidden font-normal text-[#64748B] sm:inline">— Cotizador</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3 sm:gap-4">
|
||||||
|
<span className="hidden text-xs font-medium text-[#64748B] md:inline">
|
||||||
|
{sesion.usuario.nombre} · {sesion.usuario.rol}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setModalPassword(true)}
|
onClick={() => setModalPassword(true)}
|
||||||
className="text-xs font-medium text-[#64748B] transition-colors duration-100 hover:text-[#111827]"
|
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||||
>
|
>
|
||||||
Contraseña
|
Contraseña
|
||||||
</button>
|
</button>
|
||||||
@@ -43,7 +49,7 @@ export default function AuthGate({ children }: { children: ReactNode }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
{children}
|
{children}
|
||||||
{modalPassword && <CambiarPasswordModal onCerrar={() => setModalPassword(false)} />}
|
{modalPassword && <CambiarPasswordModal onCerrar={() => setModalPassword(false)} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulo
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mt-16" aria-labelledby="comparador-titulo">
|
<section aria-labelledby="comparador-titulo">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<h2
|
<h2
|
||||||
id="comparador-titulo"
|
id="comparador-titulo"
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mt-16" aria-labelledby="comparador-unidades-titulo">
|
<section aria-labelledby="comparador-unidades-titulo">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<h2
|
<h2
|
||||||
id="comparador-unidades-titulo"
|
id="comparador-unidades-titulo"
|
||||||
|
|||||||
@@ -255,278 +255,276 @@ export default function CotizadorPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8FAFC]">
|
<div className="min-h-screen bg-[#F8FAFC]">
|
||||||
<header className="border-b border-[#E5E7EB] bg-white">
|
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 lg:py-10">
|
||||||
<div className="mx-auto flex max-w-7xl flex-wrap items-baseline justify-between gap-2 px-4 py-6">
|
{/* Encabezado de página */}
|
||||||
<h1 className="text-lg text-[#111827]">
|
<div className="mb-8">
|
||||||
<span className="font-bold">FRASA DESARROLLOS<span className="text-[#F97316]">.</span></span>{' '}
|
<h1 className="text-xl font-semibold text-[#111827]">Cotizador</h1>
|
||||||
<span className="font-normal text-[#64748B]">— Cotizador</span>
|
<p className="mt-1 text-sm text-[#64748B] capitalize">{formatoFechaLarga(new Date())}</p>
|
||||||
</h1>
|
<p className="text-sm text-[#64748B]">
|
||||||
<div className="text-right">
|
Vigencia: hasta el {formatoFechaLarga(calcularVigencia())}
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] capitalize">
|
</p>
|
||||||
{formatoFechaLarga(new Date())}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-[#64748B]">
|
|
||||||
Vigencia: hasta el {formatoFechaLarga(calcularVigencia())}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="mx-auto grid max-w-7xl gap-x-8 gap-y-16 px-4 py-16 lg:grid-cols-[minmax(320px,380px)_1fr]">
|
<div className="grid items-start gap-6 lg:grid-cols-[380px_minmax(0,1fr)] lg:gap-8">
|
||||||
{/* Panel de configuración */}
|
{/* Panel de configuración */}
|
||||||
<aside className="space-y-16">
|
<aside className="space-y-6 lg:sticky lg:top-24">
|
||||||
<section className="space-y-8 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
<section className="space-y-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||||
{error && <p className="text-xs text-red-700">{error}</p>}
|
{error && <p className="text-xs text-red-700">{error}</p>}
|
||||||
{usandoSeed && !cargando && (
|
{usandoSeed && !cargando && (
|
||||||
<p className="text-xs text-amber-700">Datos de demostración (sin conexión al servidor)</p>
|
<p className="text-xs text-amber-700">Datos de demostración (sin conexión al servidor)</p>
|
||||||
)}
|
)}
|
||||||
{cargando ? (
|
{cargando ? (
|
||||||
<p className="text-sm text-[#64748B]">Cargando unidades…</p>
|
<p className="text-sm text-[#64748B]">Cargando unidades…</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<span className={etiquetaCampo}>Desarrollo</span>
|
<span className={etiquetaCampo}>Desarrollo</span>
|
||||||
<div className="flex flex-wrap overflow-hidden rounded-lg border border-[#D1D5DB]" role="group">
|
<div className="flex flex-wrap overflow-hidden rounded-lg border border-[#D1D5DB]" role="group">
|
||||||
{LISTA_DESARROLLOS.map((id) => {
|
{LISTA_DESARROLLOS.map((id) => {
|
||||||
const activo = id === desarrollo
|
const activo = id === desarrollo
|
||||||
const total = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === id).length
|
const total = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === id).length
|
||||||
const disponibles = unidades.filter(
|
const disponibles = unidades.filter(
|
||||||
(u) => (u.desarrollo ?? 'DIAGONAL') === id && u.status === 'DISPONIBLE',
|
(u) => (u.desarrollo ?? 'DIAGONAL') === id && u.status === 'DISPONIBLE',
|
||||||
).length
|
).length
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={activo}
|
aria-pressed={activo}
|
||||||
onClick={() => setDesarrollo(id)}
|
onClick={() => setDesarrollo(id)}
|
||||||
className={`px-4 py-2.5 text-xs font-medium transition-colors duration-100 ${
|
className={`px-4 py-2.5 text-xs font-medium transition-colors duration-100 ${
|
||||||
activo
|
activo
|
||||||
? 'bg-accent text-white'
|
? 'bg-accent text-white'
|
||||||
: 'bg-white text-[#4B5563] hover:bg-[#F9FAFB]'
|
: 'bg-white text-[#4B5563] hover:bg-[#F9FAFB]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{obtenerDesarrollo(id).nombre}
|
{obtenerDesarrollo(id).nombre}
|
||||||
<span className={`ml-1 font-normal ${activo ? 'text-blue-100' : 'text-[#64748B]'}`}>
|
<span className={`ml-1 font-normal ${activo ? 'text-blue-100' : 'text-[#64748B]'}`}>
|
||||||
{disponibles}/{total}
|
{disponibles}/{total}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-[#64748B]">
|
||||||
|
{unidadesDesarrollo.length} unidades · {disponiblesDesarrollo} disponibles
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-[#64748B]">
|
<UnitSelector
|
||||||
{unidadesDesarrollo.length} unidades · {disponiblesDesarrollo} disponibles
|
unidades={unidadesDesarrollo}
|
||||||
|
valor={unidad?.codigo ?? ''}
|
||||||
|
onChange={(codigo) =>
|
||||||
|
setUnidad(unidadesDesarrollo.find((u) => u.codigo === codigo) ?? null)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!params.precioUnico && (
|
||||||
|
<div>
|
||||||
|
<span className={etiquetaCampo}>Cocina y closet</span>
|
||||||
|
<div className="inline-flex overflow-hidden rounded-lg border border-[#D1D5DB]" role="group">
|
||||||
|
{(['SI', 'NO'] as const).map((opcion) => {
|
||||||
|
const activo = (opcion === 'SI') === acabados
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opcion}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={activo}
|
||||||
|
onClick={() => setAcabados(opcion === 'SI')}
|
||||||
|
className={`px-5 py-2.5 text-xs font-medium transition-colors duration-100 ${
|
||||||
|
activo
|
||||||
|
? 'bg-accent text-white'
|
||||||
|
: 'bg-white text-[#4B5563] hover:bg-[#F9FAFB]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opcion}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="apartado" className={etiquetaCampo}>
|
||||||
|
Monto de apartado (MXN)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="apartado"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1000}
|
||||||
|
value={apartado}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value)
|
||||||
|
setApartado(Number.isFinite(v) && v >= 0 ? v : 0)
|
||||||
|
}}
|
||||||
|
className={errorApartado ? campoInputError : campoInput}
|
||||||
|
/>
|
||||||
|
{errorApartado ? (
|
||||||
|
<p className="mt-1 text-xs text-red-700">{errorApartado}</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-xs text-[#64748B]">{formatoMoneda(apartado)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="nombre" className={etiquetaCampo}>
|
||||||
|
Nombre del cliente *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="nombre"
|
||||||
|
type="text"
|
||||||
|
className={errors.nombre ? campoInputError : campoInput}
|
||||||
|
{...register('nombre')}
|
||||||
|
/>
|
||||||
|
{errors.nombre && <p className="mt-1 text-xs text-red-700">{errors.nombre.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className={etiquetaCampo}>
|
||||||
|
Correo electrónico (opcional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
className={errors.email ? campoInputError : campoInput}
|
||||||
|
{...register('email')}
|
||||||
|
/>
|
||||||
|
{errors.email && <p className="mt-1 text-xs text-red-700">{errors.email.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="telefono" className={etiquetaCampo}>
|
||||||
|
Teléfono (opcional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="telefono"
|
||||||
|
type="tel"
|
||||||
|
className={errors.telefono ? campoInputError : campoInput}
|
||||||
|
{...register('telefono')}
|
||||||
|
/>
|
||||||
|
{errors.telefono && (
|
||||||
|
<p className="mt-1 text-xs text-red-700">{errors.telefono.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{unidad && (
|
||||||
|
<div className="border-t border-[#E5E7EB] pt-6">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||||
|
Precio de lista
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-3xl font-semibold text-[#111827]">
|
||||||
|
{formatoMoneda(precioLista)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<UnitSelector
|
)}
|
||||||
unidades={unidadesDesarrollo}
|
|
||||||
valor={unidad?.codigo ?? ''}
|
{unidad && (
|
||||||
onChange={(codigo) =>
|
<button
|
||||||
setUnidad(unidadesDesarrollo.find((u) => u.codigo === codigo) ?? null)
|
type="button"
|
||||||
}
|
onClick={handleNueva}
|
||||||
|
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||||
|
>
|
||||||
|
Nueva cotización
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{unidad && <UnitInfoCard unidad={unidad} numMeses={numMeses} />}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Panel de propuestas */}
|
||||||
|
<section className="min-w-0">
|
||||||
|
{!unidad ? (
|
||||||
|
<div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#D1D5DB] bg-white">
|
||||||
|
<p className="text-sm text-[#64748B]">
|
||||||
|
Selecciona una unidad para ver las propuestas comerciales.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{propuestas.map((p, i) => (
|
||||||
|
<ProposalCard
|
||||||
|
key={p.tipo}
|
||||||
|
numero={i + 1}
|
||||||
|
titulo={p.titulo}
|
||||||
|
propuesta={p}
|
||||||
|
onDescuentoChange={(v) => setDescuento(params.propuestas[i].claveDescuento, v)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ComparadorPropuestas
|
||||||
|
precioLista={precioLista}
|
||||||
|
apartado={apartadoEfectivo}
|
||||||
|
propuestas={propuestas}
|
||||||
|
titulos={propuestas.map((p) => p.titulo)}
|
||||||
/>
|
/>
|
||||||
</>
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm">
|
||||||
)}
|
<div className="flex flex-wrap items-center justify-end gap-4">
|
||||||
|
<div>
|
||||||
{!params.precioUnico && (
|
<label htmlFor="propuesta-elegida" className={etiquetaCampo}>
|
||||||
<div>
|
Propuesta elegida
|
||||||
<span className={etiquetaCampo}>Cocina y closet</span>
|
</label>
|
||||||
<div className="inline-flex overflow-hidden rounded-lg border border-[#D1D5DB]" role="group">
|
<select
|
||||||
{(['SI', 'NO'] as const).map((opcion) => {
|
id="propuesta-elegida"
|
||||||
const activo = (opcion === 'SI') === acabados
|
value={Math.min(propuestaIndex, propuestas.length - 1)}
|
||||||
return (
|
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
|
||||||
|
>
|
||||||
|
{propuestas.map((p, i) => (
|
||||||
|
<option key={p.tipo} value={i}>
|
||||||
|
#{i + 1} {p.titulo}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
key={opcion}
|
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={activo}
|
onClick={handleCompartir}
|
||||||
onClick={() => setAcabados(opcion === 'SI')}
|
disabled={!puedeGenerarPdf}
|
||||||
className={`px-5 py-2.5 text-xs font-medium transition-colors duration-100 ${
|
className="rounded-lg border border-accent bg-white px-5 py-2.5 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
activo
|
|
||||||
? 'bg-accent text-white'
|
|
||||||
: 'bg-white text-[#4B5563] hover:bg-[#F9FAFB]'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{opcion}
|
Compartir
|
||||||
</button>
|
</button>
|
||||||
)
|
<button
|
||||||
})}
|
type="button"
|
||||||
|
onClick={handleGenerarPdf}
|
||||||
|
disabled={!puedeGenerarPdf}
|
||||||
|
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{guardando ? 'Guardando…' : 'Guardar y generar PDF'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{aviso && <p className="mt-2 text-right text-xs text-[#64748B]">{aviso}</p>}
|
||||||
|
{!puedeGenerarPdf && !guardando && (
|
||||||
|
<p className="mt-2 text-right text-xs text-[#64748B]">
|
||||||
|
Selecciona una unidad para guardar y generar el PDF.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="apartado" className={etiquetaCampo}>
|
|
||||||
Monto de apartado (MXN)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="apartado"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step={1000}
|
|
||||||
value={apartado}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = Number(e.target.value)
|
|
||||||
setApartado(Number.isFinite(v) && v >= 0 ? v : 0)
|
|
||||||
}}
|
|
||||||
className={errorApartado ? campoInputError : campoInput}
|
|
||||||
/>
|
|
||||||
{errorApartado ? (
|
|
||||||
<p className="mt-1 text-xs text-red-700">{errorApartado}</p>
|
|
||||||
) : (
|
|
||||||
<p className="mt-1 text-xs text-[#64748B]">{formatoMoneda(apartado)}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="nombre" className={etiquetaCampo}>
|
|
||||||
Nombre del cliente *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="nombre"
|
|
||||||
type="text"
|
|
||||||
className={errors.nombre ? campoInputError : campoInput}
|
|
||||||
{...register('nombre')}
|
|
||||||
/>
|
|
||||||
{errors.nombre && <p className="mt-1 text-xs text-red-700">{errors.nombre.message}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className={etiquetaCampo}>
|
|
||||||
Correo electrónico (opcional)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
className={errors.email ? campoInputError : campoInput}
|
|
||||||
{...register('email')}
|
|
||||||
/>
|
|
||||||
{errors.email && <p className="mt-1 text-xs text-red-700">{errors.email.message}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="telefono" className={etiquetaCampo}>
|
|
||||||
Teléfono (opcional)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="telefono"
|
|
||||||
type="tel"
|
|
||||||
className={errors.telefono ? campoInputError : campoInput}
|
|
||||||
{...register('telefono')}
|
|
||||||
/>
|
|
||||||
{errors.telefono && (
|
|
||||||
<p className="mt-1 text-xs text-red-700">{errors.telefono.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{unidad && (
|
|
||||||
<div className="border-t border-[#E5E7EB] pt-8">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
|
||||||
Precio de lista
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-3xl font-semibold text-[#111827]">
|
|
||||||
{formatoMoneda(precioLista)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{unidad && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleNueva}
|
|
||||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
|
||||||
>
|
|
||||||
Nueva cotización
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
{unidad && <UnitInfoCard unidad={unidad} numMeses={numMeses} />}
|
{/* Secciones de ancho completo */}
|
||||||
</aside>
|
<div className="mt-10 space-y-8">
|
||||||
|
|
||||||
{/* Panel de propuestas */}
|
|
||||||
<section>
|
|
||||||
<MisCotizaciones
|
<MisCotizaciones
|
||||||
cotizaciones={misCotizaciones}
|
cotizaciones={misCotizaciones}
|
||||||
cargando={cargandoMias}
|
cargando={cargandoMias}
|
||||||
error={errorMias}
|
error={errorMias}
|
||||||
onRetomar={handleRetomar}
|
onRetomar={handleRetomar}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!unidad ? (
|
|
||||||
<div className="mt-16 flex h-64 items-center justify-center rounded-xl border border-dashed border-[#D1D5DB] bg-white">
|
|
||||||
<p className="text-sm text-[#64748B]">
|
|
||||||
Selecciona una unidad para ver las propuestas comerciales.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-16">
|
|
||||||
<div className="grid gap-8 md:grid-cols-2">
|
|
||||||
{propuestas.map((p, i) => (
|
|
||||||
<ProposalCard
|
|
||||||
key={p.tipo}
|
|
||||||
numero={i + 1}
|
|
||||||
titulo={p.titulo}
|
|
||||||
propuesta={p}
|
|
||||||
onDescuentoChange={(v) => setDescuento(params.propuestas[i].claveDescuento, v)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<ComparadorPropuestas
|
|
||||||
precioLista={precioLista}
|
|
||||||
apartado={apartadoEfectivo}
|
|
||||||
propuestas={propuestas}
|
|
||||||
titulos={propuestas.map((p) => p.titulo)}
|
|
||||||
/>
|
|
||||||
<div className="mt-16 flex flex-wrap items-center justify-end gap-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="propuesta-elegida" className={etiquetaCampo}>
|
|
||||||
Propuesta elegida
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="propuesta-elegida"
|
|
||||||
value={Math.min(propuestaIndex, propuestas.length - 1)}
|
|
||||||
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
|
|
||||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
|
|
||||||
>
|
|
||||||
{propuestas.map((p, i) => (
|
|
||||||
<option key={p.tipo} value={i}>
|
|
||||||
#{i + 1} {p.titulo}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCompartir}
|
|
||||||
disabled={!puedeGenerarPdf}
|
|
||||||
className="rounded-lg border border-accent bg-white px-5 py-2.5 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
>
|
|
||||||
Compartir
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleGenerarPdf}
|
|
||||||
disabled={!puedeGenerarPdf}
|
|
||||||
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{guardando ? 'Guardando…' : 'Guardar y generar PDF'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{aviso && <p className="mt-2 text-right text-xs text-[#64748B]">{aviso}</p>}
|
|
||||||
{!puedeGenerarPdf && !guardando && (
|
|
||||||
<p className="mt-2 text-right text-xs text-[#64748B]">
|
|
||||||
Selecciona una unidad para guardar y generar el PDF.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ComparadorUnidades
|
<ComparadorUnidades
|
||||||
unidades={unidades}
|
unidades={unidades}
|
||||||
tipo={propuestaActiva?.tipo ?? 'CREDITO_BANCARIO'}
|
tipo={propuestaActiva?.tipo ?? 'CREDITO_BANCARIO'}
|
||||||
apartado={apartadoEfectivo}
|
apartado={apartadoEfectivo}
|
||||||
acabados={acabados}
|
acabados={acabados}
|
||||||
/>
|
/>
|
||||||
</section>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<ModalCliente
|
<ModalCliente
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function AuthGate({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||||
<div className="border-b border-[#E5E7EB] bg-white">
|
<div className="border-b border-[#E5E7EB] bg-white">
|
||||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
|
<div className="mx-auto flex max-w-[1280px] items-center justify-between px-4 py-2 sm:px-6">
|
||||||
<span className="text-xs font-medium text-[#4B5563]">
|
<span className="text-xs font-medium text-[#4B5563]">
|
||||||
{sesion.usuario.nombre} · {sesion.usuario.rol}
|
{sesion.usuario.nombre} · {sesion.usuario.rol}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Cell, Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } from 'recharts'
|
import { Cell, Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } from 'recharts'
|
||||||
import KpiCard from './KpiCard'
|
import KpiCard from './KpiCard'
|
||||||
import { EstadoVacio, TituloSeccion } from './comunes'
|
import { EstadoVacio, TarjetaGrafica, type AccionesTablero } from './comunes'
|
||||||
import { funnelMisLeads, type ResumenMiDia } from '../lib/mi-tablero'
|
import { funnelMisLeads, type ResumenMiDia } from '../lib/mi-tablero'
|
||||||
import { fetchResumenMiDia } from '../services/mi-dia'
|
import { fetchResumenMiDia } from '../services/mi-dia'
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB']
|
|||||||
* apartados por vencer) y un funnel simple de sus leads por estado.
|
* apartados por vencer) y un funnel simple de sus leads por estado.
|
||||||
* Fuente: GET /api/mi-dia/resumen (filtra por su mondayUserId en el API).
|
* Fuente: GET /api/mi-dia/resumen (filtra por su mondayUserId en el API).
|
||||||
*/
|
*/
|
||||||
export default function MiTablero() {
|
export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (acciones: AccionesTablero) => void }) {
|
||||||
const [resumen, setResumen] = useState<ResumenMiDia | null>(null)
|
const [resumen, setResumen] = useState<ResumenMiDia | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [cargando, setCargando] = useState(true)
|
const [cargando, setCargando] = useState(true)
|
||||||
@@ -34,28 +34,22 @@ export default function MiTablero() {
|
|||||||
void cargar()
|
void cargar()
|
||||||
}, [cargar])
|
}, [cargar])
|
||||||
|
|
||||||
|
// Expone "Actualizar" al header sticky de DashboardPage
|
||||||
|
useEffect(() => {
|
||||||
|
alCambiarAcciones({ actualizar: () => void cargar(), cargando })
|
||||||
|
}, [alCambiarAcciones, cargar, cargando])
|
||||||
|
|
||||||
const funnel = resumen ? funnelMisLeads(resumen.misLeads) : []
|
const funnel = resumen ? funnelMisLeads(resumen.misLeads) : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-4 py-8">
|
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6">
|
||||||
<div className="mb-8 flex items-end justify-between">
|
<div className="mb-8">
|
||||||
<div>
|
<h1 className="text-xl font-semibold text-[#111827]">Mi tablero</h1>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-accent">FRASA Desarrollos</p>
|
{resumen && (
|
||||||
<h1 className="mt-2 text-3xl font-light text-[#111827]">Mi tablero</h1>
|
<p className="mt-1 text-sm text-[#64748B]">
|
||||||
{resumen && (
|
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
|
||||||
<p className="mt-1 text-xs text-[#64748B]">
|
</p>
|
||||||
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
|
)}
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void cargar()}
|
|
||||||
disabled={cargando}
|
|
||||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{cargando ? 'Actualizando…' : 'Actualizar'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -75,7 +69,7 @@ export default function MiTablero() {
|
|||||||
|
|
||||||
{resumen && (
|
{resumen && (
|
||||||
<>
|
<>
|
||||||
<section aria-label="Mis indicadores" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<section aria-label="Mis indicadores" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<KpiCard
|
<KpiCard
|
||||||
titulo="Mis leads activos"
|
titulo="Mis leads activos"
|
||||||
valor={resumen.misLeads.length}
|
valor={resumen.misLeads.length}
|
||||||
@@ -93,26 +87,27 @@ export default function MiTablero() {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-label="Mis leads por estado" className="mt-12">
|
<section aria-label="Mis leads por estado" className="mt-10">
|
||||||
<TituloSeccion>Mis leads por estado</TituloSeccion>
|
<TarjetaGrafica titulo="Mis leads por estado">
|
||||||
{funnel.length === 0 ? (
|
{funnel.length === 0 ? (
|
||||||
<EstadoVacio mensaje="Sin leads asignados" />
|
<EstadoVacio mensaje="Sin leads asignados" />
|
||||||
) : (
|
) : (
|
||||||
<div className="h-72">
|
<div className="h-72">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<FunnelChart>
|
<FunnelChart>
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Funnel dataKey="valor" data={funnel} nameKey="etapa" isAnimationActive={false}>
|
<Funnel dataKey="valor" data={funnel} nameKey="etapa" isAnimationActive={false}>
|
||||||
{funnel.map((etapa, i) => (
|
{funnel.map((etapa, i) => (
|
||||||
<Cell key={etapa.etapa} fill={COLORES_FUNNEL[i % COLORES_FUNNEL.length]} />
|
<Cell key={etapa.etapa} fill={COLORES_FUNNEL[i % COLORES_FUNNEL.length]} />
|
||||||
))}
|
))}
|
||||||
<LabelList position="right" dataKey="etapa" stroke="none" fontSize={11} />
|
<LabelList position="right" dataKey="etapa" stroke="none" fontSize={11} />
|
||||||
<LabelList position="center" dataKey="valor" stroke="none" fill="#fff" fontSize={12} />
|
<LabelList position="center" dataKey="valor" stroke="none" fill="#fff" fontSize={12} />
|
||||||
</Funnel>
|
</Funnel>
|
||||||
</FunnelChart>
|
</FunnelChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</TarjetaGrafica>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const DIMENSIONES: { titulo: string; registro: (perfil: ResumenReportes['leads']
|
|||||||
function TarjetaDimension({ titulo, datos }: { titulo: string; datos: Record<string, number> }) {
|
function TarjetaDimension({ titulo, datos }: { titulo: string; datos: Record<string, number> }) {
|
||||||
const rebanadas = rebanadasDe(datos)
|
const rebanadas = rebanadasDe(datos)
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm transition-shadow duration-200 hover:shadow-md">
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm transition-shadow duration-200 hover:shadow-md">
|
||||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</p>
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</p>
|
||||||
{rebanadas.length === 0 ? (
|
{rebanadas.length === 0 ? (
|
||||||
<EstadoVacio mensaje="Sin datos" />
|
<EstadoVacio mensaje="Sin datos" />
|
||||||
@@ -47,9 +47,9 @@ function TarjetaDimension({ titulo, datos }: { titulo: string; datos: Record<str
|
|||||||
*/
|
*/
|
||||||
export default function Perfilamiento({ perfil }: { perfil: ResumenReportes['leads']['perfil'] }) {
|
export default function Perfilamiento({ perfil }: { perfil: ResumenReportes['leads']['perfil'] }) {
|
||||||
return (
|
return (
|
||||||
<section aria-label="Perfilamiento de la afluencia" className="mt-12">
|
<section aria-label="Perfilamiento de la afluencia" className="mt-10">
|
||||||
<TituloSeccion>Perfilamiento de la afluencia</TituloSeccion>
|
<TituloSeccion>Perfilamiento de la afluencia</TituloSeccion>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{DIMENSIONES.map((d) => (
|
{DIMENSIONES.map((d) => (
|
||||||
<TarjetaDimension key={d.titulo} titulo={d.titulo} datos={d.registro(perfil)} />
|
<TarjetaDimension key={d.titulo} titulo={d.titulo} datos={d.registro(perfil)} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function SeccionEquipo() {
|
|||||||
}, [cargar])
|
}, [cargar])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Equipo" className="mt-12">
|
<section aria-label="Equipo" className="mt-10">
|
||||||
<TituloSeccion>Equipo: leads por vendedor</TituloSeccion>
|
<TituloSeccion>Equipo: leads por vendedor</TituloSeccion>
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="rounded-lg bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700">
|
<p role="alert" className="rounded-lg bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
import KpiCard from './KpiCard'
|
import KpiCard from './KpiCard'
|
||||||
import Perfilamiento from './Perfilamiento'
|
import Perfilamiento from './Perfilamiento'
|
||||||
import SeccionEquipo from './SeccionEquipo'
|
import SeccionEquipo from './SeccionEquipo'
|
||||||
import { EstadoVacio, TituloSeccion } from './comunes'
|
import { EstadoVacio, TarjetaGrafica, type AccionesTablero } from './comunes'
|
||||||
import {
|
import {
|
||||||
datosCobranza,
|
datosCobranza,
|
||||||
datosFunnel,
|
datosFunnel,
|
||||||
@@ -47,7 +47,13 @@ const COLORES_COBRANZA = ['#2563EB', '#F59E0B']
|
|||||||
* pipeline, funnel y cobranza). Con `mostrarEquipo` (coordinador/corporativo/
|
* pipeline, funnel y cobranza). Con `mostrarEquipo` (coordinador/corporativo/
|
||||||
* admin) agrega la sección Equipo con leads por vendedor.
|
* admin) agrega la sección Equipo con leads por vendedor.
|
||||||
*/
|
*/
|
||||||
export default function TableroGeneral({ mostrarEquipo }: { mostrarEquipo: boolean }) {
|
export default function TableroGeneral({
|
||||||
|
mostrarEquipo,
|
||||||
|
alCambiarAcciones,
|
||||||
|
}: {
|
||||||
|
mostrarEquipo: boolean
|
||||||
|
alCambiarAcciones: (acciones: AccionesTablero) => void
|
||||||
|
}) {
|
||||||
const [resumen, setResumen] = useState<ResumenReportes | null>(null)
|
const [resumen, setResumen] = useState<ResumenReportes | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [cargando, setCargando] = useState(true)
|
const [cargando, setCargando] = useState(true)
|
||||||
@@ -68,6 +74,11 @@ export default function TableroGeneral({ mostrarEquipo }: { mostrarEquipo: boole
|
|||||||
void cargar()
|
void cargar()
|
||||||
}, [cargar])
|
}, [cargar])
|
||||||
|
|
||||||
|
// Expone "Actualizar" al header sticky de DashboardPage
|
||||||
|
useEffect(() => {
|
||||||
|
alCambiarAcciones({ actualizar: () => void cargar(), cargando })
|
||||||
|
}, [alCambiarAcciones, cargar, cargando])
|
||||||
|
|
||||||
const unidades = resumen ? unidadesApiladas(resumen) : []
|
const unidades = resumen ? unidadesApiladas(resumen) : []
|
||||||
const funnel = resumen ? datosFunnel(resumen) : []
|
const funnel = resumen ? datosFunnel(resumen) : []
|
||||||
const cobranza = resumen ? datosCobranza(resumen) : []
|
const cobranza = resumen ? datosCobranza(resumen) : []
|
||||||
@@ -76,25 +87,14 @@ export default function TableroGeneral({ mostrarEquipo }: { mostrarEquipo: boole
|
|||||||
const sinCobranza = resumen !== null && resumen.cobranza.planesAlCorriente + resumen.cobranza.planesConVencidos === 0
|
const sinCobranza = resumen !== null && resumen.cobranza.planesAlCorriente + resumen.cobranza.planesConVencidos === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-4 py-8">
|
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6">
|
||||||
<div className="mb-8 flex items-end justify-between">
|
<div className="mb-8">
|
||||||
<div>
|
<h1 className="text-xl font-semibold text-[#111827]">Tablero comercial</h1>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-accent">FRASA Desarrollos</p>
|
{resumen && (
|
||||||
<h1 className="mt-2 text-3xl font-light text-[#111827]">Tablero comercial</h1>
|
<p className="mt-1 text-sm text-[#64748B]">
|
||||||
{resumen && (
|
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
|
||||||
<p className="mt-1 text-xs text-[#64748B]">
|
</p>
|
||||||
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
|
)}
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void cargar()}
|
|
||||||
disabled={cargando}
|
|
||||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{cargando ? 'Actualizando…' : 'Actualizar'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -151,97 +151,101 @@ export default function TableroGeneral({ mostrarEquipo }: { mostrarEquipo: boole
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-label="Inventario por desarrollo" className="mt-12">
|
<div className="mt-10 grid gap-6 sm:grid-cols-1 lg:grid-cols-2">
|
||||||
<TituloSeccion>Unidades por desarrollo y status</TituloSeccion>
|
<section aria-label="Inventario por desarrollo" className="lg:col-span-2">
|
||||||
{unidades.length === 0 ? (
|
<TarjetaGrafica titulo="Unidades por desarrollo y status">
|
||||||
<EstadoVacio mensaje="Sin inventario" />
|
{unidades.length === 0 ? (
|
||||||
) : (
|
<EstadoVacio mensaje="Sin inventario" />
|
||||||
<div className="h-72">
|
) : (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<div className="h-72">
|
||||||
<BarChart data={unidades} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
<BarChart data={unidades} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
|
||||||
<XAxis dataKey="desarrollo" tick={{ fontSize: 11 }} />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
|
<XAxis dataKey="desarrollo" tick={{ fontSize: 11 }} />
|
||||||
<Tooltip />
|
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
|
||||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
<Tooltip />
|
||||||
{Object.keys(COLORES_STATUS).map((status) => (
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
<Bar key={status} dataKey={status} stackId="unidades" fill={COLORES_STATUS[status]} />
|
{Object.keys(COLORES_STATUS).map((status) => (
|
||||||
))}
|
<Bar key={status} dataKey={status} stackId="unidades" fill={COLORES_STATUS[status]} />
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section aria-label="Pipeline de ventas por etapa" className="mt-12">
|
|
||||||
<TituloSeccion>Pipeline de ventas por etapa</TituloSeccion>
|
|
||||||
{sinPipeline ? (
|
|
||||||
<EstadoVacio mensaje="Sin unidades en el pipeline" />
|
|
||||||
) : (
|
|
||||||
<div className="h-80">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart data={pipeline} layout="vertical" margin={{ top: 8, right: 24, left: 24, bottom: 0 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
|
||||||
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} />
|
|
||||||
<YAxis type="category" dataKey="etapa" width={90} tick={{ fontSize: 11 }} />
|
|
||||||
<Tooltip />
|
|
||||||
<Bar dataKey="valor" name="Unidades" fill="#2563EB" isAnimationActive={false} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="mt-12 grid gap-12 lg:grid-cols-2">
|
|
||||||
<section aria-label="Embudo comercial">
|
|
||||||
<TituloSeccion>Embudo: leads → oportunidades → ganado</TituloSeccion>
|
|
||||||
{funnel.every((e) => e.valor === 0) ? (
|
|
||||||
<EstadoVacio mensaje="Sin movimiento comercial" />
|
|
||||||
) : (
|
|
||||||
<div className="h-72">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<FunnelChart>
|
|
||||||
<Tooltip />
|
|
||||||
<Funnel dataKey="valor" data={funnel} nameKey="etapa" isAnimationActive={false}>
|
|
||||||
{funnel.map((etapa, i) => (
|
|
||||||
<Cell key={etapa.etapa} fill={COLORES_FUNNEL[i % COLORES_FUNNEL.length]} />
|
|
||||||
))}
|
))}
|
||||||
<LabelList position="right" dataKey="etapa" stroke="none" fontSize={11} />
|
</BarChart>
|
||||||
<LabelList position="center" dataKey="valor" stroke="none" fill="#fff" fontSize={12} />
|
</ResponsiveContainer>
|
||||||
</Funnel>
|
</div>
|
||||||
</FunnelChart>
|
)}
|
||||||
</ResponsiveContainer>
|
</TarjetaGrafica>
|
||||||
</div>
|
</section>
|
||||||
)}
|
|
||||||
|
<section aria-label="Pipeline de ventas por etapa" className="lg:col-span-2">
|
||||||
|
<TarjetaGrafica titulo="Pipeline de ventas por etapa">
|
||||||
|
{sinPipeline ? (
|
||||||
|
<EstadoVacio mensaje="Sin unidades en el pipeline" />
|
||||||
|
) : (
|
||||||
|
<div className="h-80">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={pipeline} layout="vertical" margin={{ top: 8, right: 24, left: 24, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||||
|
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis type="category" dataKey="etapa" width={90} tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="valor" name="Unidades" fill="#2563EB" isAnimationActive={false} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TarjetaGrafica>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label="Embudo comercial">
|
||||||
|
<TarjetaGrafica titulo="Embudo: leads → oportunidades → ganado">
|
||||||
|
{funnel.every((e) => e.valor === 0) ? (
|
||||||
|
<EstadoVacio mensaje="Sin movimiento comercial" />
|
||||||
|
) : (
|
||||||
|
<div className="h-72">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<FunnelChart>
|
||||||
|
<Tooltip />
|
||||||
|
<Funnel dataKey="valor" data={funnel} nameKey="etapa" isAnimationActive={false}>
|
||||||
|
{funnel.map((etapa, i) => (
|
||||||
|
<Cell key={etapa.etapa} fill={COLORES_FUNNEL[i % COLORES_FUNNEL.length]} />
|
||||||
|
))}
|
||||||
|
<LabelList position="right" dataKey="etapa" stroke="none" fontSize={11} />
|
||||||
|
<LabelList position="center" dataKey="valor" stroke="none" fill="#fff" fontSize={12} />
|
||||||
|
</Funnel>
|
||||||
|
</FunnelChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TarjetaGrafica>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-label="Estado de la cobranza">
|
<section aria-label="Estado de la cobranza">
|
||||||
<TituloSeccion>Cobranza: al corriente vs vencidos</TituloSeccion>
|
<TarjetaGrafica titulo="Cobranza: al corriente vs vencidos">
|
||||||
{sinCobranza ? (
|
{sinCobranza ? (
|
||||||
<EstadoVacio mensaje="Sin planes activos" />
|
<EstadoVacio mensaje="Sin planes activos" />
|
||||||
) : (
|
) : (
|
||||||
<div className="h-72">
|
<div className="h-72">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={cobranza}
|
data={cobranza}
|
||||||
dataKey="valor"
|
dataKey="valor"
|
||||||
nameKey="nombre"
|
nameKey="nombre"
|
||||||
innerRadius={60}
|
innerRadius={60}
|
||||||
outerRadius={90}
|
outerRadius={90}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
label={({ name, value }) => `${name}: ${value}`}
|
label={({ name, value }) => `${name}: ${value}`}
|
||||||
fontSize={11}
|
fontSize={11}
|
||||||
>
|
>
|
||||||
{cobranza.map((rebanada, i) => (
|
{cobranza.map((rebanada, i) => (
|
||||||
<Cell key={rebanada.nombre} fill={COLORES_COBRANZA[i % COLORES_COBRANZA.length]} />
|
<Cell key={rebanada.nombre} fill={COLORES_COBRANZA[i % COLORES_COBRANZA.length]} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</TarjetaGrafica>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
// Piezas visuales compartidas por las vistas del tablero (NovaSpark)
|
// Piezas visuales compartidas por las vistas del tablero (NovaSpark)
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acciones que cada vista del tablero expone al header de DashboardPage:
|
||||||
|
* el botón "Actualizar" vive en el header sticky y dispara la recarga de la
|
||||||
|
* vista activa (sin cambiar fuentes de datos ni consultas).
|
||||||
|
*/
|
||||||
|
export type AccionesTablero = { actualizar: () => void; cargando: boolean }
|
||||||
|
|
||||||
export function EstadoVacio({ mensaje }: { mensaje: string }) {
|
export function EstadoVacio({ mensaje }: { mensaje: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#E5E7EB] bg-white">
|
<div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#E5E7EB] bg-white">
|
||||||
@@ -11,3 +20,19 @@ export function EstadoVacio({ mensaje }: { mensaje: string }) {
|
|||||||
export function TituloSeccion({ children }: { children: string }) {
|
export function TituloSeccion({ children }: { children: string }) {
|
||||||
return <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B]">{children}</h2>
|
return <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B]">{children}</h2>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tarjeta contenedora de gráficas: título interno + padding consistente. */
|
||||||
|
export function TarjetaGrafica({
|
||||||
|
titulo,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
titulo: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm">
|
||||||
|
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</h3>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import MiTablero from '../components/MiTablero'
|
import MiTablero from '../components/MiTablero'
|
||||||
import TableroGeneral from '../components/TableroGeneral'
|
import TableroGeneral from '../components/TableroGeneral'
|
||||||
|
import type { AccionesTablero } from '../components/comunes'
|
||||||
import { esRolConEquipo } from '../lib/mi-tablero'
|
import { esRolConEquipo } from '../lib/mi-tablero'
|
||||||
import { getSesion } from '../services/auth'
|
import { getSesion } from '../services/auth'
|
||||||
|
|
||||||
@@ -8,42 +9,70 @@ type Vista = 'mia' | 'general'
|
|||||||
|
|
||||||
const VISTAS: { id: Vista; etiqueta: string }[] = [
|
const VISTAS: { id: Vista; etiqueta: string }[] = [
|
||||||
{ id: 'mia', etiqueta: 'Mi tablero' },
|
{ id: 'mia', etiqueta: 'Mi tablero' },
|
||||||
{ id: 'general', etiqueta: 'General' },
|
{ id: 'general', etiqueta: 'Tablero general' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tableros con vista por rol: el vendedor entra a "Mi tablero" (sus KPIs y el
|
* Tableros con vista por rol: el vendedor entra a "Mi tablero" (sus KPIs y el
|
||||||
* funnel de sus leads); coordinador/corporativo/admin entran a "General" con
|
* funnel de sus leads); coordinador/corporativo/admin entran a "General" con
|
||||||
* la sección Equipo. El toggle permite cambiar de vista en cualquier momento.
|
* la sección Equipo. El header sticky concentra la marca, el segmented control
|
||||||
|
* de vistas y el botón "Actualizar" de la vista activa.
|
||||||
*/
|
*/
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const rol = getSesion()?.usuario.rol ?? 'vendedor'
|
const rol = getSesion()?.usuario.rol ?? 'vendedor'
|
||||||
const conEquipo = esRolConEquipo(rol)
|
const conEquipo = esRolConEquipo(rol)
|
||||||
const [vista, setVista] = useState<Vista>(conEquipo ? 'general' : 'mia')
|
const [vista, setVista] = useState<Vista>(conEquipo ? 'general' : 'mia')
|
||||||
|
const [acciones, setAcciones] = useState<AccionesTablero | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="border-b border-[#E5E7EB] bg-white">
|
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||||
<div className="mx-auto flex max-w-6xl gap-2 px-4 py-3" role="tablist" aria-label="Vista del tablero">
|
<div className="mx-auto flex min-h-14 max-w-[1280px] flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-2 sm:px-6">
|
||||||
{VISTAS.map((v) => (
|
<div className="flex items-center gap-2.5">
|
||||||
<button
|
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-xs font-bold text-white">
|
||||||
key={v.id}
|
F
|
||||||
type="button"
|
</span>
|
||||||
role="tab"
|
<span className="text-sm font-semibold text-[#111827]">FRASA Desarrollos</span>
|
||||||
aria-selected={vista === v.id}
|
</div>
|
||||||
onClick={() => setVista(v.id)}
|
<div className="flex items-center gap-3">
|
||||||
className={
|
<div
|
||||||
vista === v.id
|
className="inline-flex rounded-lg border border-[#E5E7EB] bg-[#F1F5F9] p-1"
|
||||||
? 'rounded-lg bg-accent px-5 py-2 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover'
|
role="tablist"
|
||||||
: 'rounded-lg border border-[#D1D5DB] bg-white px-5 py-2 text-xs font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB]'
|
aria-label="Vista del tablero"
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{v.etiqueta}
|
{VISTAS.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={vista === v.id}
|
||||||
|
onClick={() => setVista(v.id)}
|
||||||
|
className={
|
||||||
|
vista === v.id
|
||||||
|
? 'rounded-md bg-white px-3 py-1.5 text-xs font-medium text-[#111827] shadow-sm transition-all duration-100 ease-out'
|
||||||
|
: 'rounded-md px-3 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:text-[#111827]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{v.etiqueta}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => acciones?.actualizar()}
|
||||||
|
disabled={!acciones || acciones.cargando}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{acciones?.cargando ? 'Actualizando…' : 'Actualizar'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
{vista === 'mia' ? <MiTablero /> : <TableroGeneral mostrarEquipo={conEquipo} />}
|
{vista === 'mia' ? (
|
||||||
|
<MiTablero alCambiarAcciones={setAcciones} />
|
||||||
|
) : (
|
||||||
|
<TableroGeneral mostrarEquipo={conEquipo} alCambiarAcciones={setAcciones} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,44 @@
|
|||||||
import { MODULOS } from './data/modulos'
|
import { MODULOS } from './data/modulos'
|
||||||
|
import { Navbar } from './components/Navbar'
|
||||||
import { TarjetaModulo } from './components/TarjetaModulo'
|
import { TarjetaModulo } from './components/TarjetaModulo'
|
||||||
import { ConfiguracionCuenta } from './components/ConfiguracionCuenta'
|
import { ConfiguracionCuenta } from './components/ConfiguracionCuenta'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
<header className="border-b border-[#E5E7EB] bg-white px-6 pt-20 pb-16 sm:px-12 lg:px-24">
|
<Navbar />
|
||||||
<p className="text-xs font-semibold tracking-wider text-accent uppercase">
|
|
||||||
FRASA Desarrollos <span aria-hidden="true" className="text-[#F97316]">●</span>
|
<main className="mx-auto w-full max-w-[1280px] flex-1 px-6">
|
||||||
</p>
|
<section aria-labelledby="titulo-hero" className="py-12 sm:py-16">
|
||||||
<h1 className="mt-4 text-4xl font-light text-[#111827] sm:text-5xl">
|
<h1
|
||||||
Ecosistema comercial
|
id="titulo-hero"
|
||||||
</h1>
|
className="text-3xl font-semibold tracking-tight text-[#111827] sm:text-4xl"
|
||||||
</header>
|
>
|
||||||
|
Ecosistema comercial
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-[#64748B] sm:text-base">
|
||||||
|
Las herramientas del equipo comercial de Frasa Desarrollos en un solo lugar:
|
||||||
|
cotizaciones, alta de clientes, cobranza, tableros y asistente IA.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<main className="flex-1 px-6 sm:px-12 lg:px-24">
|
|
||||||
<nav aria-label="Módulos del ecosistema">
|
<nav aria-label="Módulos del ecosistema">
|
||||||
<ul className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
<ul className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{MODULOS.map((modulo) => (
|
{MODULOS.map((modulo) => (
|
||||||
<TarjetaModulo key={modulo.id} modulo={modulo} />
|
<TarjetaModulo key={modulo.id} modulo={modulo} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<ConfiguracionCuenta />
|
<div className="mt-6">
|
||||||
|
<ConfiguracionCuenta />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="px-6 pt-24 pb-10 sm:px-12 lg:px-24">
|
<footer className="mt-16 border-t border-[#E5E7EB]">
|
||||||
<p className="text-xs text-[#64748B]">Consult (AS) Technology</p>
|
<div className="mx-auto max-w-[1280px] px-6 py-8">
|
||||||
|
<p className="text-xs text-[#64748B]">Consult (AS) Technology</p>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -95,7 +95,11 @@ export function ConfiguracionCuenta() {
|
|||||||
perfil?.mondayUserId != null ? (usuarios?.find((u) => u.id === perfil.mondayUserId) ?? null) : null
|
perfil?.mondayUserId != null ? (usuarios?.find((u) => u.id === perfil.mondayUserId) ?? null) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby="titulo-cuenta" className="mt-24 border-t border-[#E5E7EB] pt-16">
|
<section
|
||||||
|
id="configuracion"
|
||||||
|
aria-labelledby="titulo-cuenta"
|
||||||
|
className="scroll-mt-20 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm sm:p-8"
|
||||||
|
>
|
||||||
<h2 id="titulo-cuenta" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
<h2 id="titulo-cuenta" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||||
Configuración de cuenta
|
Configuración de cuenta
|
||||||
</h2>
|
</h2>
|
||||||
@@ -103,7 +107,7 @@ export function ConfiguracionCuenta() {
|
|||||||
{!conSesion ? (
|
{!conSesion ? (
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => void entrar(e)}
|
onSubmit={(e) => void entrar(e)}
|
||||||
className="mt-8 max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm"
|
className="mt-6 max-w-sm"
|
||||||
>
|
>
|
||||||
<p className="text-sm leading-relaxed text-[#64748B]">
|
<p className="text-sm leading-relaxed text-[#64748B]">
|
||||||
Inicia sesión para vincular tu usuario de Monday.
|
Inicia sesión para vincular tu usuario de Monday.
|
||||||
@@ -150,7 +154,7 @@ export function ConfiguracionCuenta() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-8 max-w-md rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
<div className="mt-6 max-w-md">
|
||||||
{cargandoPerfil && <p className="text-sm text-[#64748B]">Cargando tu perfil…</p>}
|
{cargandoPerfil && <p className="text-sm text-[#64748B]">Cargando tu perfil…</p>}
|
||||||
|
|
||||||
{perfil && (
|
{perfil && (
|
||||||
|
|||||||
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
|
modulo: Modulo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Iniciales del "icono" de cada tarjeta; fallback: primeras 2 letras del nombre
|
||||||
|
const INICIALES: Record<string, string> = {
|
||||||
|
cotizador: 'CO',
|
||||||
|
'alta-clientes': 'AC',
|
||||||
|
cobranza: 'CB',
|
||||||
|
'agente-ia': 'IA',
|
||||||
|
tableros: 'TB',
|
||||||
|
'mi-dia': 'MD',
|
||||||
|
}
|
||||||
|
|
||||||
/** Tarjeta de acceso a un módulo del ecosistema */
|
/** Tarjeta de acceso a un módulo del ecosistema */
|
||||||
export function TarjetaModulo({ modulo }: Props) {
|
export function TarjetaModulo({ modulo }: Props) {
|
||||||
|
const iniciales = INICIALES[modulo.id] ?? modulo.nombre.slice(0, 2).toUpperCase()
|
||||||
return (
|
return (
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href={modulo.url}
|
href={modulo.url}
|
||||||
className="group flex h-full flex-col rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm transition-all duration-200 ease-in-out hover:shadow-md"
|
className="group flex h-full flex-col rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm transition-all duration-200 ease-in-out hover:shadow-md"
|
||||||
>
|
>
|
||||||
<h2 className="text-2xl font-light text-[#111827]">{modulo.nombre}</h2>
|
<span
|
||||||
<p className="mt-3 text-sm leading-relaxed text-[#64748B]">{modulo.descripcion}</p>
|
aria-hidden="true"
|
||||||
<span className="mt-auto pt-8 text-sm font-medium text-accent transition-colors duration-100 ease-out group-hover:text-accent-hover">
|
className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-sm font-semibold text-accent"
|
||||||
|
>
|
||||||
|
{iniciales}
|
||||||
|
</span>
|
||||||
|
<h2 className="mt-4 text-base font-semibold text-[#111827]">{modulo.nombre}</h2>
|
||||||
|
<p className="mt-1 text-sm leading-relaxed text-[#64748B]">{modulo.descripcion}</p>
|
||||||
|
<span className="mt-auto pt-6 text-sm font-medium text-accent transition-colors duration-100 ease-out group-hover:text-accent-hover">
|
||||||
Entrar <span aria-hidden="true">→</span>
|
Entrar <span aria-hidden="true">→</span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export function getToken(): string | null {
|
|||||||
|
|
||||||
export function logout(): void {
|
export function logout(): void {
|
||||||
localStorage.removeItem(STORAGE_KEY)
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
|
notificarCambioSesion()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Avisa a la UI (p. ej. el navbar) que cambió el estado de sesión */
|
||||||
|
function notificarCambioSesion(): void {
|
||||||
|
if (typeof window !== 'undefined') window.dispatchEvent(new Event('frasa:sesion'))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /auth/login; guarda token + usuario en localStorage */
|
/** POST /auth/login; guarda token + usuario en localStorage */
|
||||||
@@ -57,6 +63,7 @@ export async function login(username: string, password: string): Promise<Usuario
|
|||||||
if (!res.ok) throw new ErrorLogin()
|
if (!res.ok) throw new ErrorLogin()
|
||||||
const data = (await res.json()) as Sesion
|
const data = (await res.json()) as Sesion
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: data.token, usuario: data.usuario }))
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: data.token, usuario: data.usuario }))
|
||||||
|
notificarCambioSesion()
|
||||||
return data.usuario
|
return data.usuario
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,18 @@ import { getSesion, logout, type Sesion } from '../services/auth'
|
|||||||
import CambiarPasswordModal from './CambiarPasswordModal'
|
import CambiarPasswordModal from './CambiarPasswordModal'
|
||||||
import LoginScreen from './LoginScreen'
|
import LoginScreen from './LoginScreen'
|
||||||
|
|
||||||
|
/** Acciones de sesión que la página ubica en el header ("Contraseña" y "Salir") */
|
||||||
|
export interface AccionesSesion {
|
||||||
|
onCambiarPassword: () => void
|
||||||
|
onLogout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la
|
* Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la
|
||||||
* app con una barra discreta "nombre · rol", "Contraseña" (modal de cambio)
|
* app. La página recibe las acciones de sesión como render prop para
|
||||||
* y botón "Salir".
|
* integrarlas en su propio header.
|
||||||
*/
|
*/
|
||||||
export default function AuthGate({ children }: { children: ReactNode }) {
|
export default function AuthGate({ children }: { children: (acciones: AccionesSesion) => ReactNode }) {
|
||||||
const [sesion, setSesion] = useState<Sesion | null>(() => getSesion())
|
const [sesion, setSesion] = useState<Sesion | null>(() => getSesion())
|
||||||
const [modalPassword, setModalPassword] = useState(false)
|
const [modalPassword, setModalPassword] = useState(false)
|
||||||
|
|
||||||
@@ -18,33 +24,13 @@ export default function AuthGate({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||||
<div className="border-b border-[#E5E7EB] bg-white">
|
{children({
|
||||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
|
onCambiarPassword: () => setModalPassword(true),
|
||||||
<span className="text-xs font-medium text-[#64748B]">
|
onLogout: () => {
|
||||||
{sesion.usuario.nombre} · {sesion.usuario.rol}
|
logout()
|
||||||
</span>
|
setSesion(null)
|
||||||
<div className="flex items-center gap-4">
|
},
|
||||||
<button
|
})}
|
||||||
type="button"
|
|
||||||
onClick={() => setModalPassword(true)}
|
|
||||||
className="rounded-md px-2 py-1.5 text-xs font-medium text-[#4B5563] transition-all duration-100 ease-out hover:text-[#111827]"
|
|
||||||
>
|
|
||||||
Contraseña
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
logout()
|
|
||||||
setSesion(null)
|
|
||||||
}}
|
|
||||||
className="rounded-md px-2 py-1.5 text-xs font-medium text-accent transition-all duration-100 ease-out hover:text-accent-hover"
|
|
||||||
>
|
|
||||||
Salir
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
{modalPassword && <CambiarPasswordModal onCerrar={() => setModalPassword(false)} />}
|
{modalPassword && <CambiarPasswordModal onCerrar={() => setModalPassword(false)} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import MiDiaPage from './pages/MiDiaPage'
|
|||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<AuthGate>
|
<AuthGate>
|
||||||
<MiDiaPage />
|
{(accionesSesion) => <MiDiaPage accionesSesion={accionesSesion} />}
|
||||||
</AuthGate>
|
</AuthGate>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState, type FormEvent } from 'react'
|
import { useCallback, useEffect, useState, type FormEvent } from 'react'
|
||||||
import KpiCard from '../components/KpiCard'
|
import KpiCard from '../components/KpiCard'
|
||||||
|
import type { AccionesSesion } from '../components/AuthGate'
|
||||||
import {
|
import {
|
||||||
esActividadVencida,
|
esActividadVencida,
|
||||||
esRolConEquipo,
|
esRolConEquipo,
|
||||||
@@ -179,7 +180,7 @@ function FilaLead({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MiDiaPage() {
|
export default function MiDiaPage({ accionesSesion }: { accionesSesion: AccionesSesion }) {
|
||||||
const nombreVendedor = getSesion()?.usuario.nombre ?? ''
|
const nombreVendedor = getSesion()?.usuario.nombre ?? ''
|
||||||
const rol = getSesion()?.usuario.rol ?? 'vendedor'
|
const rol = getSesion()?.usuario.rol ?? 'vendedor'
|
||||||
const [resumen, setResumen] = useState<ResumenMiDia | null>(null)
|
const [resumen, setResumen] = useState<ResumenMiDia | null>(null)
|
||||||
@@ -258,340 +259,387 @@ export default function MiDiaPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hoy = hoyLocal()
|
const hoy = hoyLocal()
|
||||||
|
const iniciales = nombreVendedor
|
||||||
|
.split(' ')
|
||||||
|
.filter((p) => p !== '')
|
||||||
|
.map((p) => p.charAt(0))
|
||||||
|
.slice(0, 2)
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-4 py-8">
|
<>
|
||||||
<div className="mb-8 flex items-end justify-between">
|
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||||
<div>
|
<div className="mx-auto flex h-14 max-w-[1280px] items-center justify-between gap-4 px-4 sm:px-6">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-accent">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
FRASA <span aria-hidden="true" className="text-[#F97316]">·</span> Mi día
|
<p className="shrink-0 text-xs font-semibold uppercase tracking-wider text-accent">
|
||||||
</p>
|
FRASA <span aria-hidden="true" className="text-[#F97316]">·</span> Mi día
|
||||||
<h1 className="mt-2 text-3xl font-semibold text-[#111827]">{nombreVendedor}</h1>
|
</p>
|
||||||
|
<span aria-hidden="true" className="hidden h-4 w-px bg-[#E5E7EB] sm:block" />
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-accent/10 text-xs font-semibold text-accent"
|
||||||
|
>
|
||||||
|
{iniciales}
|
||||||
|
</span>
|
||||||
|
<span className="hidden truncate text-sm font-medium text-[#111827] sm:block">{nombreVendedor}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={accionesSesion.onCambiarPassword}
|
||||||
|
className="rounded-md px-2 py-1.5 text-xs font-medium text-[#4B5563] transition-all duration-100 ease-out hover:text-[#111827]"
|
||||||
|
>
|
||||||
|
Contraseña
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={accionesSesion.onLogout}
|
||||||
|
className="rounded-md px-2 py-1.5 text-xs font-medium text-accent transition-all duration-100 ease-out hover:text-accent-hover"
|
||||||
|
>
|
||||||
|
Salir
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void cargar()}
|
||||||
|
disabled={cargando}
|
||||||
|
className="rounded-lg bg-accent px-4 py-2 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50 sm:text-sm"
|
||||||
|
>
|
||||||
|
{cargando ? 'Actualizando…' : 'Actualizar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
</header>
|
||||||
type="button"
|
|
||||||
onClick={() => void cargar()}
|
|
||||||
disabled={cargando}
|
|
||||||
className="rounded-lg bg-accent px-5 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{cargando ? 'Actualizando…' : 'Actualizar'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6">
|
||||||
<p role="alert" className="mb-8 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-700">
|
<div className="mb-6">
|
||||||
Sin datos del API — {error}
|
<h1 className="text-xl font-semibold text-[#111827]">{nombreVendedor}</h1>
|
||||||
</p>
|
<p className="mt-1 text-sm text-[#64748B]">{formatoFechaCorta(hoy)}</p>
|
||||||
)}
|
</div>
|
||||||
{aviso && (
|
|
||||||
<p role="alert" className="mb-8 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">
|
|
||||||
{aviso}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{resumen?.sinMapeoMonday && (
|
|
||||||
<p className="mb-8 rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-sm font-medium text-[#64748B] shadow-sm">
|
|
||||||
Tu usuario aún no está vinculado a Monday; las acciones siguen funcionando
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!resumen && cargando && <EstadoVacio mensaje="Cargando…" />}
|
{error && (
|
||||||
|
<p role="alert" className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-700">
|
||||||
|
Sin datos del API — {error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{aviso && (
|
||||||
|
<p role="alert" className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">
|
||||||
|
{aviso}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{resumen?.sinMapeoMonday && (
|
||||||
|
<p className="mb-6 rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-sm font-medium text-[#64748B] shadow-sm">
|
||||||
|
Tu usuario aún no está vinculado a Monday; las acciones siguen funcionando
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{resumen && (
|
{!resumen && cargando && <EstadoVacio mensaje="Cargando…" />}
|
||||||
<>
|
|
||||||
<section aria-label="Indicadores del día" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<KpiCard titulo="Leads asignados" valor={resumen.misLeads.length} />
|
|
||||||
<KpiCard
|
|
||||||
titulo="Actividades de hoy"
|
|
||||||
valor={resumen.misActividadesHoy.length}
|
|
||||||
detalle={`${resumen.conteos.actividadesAbiertas} abiertas en total`}
|
|
||||||
/>
|
|
||||||
<KpiCard titulo="Apartados por vencer" valor={resumen.apartadosPorVencer.length} detalle="próximos 7 días" />
|
|
||||||
<KpiCard titulo="Leads nuevos esta semana" valor={resumen.conteos.leadsNuevosSemana} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section aria-label="Mis actividades" className="mt-12">
|
{resumen && (
|
||||||
<TituloSeccion>Mis actividades</TituloSeccion>
|
<>
|
||||||
{resumen.misActividadesHoy.length === 0 ? (
|
<section aria-label="Indicadores del día" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<EstadoVacio mensaje="Sin actividades para hoy" />
|
<KpiCard titulo="Leads asignados" valor={resumen.misLeads.length} />
|
||||||
) : (
|
<KpiCard
|
||||||
<ul className="space-y-2">
|
titulo="Actividades de hoy"
|
||||||
{resumen.misActividadesHoy.map((a) => (
|
valor={resumen.misActividadesHoy.length}
|
||||||
<li
|
detalle={`${resumen.conteos.actividadesAbiertas} abiertas en total`}
|
||||||
key={a.id}
|
/>
|
||||||
className="flex items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
|
<KpiCard titulo="Apartados por vencer" valor={resumen.apartadosPorVencer.length} detalle="próximos 7 días" />
|
||||||
>
|
<KpiCard titulo="Leads nuevos esta semana" valor={resumen.conteos.leadsNuevosSemana} />
|
||||||
<p className="text-sm">
|
|
||||||
{a.nombre}
|
|
||||||
{a.tipo ? <span className="ml-2 text-xs uppercase tracking-wider text-[#94A3B8]">{a.tipo}</span> : null}
|
|
||||||
</p>
|
|
||||||
<span
|
|
||||||
className={`text-xs font-medium ${
|
|
||||||
esActividadVencida(a.fecha, hoy) ? 'text-red-600' : 'text-[#64748B]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{formatoFechaCorta(a.fecha)}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{resumen.apartadosPorVencer.length > 0 && (
|
|
||||||
<section aria-label="Apartados por vencer" className="mt-12">
|
|
||||||
<TituloSeccion>Apartados por vencer</TituloSeccion>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{resumen.apartadosPorVencer.map((a) => (
|
|
||||||
<li
|
|
||||||
key={a.id}
|
|
||||||
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
|
|
||||||
>
|
|
||||||
<p className="text-sm">
|
|
||||||
{a.nombre}
|
|
||||||
{a.unidadCodigo ? <span className="ml-2 text-xs text-[#94A3B8]">{a.unidadCodigo}</span> : null}
|
|
||||||
</p>
|
|
||||||
<span
|
|
||||||
className={`text-xs font-medium ${
|
|
||||||
a.diasRestantes <= 1 ? 'text-red-600' : 'text-amber-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{textoDiasRestantes(a.diasRestantes)} · {formatoFechaCorta(a.fechaLimite)}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
|
||||||
|
|
||||||
<section aria-label="Mis leads" className="mt-12">
|
<div className="mt-6 flex flex-col gap-6 lg:grid lg:grid-cols-3 lg:gap-6">
|
||||||
<TituloSeccion>Mis leads</TituloSeccion>
|
<div className="order-2 flex flex-col gap-6 lg:order-1 lg:col-span-2">
|
||||||
{resumen.misLeads.length === 0 ? (
|
<section aria-label="Mis leads">
|
||||||
<EstadoVacio mensaje="Sin leads asignados" />
|
<TituloSeccion>Mis leads</TituloSeccion>
|
||||||
) : (
|
{resumen.misLeads.length === 0 ? (
|
||||||
<ul className="space-y-2">
|
<EstadoVacio mensaje="Sin leads asignados" />
|
||||||
{resumen.misLeads.map((lead) => (
|
) : (
|
||||||
<FilaLead
|
<ul className="space-y-2">
|
||||||
key={lead.id}
|
{resumen.misLeads.map((lead) => (
|
||||||
lead={lead}
|
<FilaLead
|
||||||
enviando={enviando}
|
key={lead.id}
|
||||||
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha), 'No se pudo actualizar el lead')}
|
lead={lead}
|
||||||
onDescartar={() => setDescartando(lead)}
|
enviando={enviando}
|
||||||
/>
|
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha), 'No se pudo actualizar el lead')}
|
||||||
))}
|
onDescartar={() => setDescartando(lead)}
|
||||||
</ul>
|
/>
|
||||||
)}
|
))}
|
||||||
</section>
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section aria-label="Mis capturas de afluencia" className="mt-12">
|
<section aria-label="Mis capturas de afluencia">
|
||||||
<TituloSeccion>Mis capturas de afluencia</TituloSeccion>
|
<TituloSeccion>Mis capturas de afluencia</TituloSeccion>
|
||||||
{resumen.misCapturas.length === 0 ? (
|
{resumen.misCapturas.length === 0 ? (
|
||||||
<EstadoVacio mensaje="Aún no registras afluencias" />
|
<EstadoVacio mensaje="Aún no registras afluencias" />
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{resumen.misCapturas.map((c) => (
|
{resumen.misCapturas.map((c) => (
|
||||||
<li
|
<li
|
||||||
key={c.leadId}
|
key={c.leadId}
|
||||||
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
|
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{c.nombre}</p>
|
||||||
|
<p className="mt-1 text-xs text-[#64748B]">
|
||||||
|
{c.telefono || 'Sin teléfono'}
|
||||||
|
{c.origen ? ` · ${c.origen}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
|
||||||
|
{c.estado || '—'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-[#64748B]">
|
||||||
|
{formatoFechaCorta(c.capturadoEn.slice(0, 10))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label="Mis clientes">
|
||||||
|
<TituloSeccion>Mis clientes</TituloSeccion>
|
||||||
|
{resumen.misClientes.length === 0 ? (
|
||||||
|
<EstadoVacio mensaje="Aún no registras clientes" />
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{resumen.misClientes.map((c) => (
|
||||||
|
<li
|
||||||
|
key={c.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{c.nombre}</p>
|
||||||
|
<p className="mt-1 text-xs text-[#64748B]">
|
||||||
|
{[c.proyecto, c.unidadCodigo].filter((p) => p !== '').join(' · ') || 'Alta sin proyecto'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||||
|
c.completado
|
||||||
|
? 'bg-gray-100 text-gray-600'
|
||||||
|
: 'bg-amber-50 text-amber-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.completado ? 'Cliente' : 'En captura'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-[#64748B]">
|
||||||
|
{formatoFechaCorta(c.capturadoEn.slice(0, 10))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="order-1 flex flex-col gap-6 lg:order-2 lg:sticky lg:top-20 lg:self-start">
|
||||||
|
<section aria-label="Mis actividades">
|
||||||
|
<TituloSeccion>Mis actividades</TituloSeccion>
|
||||||
|
{resumen.misActividadesHoy.length === 0 ? (
|
||||||
|
<EstadoVacio mensaje="Sin actividades para hoy" />
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{resumen.misActividadesHoy.map((a) => (
|
||||||
|
<li
|
||||||
|
key={a.id}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
|
||||||
|
>
|
||||||
|
<p className="text-sm">
|
||||||
|
{a.nombre}
|
||||||
|
{a.tipo ? <span className="ml-2 text-xs uppercase tracking-wider text-[#94A3B8]">{a.tipo}</span> : null}
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium ${
|
||||||
|
esActividadVencida(a.fecha, hoy) ? 'text-red-600' : 'text-[#64748B]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatoFechaCorta(a.fecha)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{resumen.apartadosPorVencer.length > 0 && (
|
||||||
|
<section aria-label="Apartados por vencer">
|
||||||
|
<TituloSeccion>Apartados por vencer</TituloSeccion>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{resumen.apartadosPorVencer.map((a) => (
|
||||||
|
<li
|
||||||
|
key={a.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
|
||||||
|
>
|
||||||
|
<p className="text-sm">
|
||||||
|
{a.nombre}
|
||||||
|
{a.unidadCodigo ? <span className="ml-2 text-xs text-[#94A3B8]">{a.unidadCodigo}</span> : null}
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium ${
|
||||||
|
a.diasRestantes <= 1 ? 'text-red-600' : 'text-amber-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{textoDiasRestantes(a.diasRestantes)} · {formatoFechaCorta(a.fechaLimite)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section aria-label="Captura rápida de lead">
|
||||||
|
<TituloSeccion>Captura rápida de lead</TituloSeccion>
|
||||||
|
<form
|
||||||
|
onSubmit={enviarCaptura}
|
||||||
|
className="grid gap-4 rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">{c.nombre}</p>
|
<label htmlFor="cap-nombre" className={CLASE_LABEL}>
|
||||||
<p className="mt-1 text-xs text-[#64748B]">
|
Nombre
|
||||||
{c.telefono || 'Sin teléfono'}
|
</label>
|
||||||
{c.origen ? ` · ${c.origen}` : ''}
|
<input
|
||||||
</p>
|
id="cap-nombre"
|
||||||
|
type="text"
|
||||||
|
value={capNombre}
|
||||||
|
onChange={(e) => setCapNombre(e.target.value)}
|
||||||
|
required
|
||||||
|
className={CLASE_INPUT}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
|
|
||||||
{c.estado || '—'}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-medium text-[#64748B]">
|
|
||||||
{formatoFechaCorta(c.capturadoEn.slice(0, 10))}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section aria-label="Mis clientes" className="mt-12">
|
|
||||||
<TituloSeccion>Mis clientes</TituloSeccion>
|
|
||||||
{resumen.misClientes.length === 0 ? (
|
|
||||||
<EstadoVacio mensaje="Aún no registras clientes" />
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{resumen.misClientes.map((c) => (
|
|
||||||
<li
|
|
||||||
key={c.id}
|
|
||||||
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">{c.nombre}</p>
|
<label htmlFor="cap-telefono" className={CLASE_LABEL}>
|
||||||
<p className="mt-1 text-xs text-[#64748B]">
|
Teléfono
|
||||||
{[c.proyecto, c.unidadCodigo].filter((p) => p !== '').join(' · ') || 'Alta sin proyecto'}
|
</label>
|
||||||
</p>
|
<input
|
||||||
|
id="cap-telefono"
|
||||||
|
type="tel"
|
||||||
|
value={capTelefono}
|
||||||
|
onChange={(e) => setCapTelefono(e.target.value)}
|
||||||
|
className={CLASE_INPUT}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div>
|
||||||
<span
|
<label htmlFor="cap-email" className={CLASE_LABEL}>
|
||||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
Email
|
||||||
c.completado
|
</label>
|
||||||
? 'bg-gray-100 text-gray-600'
|
<input
|
||||||
: 'bg-amber-50 text-amber-700'
|
id="cap-email"
|
||||||
|
type="email"
|
||||||
|
value={capEmail}
|
||||||
|
onChange={(e) => setCapEmail(e.target.value)}
|
||||||
|
className={CLASE_INPUT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="cap-origen" className={CLASE_LABEL}>
|
||||||
|
Origen
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="cap-origen"
|
||||||
|
value={capOrigen}
|
||||||
|
onChange={(e) => setCapOrigen(e.target.value)}
|
||||||
|
className={CLASE_INPUT}
|
||||||
|
>
|
||||||
|
{ORIGENES_LEAD.map((o) => (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
{o}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="cap-desarrollo" className={CLASE_LABEL}>
|
||||||
|
Desarrollo de interés
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="cap-desarrollo"
|
||||||
|
value={capDesarrollo}
|
||||||
|
onChange={(e) => setCapDesarrollo(e.target.value)}
|
||||||
|
className={CLASE_INPUT}
|
||||||
|
>
|
||||||
|
<option value="">Sin especificar</option>
|
||||||
|
{DESARROLLOS_LEAD.map((d) => (
|
||||||
|
<option key={d} value={d}>
|
||||||
|
{d}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="cap-notas" className={CLASE_LABEL}>
|
||||||
|
Notas
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="cap-notas"
|
||||||
|
type="text"
|
||||||
|
value={capNotas}
|
||||||
|
onChange={(e) => setCapNotas(e.target.value)}
|
||||||
|
className={CLASE_INPUT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{capMensaje && (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className={`rounded-lg border px-4 py-3 text-sm font-medium ${
|
||||||
|
capMensaje.tipo === 'ok'
|
||||||
|
? 'border-green-200 bg-green-50 text-green-700'
|
||||||
|
: 'border-red-200 bg-red-50 text-red-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{c.completado ? 'Cliente' : 'En captura'}
|
{capMensaje.texto}
|
||||||
</span>
|
</p>
|
||||||
<span className="text-xs font-medium text-[#64748B]">
|
)}
|
||||||
{formatoFechaCorta(c.capturadoEn.slice(0, 10))}
|
<div>
|
||||||
</span>
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={capEnviando}
|
||||||
|
className="w-full rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{capEnviando ? 'Registrando…' : 'Registrar lead'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</form>
|
||||||
))}
|
</section>
|
||||||
</ul>
|
</aside>
|
||||||
)}
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section aria-label="Captura rápida de lead" className="mt-12">
|
{esRolConEquipo(rol) && resumen.equipo && (
|
||||||
<TituloSeccion>Captura rápida de lead</TituloSeccion>
|
<section aria-label="Equipo" className="mt-6">
|
||||||
<form
|
<TituloSeccion>Equipo</TituloSeccion>
|
||||||
onSubmit={enviarCaptura}
|
<p className="mb-4 text-sm text-[#64748B]">
|
||||||
className="grid gap-4 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm sm:grid-cols-2"
|
Leads nuevos sin asignar: <strong>{resumen.equipo.leadsNuevosSinAsignar}</strong>
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="cap-nombre" className={CLASE_LABEL}>
|
|
||||||
Nombre
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="cap-nombre"
|
|
||||||
type="text"
|
|
||||||
value={capNombre}
|
|
||||||
onChange={(e) => setCapNombre(e.target.value)}
|
|
||||||
required
|
|
||||||
className={CLASE_INPUT}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="cap-telefono" className={CLASE_LABEL}>
|
|
||||||
Teléfono
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="cap-telefono"
|
|
||||||
type="tel"
|
|
||||||
value={capTelefono}
|
|
||||||
onChange={(e) => setCapTelefono(e.target.value)}
|
|
||||||
className={CLASE_INPUT}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="cap-email" className={CLASE_LABEL}>
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="cap-email"
|
|
||||||
type="email"
|
|
||||||
value={capEmail}
|
|
||||||
onChange={(e) => setCapEmail(e.target.value)}
|
|
||||||
className={CLASE_INPUT}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="cap-origen" className={CLASE_LABEL}>
|
|
||||||
Origen
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="cap-origen"
|
|
||||||
value={capOrigen}
|
|
||||||
onChange={(e) => setCapOrigen(e.target.value)}
|
|
||||||
className={CLASE_INPUT}
|
|
||||||
>
|
|
||||||
{ORIGENES_LEAD.map((o) => (
|
|
||||||
<option key={o} value={o}>
|
|
||||||
{o}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="cap-desarrollo" className={CLASE_LABEL}>
|
|
||||||
Desarrollo de interés
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="cap-desarrollo"
|
|
||||||
value={capDesarrollo}
|
|
||||||
onChange={(e) => setCapDesarrollo(e.target.value)}
|
|
||||||
className={CLASE_INPUT}
|
|
||||||
>
|
|
||||||
<option value="">Sin especificar</option>
|
|
||||||
{DESARROLLOS_LEAD.map((d) => (
|
|
||||||
<option key={d} value={d}>
|
|
||||||
{d}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="cap-notas" className={CLASE_LABEL}>
|
|
||||||
Notas
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="cap-notas"
|
|
||||||
type="text"
|
|
||||||
value={capNotas}
|
|
||||||
onChange={(e) => setCapNotas(e.target.value)}
|
|
||||||
className={CLASE_INPUT}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{capMensaje && (
|
|
||||||
<p
|
|
||||||
role="status"
|
|
||||||
className={`rounded-lg border px-4 py-3 text-sm font-medium sm:col-span-2 ${
|
|
||||||
capMensaje.tipo === 'ok'
|
|
||||||
? 'border-green-200 bg-green-50 text-green-700'
|
|
||||||
: 'border-red-200 bg-red-50 text-red-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{capMensaje.texto}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
{resumen.equipo.porVendedor.length === 0 ? (
|
||||||
<div className="sm:col-span-2">
|
<EstadoVacio mensaje="Sin leads asignados en el equipo" />
|
||||||
<button
|
) : (
|
||||||
type="submit"
|
<ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
disabled={capEnviando}
|
{resumen.equipo.porVendedor.map((v) => (
|
||||||
className="rounded-lg bg-accent px-5 py-3 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
|
<li
|
||||||
>
|
key={v.mondayUserId}
|
||||||
{capEnviando ? 'Registrando…' : 'Registrar lead'}
|
className="flex items-center justify-between rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
|
||||||
</button>
|
>
|
||||||
</div>
|
<p className="text-sm">{v.nombre ?? `Monday #${v.mondayUserId}`}</p>
|
||||||
</form>
|
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
|
||||||
</section>
|
{v.leadsAsignados} lead(s)
|
||||||
|
</span>
|
||||||
{esRolConEquipo(rol) && resumen.equipo && (
|
</li>
|
||||||
<section aria-label="Equipo" className="mt-12">
|
))}
|
||||||
<TituloSeccion>Equipo</TituloSeccion>
|
</ul>
|
||||||
<p className="mb-4 text-sm text-[#64748B]">
|
)}
|
||||||
Leads nuevos sin asignar: <strong>{resumen.equipo.leadsNuevosSinAsignar}</strong>
|
</section>
|
||||||
</p>
|
)}
|
||||||
{resumen.equipo.porVendedor.length === 0 ? (
|
</>
|
||||||
<EstadoVacio mensaje="Sin leads asignados en el equipo" />
|
)}
|
||||||
) : (
|
</main>
|
||||||
<ul className="space-y-2">
|
|
||||||
{resumen.equipo.porVendedor.map((v) => (
|
|
||||||
<li
|
|
||||||
key={v.mondayUserId}
|
|
||||||
className="flex items-center justify-between rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
|
|
||||||
>
|
|
||||||
<p className="text-sm">{v.nombre ?? `Monday #${v.mondayUserId}`}</p>
|
|
||||||
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
|
|
||||||
{v.leadsAsignados} lead(s)
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{descartando && (
|
{descartando && (
|
||||||
<ModalDescartar
|
<ModalDescartar
|
||||||
@@ -608,6 +656,6 @@ export default function MiDiaPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user