style: reskin NovaSpark en las 7 apps
Del look editorial (verde #1B4332, esquinas a 0, botones en mayusculas) al sistema NovaSpark: primario azul #2563EB, detalle naranja #F97316, fondo #F8FAFC con tarjetas blancas rounded-xl + shadow-sm, chips rounded-full semanticos, focus ring azul, transiciones 100-200ms. Incluye headers de tabla en los PDFs (cotizador y estado de cuenta).
This commit is contained in:
@@ -17,17 +17,17 @@ export default function AuthGate({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-neutral-900">
|
||||
<div className="border-b border-neutral-200">
|
||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||
<div className="border-b border-[#E5E7EB] bg-white">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
<span className="text-xs font-medium text-[#64748B]">
|
||||
{sesion.usuario.nombre} · {sesion.usuario.rol}
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalPassword(true)}
|
||||
className="text-xs font-bold uppercase tracking-widest text-neutral-500 transition-colors duration-150 hover:text-neutral-900"
|
||||
className="text-xs font-medium text-[#64748B] transition-colors duration-100 ease-out hover:text-[#111827]"
|
||||
>
|
||||
Contraseña
|
||||
</button>
|
||||
@@ -37,7 +37,7 @@ export default function AuthGate({ children }: { children: ReactNode }) {
|
||||
logout()
|
||||
setSesion(null)
|
||||
}}
|
||||
className="text-xs font-bold uppercase tracking-widest text-accent transition-colors duration-150 hover:text-accent-hover"
|
||||
className="text-xs font-medium text-accent transition-colors duration-100 ease-out hover:text-accent-hover"
|
||||
>
|
||||
Salir
|
||||
</button>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { StatusPago } from '../types'
|
||||
|
||||
const CLASES: Record<StatusPago, string> = {
|
||||
PENDIENTE: 'border-neutral-300 text-neutral-500',
|
||||
PAGADO: 'border-green-300 text-green-700',
|
||||
VENCIDO: 'border-red-300 text-red-700',
|
||||
PARCIAL: 'border-amber-300 text-amber-700',
|
||||
PENDIENTE: 'bg-gray-100 text-gray-600',
|
||||
PAGADO: 'bg-green-50 text-green-700',
|
||||
VENCIDO: 'bg-red-50 text-red-700',
|
||||
PARCIAL: 'bg-amber-50 text-amber-700',
|
||||
}
|
||||
|
||||
export default function BadgeStatus({ status }: { status: StatusPago }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block border bg-white px-2 py-0.5 text-xs font-bold uppercase tracking-wider ${CLASES[status]}`}
|
||||
className={`inline-block rounded-full px-2.5 py-0.5 text-xs font-medium ${CLASES[status]}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
|
||||
@@ -3,8 +3,8 @@ import { validarConfirmacion, validarPasswordNueva } from '../lib/password'
|
||||
import { cambiarPassword } from '../services/auth'
|
||||
|
||||
const CLASE_INPUT =
|
||||
'w-full border border-neutral-300 px-4 py-3 text-sm focus:border-accent focus:outline-none'
|
||||
const CLASE_LABEL = 'mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500'
|
||||
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
|
||||
const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563]'
|
||||
|
||||
/**
|
||||
* Modal "Cambiar contraseña": actual + nueva + confirmar. Valida en cliente
|
||||
@@ -45,19 +45,19 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
|
||||
aria-modal="true"
|
||||
aria-label="Cambiar contraseña"
|
||||
>
|
||||
<div className="w-full max-w-sm border border-neutral-200 bg-white p-6">
|
||||
<h2 className="mb-6 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-xl">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
Cambiar contraseña
|
||||
</h2>
|
||||
{exito ? (
|
||||
<>
|
||||
<p className="border border-accent px-4 py-3 text-xs font-bold uppercase tracking-widest text-accent">
|
||||
<p className="rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700">
|
||||
Contraseña actualizada
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCerrar}
|
||||
className="mt-6 w-full bg-accent px-5 py-3 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:bg-accent-hover"
|
||||
className="mt-6 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"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
@@ -111,7 +111,7 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="border border-red-700 px-4 py-3 text-xs font-bold uppercase tracking-widest text-red-700"
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
@@ -120,14 +120,14 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
|
||||
<button
|
||||
type="submit"
|
||||
disabled={cargando}
|
||||
className="flex-1 bg-accent px-5 py-3 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:bg-accent-hover disabled:opacity-50"
|
||||
className="flex-1 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"
|
||||
>
|
||||
{cargando ? 'Guardando…' : 'Guardar'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCerrar}
|
||||
className="border border-neutral-300 px-5 py-3 text-xs font-bold uppercase tracking-widest text-neutral-500 transition-colors duration-150 hover:border-neutral-500"
|
||||
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]"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
@@ -2,10 +2,10 @@ import type { ReactNode } from 'react'
|
||||
|
||||
export default function KpiCard({ titulo, valor, detalle }: { titulo: string; valor: ReactNode; detalle?: string }) {
|
||||
return (
|
||||
<div className="border border-neutral-200 bg-white p-6">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">{titulo}</p>
|
||||
<p className="mt-2 text-3xl font-light text-neutral-900">{valor}</p>
|
||||
{detalle && <p className="mt-2 text-xs text-neutral-500">{detalle}</p>}
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-md">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</p>
|
||||
<p className="mt-2 text-3xl font-light text-[#111827]">{valor}</p>
|
||||
{detalle && <p className="mt-2 text-xs text-[#64748B]">{detalle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,36 +3,37 @@ import { Link } from 'react-router-dom'
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-neutral-900">
|
||||
<header className="border-b border-neutral-200">
|
||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||
<header className="border-b border-[#E5E7EB] bg-white">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
|
||||
<Link to="/" className="text-sm tracking-widest">
|
||||
<span className="font-bold">FRASA</span>{' '}
|
||||
<span className="font-light text-neutral-500">COBRANZA</span>
|
||||
<span className="font-bold">FRASA</span>
|
||||
<span className="text-[#F97316]">.</span>{' '}
|
||||
<span className="font-light text-[#64748B]">COBRANZA</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-neutral-500 transition-colors duration-150 hover:text-neutral-900"
|
||||
className="text-[#64748B] transition-colors duration-150 hover:text-[#111827]"
|
||||
>
|
||||
Cobranza
|
||||
</Link>
|
||||
<Link
|
||||
to="/hoy"
|
||||
className="border border-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-accent transition-colors duration-150 hover:bg-accent hover:text-white"
|
||||
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"
|
||||
>
|
||||
Cobranza del día
|
||||
</Link>
|
||||
<Link
|
||||
to="/nuevo"
|
||||
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90"
|
||||
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"
|
||||
>
|
||||
Nuevo plan
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-6xl px-4 py-16">{children}</main>
|
||||
<main className="mx-auto max-w-6xl px-4 py-10">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,11 +22,13 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-white px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#F8FAFC] px-4">
|
||||
<div className="w-full max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm">
|
||||
<div className="mb-10 text-center">
|
||||
<p className="text-lg font-bold tracking-widest">FRASA</p>
|
||||
<p className="mt-2 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
<p className="text-lg font-bold tracking-widest">
|
||||
FRASA<span className="text-[#F97316]">.</span>
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
Acceso al ecosistema
|
||||
</p>
|
||||
</div>
|
||||
@@ -34,7 +36,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
<div>
|
||||
<label
|
||||
htmlFor="login-usuario"
|
||||
className="mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563]"
|
||||
>
|
||||
Usuario
|
||||
</label>
|
||||
@@ -46,13 +48,13 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
className="w-full border border-neutral-300 px-4 py-3 text-sm focus:border-accent focus:outline-none"
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="login-password"
|
||||
className="mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563]"
|
||||
>
|
||||
Contraseña
|
||||
</label>
|
||||
@@ -63,13 +65,13 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="w-full border border-neutral-300 px-4 py-3 text-sm focus:border-accent focus:outline-none"
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="border border-red-700 px-4 py-3 text-xs font-bold uppercase tracking-widest text-red-700"
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
@@ -77,7 +79,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
<button
|
||||
type="submit"
|
||||
disabled={cargando}
|
||||
className="w-full bg-accent px-5 py-3 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:bg-accent-hover disabled:opacity-50"
|
||||
className="w-full rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{cargando ? 'Entrando…' : 'Entrar'}
|
||||
</button>
|
||||
|
||||
@@ -25,7 +25,7 @@ interface Props {
|
||||
onGuardar: (datos: AbonoForm) => void
|
||||
}
|
||||
|
||||
const claseLabel = 'block text-xs font-bold uppercase tracking-widest text-neutral-500'
|
||||
const claseLabel = 'block text-xs font-medium text-[#4B5563]'
|
||||
|
||||
/** Modal para registrar un abono (total o parcial) a un pago del plan */
|
||||
export default function ModalAbono({ pago, onCancelar, onGuardar }: Props) {
|
||||
@@ -62,13 +62,13 @@ export default function ModalAbono({ pago, onCancelar, onGuardar }: Props) {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="titulo-abono"
|
||||
className="w-full max-w-md border border-neutral-900 bg-white p-8"
|
||||
className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-8 shadow-xl"
|
||||
>
|
||||
<h2 id="titulo-abono" className="text-xl font-light text-neutral-900">
|
||||
<h2 id="titulo-abono" className="text-xl font-light text-[#111827]">
|
||||
Registrar abono — Pago #{pago.numPago}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
Restante: <span className="font-bold text-neutral-900">{formatoMoneda(restante)}</span>
|
||||
<p className="mt-2 text-sm text-[#64748B]">
|
||||
Restante: <span className="font-bold text-[#111827]">{formatoMoneda(restante)}</span>
|
||||
</p>
|
||||
<form
|
||||
className="mt-8 space-y-6"
|
||||
@@ -84,7 +84,7 @@ export default function ModalAbono({ pago, onCancelar, onGuardar }: Props) {
|
||||
id="abono-monto"
|
||||
type="number"
|
||||
step="0.01"
|
||||
className={`mt-1 w-full border bg-white px-3 py-2.5 text-sm focus:border-neutral-900 focus:outline-none ${errors.monto ? 'border-red-400' : 'border-neutral-300'}`}
|
||||
className={`mt-1 w-full rounded-lg border bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 ${errors.monto ? 'border-red-400' : 'border-[#D1D5DB]'}`}
|
||||
{...regMonto}
|
||||
ref={(el) => {
|
||||
regMonto.ref(el)
|
||||
@@ -100,7 +100,7 @@ export default function ModalAbono({ pago, onCancelar, onGuardar }: Props) {
|
||||
<input
|
||||
id="abono-fecha"
|
||||
type="date"
|
||||
className={`mt-1 w-full border bg-white px-3 py-2.5 text-sm focus:border-neutral-900 focus:outline-none ${errors.fechaPago ? 'border-red-400' : 'border-neutral-300'}`}
|
||||
className={`mt-1 w-full rounded-lg border bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 ${errors.fechaPago ? 'border-red-400' : 'border-[#D1D5DB]'}`}
|
||||
{...register('fechaPago')}
|
||||
/>
|
||||
{errors.fechaPago && <p className="mt-1 text-xs text-red-700">{errors.fechaPago.message}</p>}
|
||||
@@ -112,7 +112,7 @@ export default function ModalAbono({ pago, onCancelar, onGuardar }: Props) {
|
||||
<input
|
||||
id="abono-referencia"
|
||||
type="text"
|
||||
className={`mt-1 w-full border bg-white px-3 py-2.5 text-sm focus:border-neutral-900 focus:outline-none ${errors.referencia ? 'border-red-400' : 'border-neutral-300'}`}
|
||||
className={`mt-1 w-full rounded-lg border bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 ${errors.referencia ? 'border-red-400' : 'border-[#D1D5DB]'}`}
|
||||
placeholder="SPEI-000123"
|
||||
{...register('referencia')}
|
||||
/>
|
||||
@@ -123,13 +123,13 @@ export default function ModalAbono({ pago, onCancelar, onGuardar }: Props) {
|
||||
type="button"
|
||||
onClick={onCancelar}
|
||||
aria-label="Cancelar y cerrar el registro de abono"
|
||||
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
|
||||
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]"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90 disabled:opacity-40"
|
||||
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:opacity-40"
|
||||
>
|
||||
Guardar abono
|
||||
</button>
|
||||
|
||||
@@ -11,10 +11,10 @@ const ETIQUETA: Record<Semaforo, string> = {
|
||||
}
|
||||
|
||||
const CLASE_DOT: Record<Semaforo, string> = {
|
||||
VERDE: 'border-green-300 bg-white text-green-700',
|
||||
AMBAR: 'border-amber-300 bg-white text-amber-700',
|
||||
ROJO: 'border-red-300 bg-white text-red-700',
|
||||
GRIS: 'border-neutral-300 bg-white text-neutral-500',
|
||||
VERDE: 'bg-green-50 text-green-700',
|
||||
AMBAR: 'bg-amber-50 text-amber-700',
|
||||
ROJO: 'bg-red-50 text-red-700',
|
||||
GRIS: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,7 +37,7 @@ export function semaforoPlan(plan: PlanPago, hoy: Date = new Date()): Semaforo {
|
||||
export default function SemaforoDot({ valor }: { valor: Semaforo }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block border px-2 py-0.5 text-xs font-bold uppercase tracking-wider ${CLASE_DOT[valor]}`}
|
||||
className={`inline-block rounded-full px-2.5 py-0.5 text-xs font-medium ${CLASE_DOT[valor]}`}
|
||||
>
|
||||
{ETIQUETA[valor]}
|
||||
</span>
|
||||
|
||||
@@ -2,22 +2,17 @@
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--color-accent: #1B4332;
|
||||
--radius-sm: 0;
|
||||
--radius-md: 0;
|
||||
--radius-lg: 0;
|
||||
--radius-xl: 0;
|
||||
--radius-2xl: 0;
|
||||
--radius-3xl: 0;
|
||||
--color-accent: #2563EB;
|
||||
--color-accent-hover: #1D4ED8;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-white text-neutral-900 antialiased;
|
||||
@apply bg-[#F8FAFC] text-[#111827] antialiased;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 2px solid #1B4332;
|
||||
outline-offset: 2px;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function crearDocumento(plan: PlanPago, hoy: Date = new Date()): Document
|
||||
formatoMoneda(plan.mensualidad),
|
||||
String(plan.numPagos),
|
||||
]],
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
headStyles: { fillColor: [37, 99, 235] },
|
||||
})
|
||||
y = finalY(doc) + 6
|
||||
|
||||
@@ -69,7 +69,7 @@ export function crearDocumento(plan: PlanPago, hoy: Date = new Date()): Document
|
||||
.map((a) => `${a.referencia} (${formatoFechaCorta(a.fechaPago)})`)
|
||||
.join('\n') || '—',
|
||||
]),
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
headStyles: { fillColor: [37, 99, 235] },
|
||||
})
|
||||
y = finalY(doc) + 6
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import KpiCard from '../components/KpiCard'
|
||||
import SemaforoDot, { semaforoPlan } from '../components/Semaforo'
|
||||
|
||||
const MESES_CORTOS = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic']
|
||||
const COLORES_PIE = ['#1B4332', '#d4d4d4']
|
||||
const COLORES_PIE = ['#2563EB', '#CBD5E1']
|
||||
|
||||
type ColumnaOrden = 'saldo' | 'vencimiento' | 'atraso' | 'avance'
|
||||
const COLUMNAS_ORDEN: ColumnaOrden[] = ['saldo', 'vencimiento', 'atraso', 'avance']
|
||||
@@ -160,9 +160,9 @@ export default function DashboardPage() {
|
||||
|
||||
if (!resultado) {
|
||||
return errorCarga ? (
|
||||
<div className="border border-red-300 bg-white px-4 py-3 text-sm text-red-700">{errorCarga}</div>
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{errorCarga}</div>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-400">Cargando...</p>
|
||||
<p className="text-sm text-[#64748B]">Cargando...</p>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -200,12 +200,12 @@ export default function DashboardPage() {
|
||||
})()
|
||||
|
||||
const encabezadoOrdenable = (columna: ColumnaOrden, titulo: string, alineadoDerecha = false) => (
|
||||
<th scope="col" className={`py-3 pr-4 ${alineadoDerecha ? 'text-right' : ''}`}>
|
||||
<th scope="col" className={`px-4 py-3 ${alineadoDerecha ? 'text-right' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => alternarOrden(columna)}
|
||||
aria-label={`Ordenar por ${titulo}`}
|
||||
className="inline-flex items-center gap-1 font-bold uppercase tracking-widest transition-colors duration-150 hover:text-neutral-900"
|
||||
className="inline-flex items-center gap-1 font-semibold uppercase tracking-wider transition-colors duration-100 ease-out hover:text-[#111827]"
|
||||
>
|
||||
{titulo}
|
||||
{orden === columna && <span aria-hidden="true">{dir === 'asc' ? '↑' : '↓'}</span>}
|
||||
@@ -214,12 +214,12 @@ export default function DashboardPage() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-light text-neutral-900">Módulo Cobranza — Planes de enganche</h1>
|
||||
<h1 className="text-3xl font-light text-[#111827]">Módulo Cobranza — Planes de enganche</h1>
|
||||
<Link
|
||||
to="/nuevo"
|
||||
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90"
|
||||
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"
|
||||
>
|
||||
Nuevo plan
|
||||
</Link>
|
||||
@@ -248,8 +248,8 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Gráficas */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="border border-neutral-200 bg-white p-6">
|
||||
<h2 className="mb-6 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
Al corriente vs vencidos
|
||||
</h2>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
@@ -272,21 +272,21 @@ export default function DashboardPage() {
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="border border-neutral-200 bg-white p-6">
|
||||
<h2 className="mb-6 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
Cobrado por mes (últimos 6 meses)
|
||||
</h2>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={datosBarras}>
|
||||
<CartesianGrid stroke="#e5e5e5" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="mes" tickLine={false} axisLine={{ stroke: '#e5e5e5' }} />
|
||||
<CartesianGrid stroke="#E5E7EB" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="mes" tickLine={false} axisLine={{ stroke: '#E5E7EB' }} />
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v: number) => `$${(v / 1000).toLocaleString('es-MX')}k`}
|
||||
/>
|
||||
<Tooltip formatter={(v) => formatoMoneda(Number(v))} cursor={{ fill: '#fafafa' }} />
|
||||
<Bar dataKey="cobrado" fill="#1B4332" name="Cobrado" />
|
||||
<Tooltip formatter={(v) => formatoMoneda(Number(v))} cursor={{ fill: '#F8FAFC' }} />
|
||||
<Bar dataKey="cobrado" fill="#2563EB" name="Cobrado" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -295,13 +295,13 @@ export default function DashboardPage() {
|
||||
{/* Filtros */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/* Toggle Mis planes / Todos (?mios=1 filtra en el API por el vendedor del token) */}
|
||||
<div className="flex border border-neutral-300" 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
|
||||
type="button"
|
||||
onClick={() => actualizarParams({ vista: mios ? null : 'mios' })}
|
||||
aria-pressed={mios}
|
||||
className={`px-4 py-2.5 text-xs font-bold uppercase tracking-widest transition-colors duration-150 ${
|
||||
mios ? 'bg-accent text-white' : 'bg-white text-neutral-600 hover:text-neutral-900'
|
||||
className={`rounded-l-lg px-4 py-2.5 text-sm font-medium transition-all duration-100 ease-out ${
|
||||
mios ? 'bg-accent text-white shadow-sm' : 'bg-white text-[#64748B] hover:text-[#111827]'
|
||||
}`}
|
||||
>
|
||||
Mis planes
|
||||
@@ -310,8 +310,8 @@ export default function DashboardPage() {
|
||||
type="button"
|
||||
onClick={() => actualizarParams({ vista: mios ? 'todos' : null })}
|
||||
aria-pressed={!mios}
|
||||
className={`px-4 py-2.5 text-xs font-bold uppercase tracking-widest transition-colors duration-150 ${
|
||||
mios ? 'bg-white text-neutral-600 hover:text-neutral-900' : 'bg-accent text-white'
|
||||
className={`rounded-r-lg px-4 py-2.5 text-sm font-medium transition-all duration-100 ease-out ${
|
||||
mios ? 'bg-white text-[#64748B] hover:text-[#111827]' : 'bg-accent text-white shadow-sm'
|
||||
}`}
|
||||
>
|
||||
Todos
|
||||
@@ -326,7 +326,7 @@ export default function DashboardPage() {
|
||||
value={busqueda}
|
||||
onChange={(e) => actualizarParams({ q: e.target.value })}
|
||||
placeholder="Buscar por cliente o unidad…"
|
||||
className="w-64 border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
|
||||
className="w-64 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<label htmlFor="filtro-status" className="sr-only">
|
||||
Filtrar por status
|
||||
@@ -335,7 +335,7 @@ export default function DashboardPage() {
|
||||
id="filtro-status"
|
||||
value={filtroStatus}
|
||||
onChange={(e) => actualizarParams({ status: e.target.value === 'TODOS' ? null : e.target.value })}
|
||||
className="border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
>
|
||||
<option value="TODOS">Todos los status</option>
|
||||
<option value="ACTIVO">ACTIVO</option>
|
||||
@@ -349,7 +349,7 @@ export default function DashboardPage() {
|
||||
id="filtro-proyecto"
|
||||
value={filtroProyecto}
|
||||
onChange={(e) => actualizarParams({ proyecto: e.target.value === 'TODOS' ? null : e.target.value })}
|
||||
className="border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
>
|
||||
<option value="TODOS">Todos los proyectos</option>
|
||||
{proyectos.map((p) => (
|
||||
@@ -361,49 +361,49 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Tabla de planes */}
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-900 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
<th scope="col" className="py-3 pr-4">Cliente</th>
|
||||
<th scope="col" className="py-3 pr-4">Unidad</th>
|
||||
<th scope="col" className="py-3 pr-4">Proyecto</th>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<th scope="col" className="px-4 py-3">Cliente</th>
|
||||
<th scope="col" className="px-4 py-3">Unidad</th>
|
||||
<th scope="col" className="px-4 py-3">Proyecto</th>
|
||||
{encabezadoOrdenable('avance', '% pagado')}
|
||||
{encabezadoOrdenable('vencimiento', 'Próx. vencimiento')}
|
||||
{encabezadoOrdenable('saldo', 'Saldo', true)}
|
||||
{encabezadoOrdenable('atraso', 'Días atraso', true)}
|
||||
<th scope="col" className="py-3">Estado</th>
|
||||
<th scope="col" className="px-4 py-3">Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="divide-y divide-[#E5E7EB]">
|
||||
{ordenados.map((d) => (
|
||||
<tr key={d.plan.id} className="border-b border-neutral-200 transition-colors duration-150 hover:bg-neutral-50">
|
||||
<td className="py-3 pr-4">
|
||||
<tr key={d.plan.id} className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC]">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/planes/${d.plan.id}`} className="font-medium text-accent hover:underline">
|
||||
{d.plan.clienteNombre}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 pr-4">{d.plan.unidadCodigo}</td>
|
||||
<td className="py-3 pr-4">{d.plan.proyecto}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<td className="px-4 py-3">{d.plan.unidadCodigo}</td>
|
||||
<td className="px-4 py-3">{d.plan.proyecto}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1 w-24 bg-neutral-200">
|
||||
<div className="h-1.5 w-24 rounded-full bg-[#E5E7EB]">
|
||||
<div
|
||||
className="h-full bg-accent"
|
||||
className="h-full rounded-full bg-accent"
|
||||
style={{ width: `${Math.min(100, d.avance * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-500">{formatoPorcentaje(d.avance)}</span>
|
||||
<span className="text-xs text-[#64748B]">{formatoPorcentaje(d.avance)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<td className="px-4 py-3">
|
||||
{d.proximo ? formatoFechaCorta(d.proximo.fechaVencimiento) : '—'}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-right font-medium">{formatoMoneda(d.saldo)}</td>
|
||||
<td className="py-3 pr-4 text-right">
|
||||
<td className="px-4 py-3 text-right font-medium">{formatoMoneda(d.saldo)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{d.atraso > 0 ? <span className="text-red-700">{d.atraso}</span> : '0'}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<td className="px-4 py-3">
|
||||
<SemaforoDot valor={d.semaforo} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -36,17 +36,17 @@ export default function HoyPage() {
|
||||
|
||||
if (!resultado) {
|
||||
return errorCarga ? (
|
||||
<div className="border border-red-300 bg-white px-4 py-3 text-sm text-red-700">{errorCarga}</div>
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{errorCarga}</div>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-400">Cargando...</p>
|
||||
<p className="text-sm text-[#64748B]">Cargando...</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-light text-neutral-900">Cobranza del día</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
<h1 className="text-3xl font-light text-[#111827]">Cobranza del día</h1>
|
||||
<p className="mt-1 text-sm text-[#64748B]">
|
||||
Pagos vencidos y vencimientos de los próximos 7 días, en todos los planes activos.
|
||||
</p>
|
||||
{resultado.origen === 'local' && (
|
||||
@@ -62,29 +62,29 @@ export default function HoyPage() {
|
||||
<KpiCard titulo="Monto por cobrar" valor={formatoMoneda(totalPorCobrar)} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-900 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
<th scope="col" className="py-3 pr-4">Cliente</th>
|
||||
<th scope="col" className="py-3 pr-4">Teléfono</th>
|
||||
<th scope="col" className="py-3 pr-4">Unidad</th>
|
||||
<th scope="col" className="py-3 pr-4"># pago</th>
|
||||
<th scope="col" className="py-3 pr-4">Vencimiento</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Monto</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Restante</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Días atraso</th>
|
||||
<th scope="col" className="py-3">Plan</th>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<th scope="col" className="px-4 py-3">Cliente</th>
|
||||
<th scope="col" className="px-4 py-3">Teléfono</th>
|
||||
<th scope="col" className="px-4 py-3">Unidad</th>
|
||||
<th scope="col" className="px-4 py-3"># pago</th>
|
||||
<th scope="col" className="px-4 py-3">Vencimiento</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Monto</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Restante</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Días atraso</th>
|
||||
<th scope="col" className="px-4 py-3">Plan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="divide-y divide-[#E5E7EB]">
|
||||
{items.map(({ plan, pago, atraso }) => (
|
||||
<tr
|
||||
key={`${plan.id}-${pago.id}`}
|
||||
className="border-b border-neutral-200 transition-colors duration-150 hover:bg-neutral-50"
|
||||
className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC]"
|
||||
>
|
||||
<td className="py-3 pr-4 font-medium">{plan.clienteNombre}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<td className="px-4 py-3 font-medium">{plan.clienteNombre}</td>
|
||||
<td className="px-4 py-3">
|
||||
{plan.clienteTelefono ? (
|
||||
<a href={`tel:${plan.clienteTelefono.replace(/\s/g, '')}`} className="text-accent hover:underline">
|
||||
{plan.clienteTelefono}
|
||||
@@ -93,14 +93,14 @@ export default function HoyPage() {
|
||||
<span className="text-neutral-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 pr-4">{plan.unidadCodigo}</td>
|
||||
<td className="py-3 pr-4">{pago.numPago}</td>
|
||||
<td className="py-3 pr-4">{formatoFechaCorta(pago.fechaVencimiento)}</td>
|
||||
<td className="py-3 pr-4 text-right">{formatoMoneda(pago.monto)}</td>
|
||||
<td className="py-3 pr-4 text-right font-medium">
|
||||
<td className="px-4 py-3">{plan.unidadCodigo}</td>
|
||||
<td className="px-4 py-3">{pago.numPago}</td>
|
||||
<td className="px-4 py-3">{formatoFechaCorta(pago.fechaVencimiento)}</td>
|
||||
<td className="px-4 py-3 text-right">{formatoMoneda(pago.monto)}</td>
|
||||
<td className="px-4 py-3 text-right font-medium">
|
||||
{formatoMoneda(round2(pago.monto - pago.montoPagado))}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-right">
|
||||
<td className="px-4 py-3 text-right">
|
||||
{atraso > 0 ? (
|
||||
<span className="font-medium text-red-700">{atraso}</span>
|
||||
) : (
|
||||
@@ -109,7 +109,7 @@ export default function HoyPage() {
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/planes/${plan.id}`} className="text-accent hover:underline">
|
||||
Ver plan
|
||||
</Link>
|
||||
|
||||
@@ -37,9 +37,9 @@ const esquema = z.object({
|
||||
type NuevoPlanForm = z.infer<typeof esquema>
|
||||
|
||||
const claseInput =
|
||||
'mt-1 w-full border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none'
|
||||
'mt-1 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
|
||||
const claseError = 'mt-1 text-xs text-red-700'
|
||||
const claseLabel = 'block text-xs font-bold uppercase tracking-widest text-neutral-500'
|
||||
const claseLabel = 'block text-xs font-medium text-[#4B5563]'
|
||||
|
||||
export default function NuevoPlanPage() {
|
||||
const navigate = useNavigate()
|
||||
@@ -88,11 +88,11 @@ export default function NuevoPlanPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-16">
|
||||
<h1 className="text-3xl font-light text-neutral-900">Nuevo plan de enganche</h1>
|
||||
<div className="mx-auto max-w-3xl space-y-8">
|
||||
<h1 className="text-3xl font-light text-[#111827]">Nuevo plan de enganche</h1>
|
||||
|
||||
<form
|
||||
className="border border-neutral-200 bg-white p-8"
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm"
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(guardar)(e)
|
||||
}}
|
||||
@@ -176,14 +176,14 @@ export default function NuevoPlanPage() {
|
||||
|
||||
{/* Vista previa del calendario */}
|
||||
{vistaPrevia && (
|
||||
<div className="mt-8 border border-neutral-200 bg-white p-6">
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
<div className="mt-8 rounded-xl border border-[#E5E7EB] bg-[#F8FAFC] p-6">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
Vista previa — {vistaPrevia.pagos.length} pagos de {formatoMoneda(vistaPrevia.mensualidad)}
|
||||
</h2>
|
||||
<div className="mt-4 max-h-48 overflow-y-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="text-neutral-500">
|
||||
<tr className="border-b border-neutral-900">
|
||||
<thead className="text-[#64748B]">
|
||||
<tr className="border-b border-[#D1D5DB]">
|
||||
<th scope="col" className="py-1">#</th>
|
||||
<th scope="col" className="py-1">Vencimiento</th>
|
||||
<th scope="col" className="py-1 text-right">Monto</th>
|
||||
@@ -191,7 +191,7 @@ export default function NuevoPlanPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{vistaPrevia.pagos.map((p) => (
|
||||
<tr key={p.numPago} className="border-b border-neutral-200">
|
||||
<tr key={p.numPago} className="border-b border-[#E5E7EB]">
|
||||
<td className="py-1">{p.numPago}</td>
|
||||
<td className="py-1">{formatoFechaCorta(p.fechaVencimiento)}</td>
|
||||
<td className="py-1 text-right">{formatoMoneda(p.monto)}</td>
|
||||
@@ -208,14 +208,14 @@ export default function NuevoPlanPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate('/')}
|
||||
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
|
||||
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]"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={guardando}
|
||||
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90 disabled:opacity-40"
|
||||
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:opacity-40"
|
||||
>
|
||||
{guardando ? 'Guardando…' : 'Guardar plan'}
|
||||
</button>
|
||||
|
||||
@@ -63,17 +63,17 @@ export default function PlanDetallePage() {
|
||||
}, [id])
|
||||
|
||||
if (cargando) {
|
||||
return <p className="text-sm text-neutral-400">Cargando...</p>
|
||||
return <p className="text-sm text-[#64748B]">Cargando...</p>
|
||||
}
|
||||
|
||||
if (errorCarga) {
|
||||
return <div className="border border-red-300 bg-white px-4 py-3 text-sm text-red-700">{errorCarga}</div>
|
||||
return <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{errorCarga}</div>
|
||||
}
|
||||
|
||||
if (!plan) {
|
||||
return (
|
||||
<div className="border border-neutral-200 bg-white p-16 text-center">
|
||||
<p className="text-neutral-500">Plan no encontrado.</p>
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-16 text-center shadow-sm">
|
||||
<p className="text-[#64748B]">Plan no encontrado.</p>
|
||||
<Link to="/" className="mt-4 inline-block text-accent hover:underline">
|
||||
Volver al dashboard
|
||||
</Link>
|
||||
@@ -134,19 +134,19 @@ export default function PlanDetallePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-wrap items-start justify-between gap-6">
|
||||
<div>
|
||||
<Link to="/" className="text-sm text-accent hover:underline">
|
||||
← Volver al dashboard
|
||||
</Link>
|
||||
<h1 className="mt-2 text-3xl font-light text-neutral-900">{plan.clienteNombre}</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
<h1 className="mt-2 text-3xl font-light text-[#111827]">{plan.clienteNombre}</h1>
|
||||
<p className="mt-1 text-sm text-[#64748B]">
|
||||
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
|
||||
<span className="font-bold text-neutral-900">{status}</span>
|
||||
<span className="font-semibold text-[#111827]">{status}</span>
|
||||
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
<p className="mt-1 text-sm text-[#64748B]">
|
||||
Tel. {plan.clienteTelefono || '—'}
|
||||
{tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>}
|
||||
</p>
|
||||
@@ -154,14 +154,14 @@ export default function PlanDetallePage() {
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => descargar(plan)}
|
||||
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
|
||||
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="border border-red-300 bg-white px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-red-700 transition-colors duration-150 hover:bg-red-700 hover:text-white"
|
||||
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>
|
||||
@@ -176,7 +176,7 @@ export default function PlanDetallePage() {
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="border border-red-300 bg-white 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 */}
|
||||
@@ -196,22 +196,22 @@ export default function PlanDetallePage() {
|
||||
</div>
|
||||
|
||||
{/* Tabla de pagos */}
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-900 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
<th scope="col" className="py-3 pr-4">#</th>
|
||||
<th scope="col" className="py-3 pr-4">Vencimiento</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Monto</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Pagado</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Restante</th>
|
||||
{tasaMora > 0 && <th scope="col" className="py-3 pr-4 text-right">Mora</th>}
|
||||
<th scope="col" className="py-3 pr-4">Status</th>
|
||||
<th scope="col" className="py-3 pr-4">Abonos</th>
|
||||
<th scope="col" className="py-3" />
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<th scope="col" className="px-4 py-3">#</th>
|
||||
<th scope="col" className="px-4 py-3">Vencimiento</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Monto</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Pagado</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Restante</th>
|
||||
{tasaMora > 0 && <th scope="col" className="px-4 py-3 text-right">Mora</th>}
|
||||
<th scope="col" className="px-4 py-3">Status</th>
|
||||
<th scope="col" className="px-4 py-3">Abonos</th>
|
||||
<th scope="col" className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="divide-y divide-[#E5E7EB]">
|
||||
{plan.pagos.map((pago) => {
|
||||
const st = statusPago(pago, hoy)
|
||||
const restante = round2(pago.monto - pago.montoPagado)
|
||||
@@ -219,14 +219,14 @@ export default function PlanDetallePage() {
|
||||
const expandido = expandidos.has(pago.id)
|
||||
return (
|
||||
<Fragment key={pago.id}>
|
||||
<tr className="border-b border-neutral-200 transition-colors duration-150 hover:bg-neutral-50">
|
||||
<td className="py-3 pr-4 font-medium">{pago.numPago}</td>
|
||||
<td className="py-3 pr-4">{formatoFechaCorta(pago.fechaVencimiento)}</td>
|
||||
<td className="py-3 pr-4 text-right">{formatoMoneda(pago.monto)}</td>
|
||||
<td className="py-3 pr-4 text-right">{formatoMoneda(pago.montoPagado)}</td>
|
||||
<td className="py-3 pr-4 text-right font-medium">{formatoMoneda(restante)}</td>
|
||||
<tr className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC]">
|
||||
<td className="px-4 py-3 font-medium">{pago.numPago}</td>
|
||||
<td className="px-4 py-3">{formatoFechaCorta(pago.fechaVencimiento)}</td>
|
||||
<td className="px-4 py-3 text-right">{formatoMoneda(pago.monto)}</td>
|
||||
<td className="px-4 py-3 text-right">{formatoMoneda(pago.montoPagado)}</td>
|
||||
<td className="px-4 py-3 text-right font-medium">{formatoMoneda(restante)}</td>
|
||||
{tasaMora > 0 && (
|
||||
<td className="py-3 pr-4 text-right">
|
||||
<td className="px-4 py-3 text-right">
|
||||
{moraPago > 0 ? (
|
||||
<span className="text-red-700">{formatoMoneda(moraPago)}</span>
|
||||
) : (
|
||||
@@ -234,10 +234,10 @@ export default function PlanDetallePage() {
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="py-3 pr-4">
|
||||
<td className="px-4 py-3">
|
||||
<BadgeStatus status={st} />
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<td className="px-4 py-3">
|
||||
{pago.aplicaciones.length > 0 ? (
|
||||
<button
|
||||
onClick={() => toggleExpandido(pago.id)}
|
||||
@@ -249,11 +249,11 @@ export default function PlanDetallePage() {
|
||||
<span className="text-neutral-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 text-right">
|
||||
<td className="px-4 py-3 text-right">
|
||||
{st !== 'PAGADO' && status === 'ACTIVO' && (
|
||||
<button
|
||||
onClick={() => setPagoAbono(pago)}
|
||||
className="bg-accent px-4 py-2 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90"
|
||||
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"
|
||||
>
|
||||
Registrar abono
|
||||
</button>
|
||||
@@ -261,11 +261,11 @@ export default function PlanDetallePage() {
|
||||
</td>
|
||||
</tr>
|
||||
{expandido && (
|
||||
<tr className="border-b border-neutral-200 bg-neutral-50">
|
||||
<tr className="bg-[#F8FAFC]">
|
||||
<td colSpan={tasaMora > 0 ? 9 : 8} className="px-4 py-4">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left font-bold uppercase tracking-widest text-neutral-500">
|
||||
<tr className="text-left font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<th scope="col" className="py-1">Fecha</th>
|
||||
<th scope="col" className="py-1 text-right">Monto</th>
|
||||
<th scope="col" className="py-1">Referencia</th>
|
||||
@@ -274,7 +274,7 @@ export default function PlanDetallePage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{pago.aplicaciones.map((a) => (
|
||||
<tr key={a.id} className="border-t border-neutral-200">
|
||||
<tr key={a.id} className="border-t border-[#E5E7EB]">
|
||||
<td className="py-1">{formatoFechaCorta(a.fechaPago)}</td>
|
||||
<td className="py-1 text-right">{formatoMoneda(a.monto)}</td>
|
||||
<td className="py-1">{a.referencia}</td>
|
||||
@@ -302,19 +302,19 @@ export default function PlanDetallePage() {
|
||||
</div>
|
||||
|
||||
{/* Bitácora del plan */}
|
||||
<section className="border border-neutral-200 bg-white p-8" aria-labelledby="titulo-bitacora">
|
||||
<h2 id="titulo-bitacora" className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
<section className="rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm" aria-labelledby="titulo-bitacora">
|
||||
<h2 id="titulo-bitacora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
Bitácora
|
||||
</h2>
|
||||
{(plan.notas ?? []).length === 0 ? (
|
||||
<p className="mt-6 text-sm text-neutral-500">Sin notas registradas.</p>
|
||||
<p className="mt-6 text-sm text-[#64748B]">Sin notas registradas.</p>
|
||||
) : (
|
||||
<ul className="mt-6 space-y-4">
|
||||
{[...(plan.notas ?? [])]
|
||||
.sort((a, b) => b.fecha.localeCompare(a.fecha))
|
||||
.map((nota) => (
|
||||
<li key={nota.id} className="border-l-2 border-neutral-200 pl-4">
|
||||
<p className="text-xs text-neutral-500">
|
||||
<li key={nota.id} className="border-l-2 border-[#E5E7EB] pl-4">
|
||||
<p className="text-xs text-[#64748B]">
|
||||
{new Date(nota.fecha).toLocaleString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
@@ -323,7 +323,7 @@ export default function PlanDetallePage() {
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-neutral-900">{nota.texto}</p>
|
||||
<p className="mt-1 text-sm text-[#111827]">{nota.texto}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -337,7 +337,7 @@ export default function PlanDetallePage() {
|
||||
>
|
||||
<label
|
||||
htmlFor="nota-texto"
|
||||
className="block text-xs font-bold uppercase tracking-widest text-neutral-500"
|
||||
className="block text-xs font-medium text-[#4B5563]"
|
||||
>
|
||||
Nueva nota
|
||||
</label>
|
||||
@@ -347,14 +347,14 @@ export default function PlanDetallePage() {
|
||||
onChange={(e) => setNotaTexto(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Gestión realizada, acuerdos con el cliente…"
|
||||
className="mt-1 w-full border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
|
||||
className="mt-1 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
{errorNota && <p className="mt-1 text-xs text-red-700">{errorNota}</p>}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={notaTexto.trim().length < 3}
|
||||
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90 disabled:opacity-40"
|
||||
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:opacity-40"
|
||||
>
|
||||
Agregar nota
|
||||
</button>
|
||||
@@ -365,7 +365,7 @@ export default function PlanDetallePage() {
|
||||
{status === 'CANCELADO' && (
|
||||
<button
|
||||
onClick={() => void navigate('/')}
|
||||
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
|
||||
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]"
|
||||
>
|
||||
Volver
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user