feat(mejoras): 14 mejoras al CRM Frasa
Fase 1 - UI: dark mode, skeletons, toasts Fase 2 - Filtros: avanzados Mi día, búsqueda global Ctrl+K Fase 3 - Datos: export Excel, notas apartados, auditoría Fase 4 - Dashboard: gráficas vendedor + métricas uso Fase 5 - Responsive: tablets optimizadas Fase 6 - PWA: IndexedDB offline, push notifications Backend: 3 tablas nuevas, 6 services, 6 endpoints nuevos 721 API tests + fronts verdes.
This commit is contained in:
37
apps/cobranza/package-lock.json
generated
37
apps/cobranza/package-lock.json
generated
@@ -12,9 +12,11 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^3.8.4",
|
||||
"lucide-react": "^1.41.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"recharts": "^2.15.0",
|
||||
"zod": "^3.24.1"
|
||||
@@ -2301,6 +2303,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/goober": {
|
||||
"version": "2.1.19",
|
||||
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.19.tgz",
|
||||
"integrity": "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"csstype": "^3.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -2696,6 +2707,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.41.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.41.0.tgz",
|
||||
"integrity": "sha512-6lksP35l6KszDKUeRTi4LV7i6DEe0Yzl2ALJm9j4c5xEYN91GdW1xGsawGMOg2mgjF5GHBVX8pKX9kP+cWsP3Q==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -2888,6 +2908,23 @@
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hot-toast": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
|
||||
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.1.3",
|
||||
"goober": "^2.1.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16",
|
||||
"react-dom": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^3.8.4",
|
||||
"lucide-react": "^1.41.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"recharts": "^2.15.0",
|
||||
"zod": "^3.24.1"
|
||||
|
||||
@@ -17,17 +17,17 @@ export default function AuthGate({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||
<div className="border-b border-[#E5E7EB] bg-white">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0] dark:bg-[#0F172A] dark:text-[#E2E8F0]">
|
||||
<div className="border-b border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
|
||||
<span className="text-xs font-medium text-[#64748B]">
|
||||
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
|
||||
{sesion.usuario.nombre} · {sesion.usuario.rol}
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalPassword(true)}
|
||||
className="text-xs font-medium text-[#64748B] transition-colors duration-100 ease-out hover:text-[#111827]"
|
||||
className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8] transition-colors duration-100 ease-out hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
Contraseña
|
||||
</button>
|
||||
|
||||
@@ -3,8 +3,8 @@ import { validarConfirmacion, validarPasswordNueva } from '../lib/password'
|
||||
import { cambiarPassword } from '../services/auth'
|
||||
|
||||
const CLASE_INPUT =
|
||||
'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]'
|
||||
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] 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] dark:text-[#CBD5E1]'
|
||||
|
||||
/**
|
||||
* Modal "Cambiar contraseña": actual + nueva + confirmar. Valida en cliente
|
||||
@@ -45,8 +45,8 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
|
||||
aria-modal="true"
|
||||
aria-label="Cambiar contraseña"
|
||||
>
|
||||
<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]">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Cambiar contraseña
|
||||
</h2>
|
||||
{exito ? (
|
||||
@@ -127,7 +127,7 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCerrar}
|
||||
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]"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
32
apps/cobranza/src/components/DarkModeToggle.tsx
Normal file
32
apps/cobranza/src/components/DarkModeToggle.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Sun, Moon } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
function getStoredTheme(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
try {
|
||||
const stored = localStorage.getItem("frasa_theme")
|
||||
if (stored) return stored === "dark"
|
||||
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export default function DarkModeToggle() {
|
||||
const [dark, setDark] = useState(getStoredTheme)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", dark)
|
||||
try {
|
||||
localStorage.setItem("frasa_theme", dark ? "dark" : "light")
|
||||
} catch {
|
||||
// localStorage not available (test env)
|
||||
}
|
||||
}, [dark])
|
||||
|
||||
return (
|
||||
<button onClick={() => setDark(!dark)} className="rounded-lg p-2 text-[#64748B] hover:bg-[#F1F5F9] dark:text-[#94A3B8] dark:hover:bg-[#1E293B]" aria-label="Cambiar tema">
|
||||
{dark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -14,13 +14,13 @@ export default function KpiCard({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border border-[#E5E7EB] bg-white p-6 ${
|
||||
className={`rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] ${
|
||||
plano ? '' : 'shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</p>
|
||||
<p className="mt-2 text-3xl font-light text-[#111827]">{valor}</p>
|
||||
{detalle && <p className="mt-2 text-xs text-[#64748B]">{detalle}</p>}
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
|
||||
<p className="mt-2 text-3xl font-light text-[#111827] dark:text-[#E2E8F0]">{valor}</p>
|
||||
{detalle && <p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">{detalle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import DarkModeToggle from './DarkModeToggle'
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0]">
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">
|
||||
<div className="mx-auto flex h-16 max-w-[1280px] items-center justify-between px-4 sm:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/"
|
||||
className="rounded-lg px-2 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827]"
|
||||
className="rounded-lg px-2 py-1.5 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
← Menú
|
||||
</a>
|
||||
<Link to="/" className="text-sm tracking-widest">
|
||||
<span className="font-bold">FRASA</span>
|
||||
<span className="text-[#F97316]">.</span>{' '}
|
||||
<span className="font-light text-[#64748B]">CLIENTES</span>
|
||||
<span className="font-light text-[#64748B] dark:text-[#94A3B8]">CLIENTES</span>
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex items-center gap-3 text-sm sm:gap-6">
|
||||
<DarkModeToggle />
|
||||
<Link
|
||||
to="/"
|
||||
className="text-[#64748B] transition-colors duration-150 hover:text-[#111827]"
|
||||
className="text-[#64748B] dark:text-[#94A3B8] transition-colors duration-150 hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
Cobranza
|
||||
</Link>
|
||||
|
||||
@@ -23,12 +23,12 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
|
||||
return (
|
||||
<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="w-full max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-8 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<div className="mb-10 text-center">
|
||||
<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]">
|
||||
<p className="mt-2 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Acceso al ecosistema
|
||||
</p>
|
||||
</div>
|
||||
@@ -36,7 +36,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
<div>
|
||||
<label
|
||||
htmlFor="login-usuario"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563]"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
||||
>
|
||||
Usuario
|
||||
</label>
|
||||
@@ -48,13 +48,13 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
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"
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] 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-medium text-[#4B5563]"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
||||
>
|
||||
Contraseña
|
||||
</label>
|
||||
@@ -65,7 +65,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
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"
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
|
||||
@@ -25,7 +25,7 @@ interface Props {
|
||||
onGuardar: (datos: AbonoForm) => void
|
||||
}
|
||||
|
||||
const claseLabel = 'block text-xs font-medium text-[#4B5563]'
|
||||
const claseLabel = 'block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
||||
|
||||
/** 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 rounded-2xl border border-[#E5E7EB] bg-white p-8 shadow-xl"
|
||||
className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-8 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl"
|
||||
>
|
||||
<h2 id="titulo-abono" className="text-xl font-light text-[#111827]">
|
||||
<h2 id="titulo-abono" className="text-xl font-light text-[#111827] dark:text-[#E2E8F0]">
|
||||
Registrar abono — Pago #{pago.numPago}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-[#64748B]">
|
||||
Restante: <span className="font-bold text-[#111827]">{formatoMoneda(restante)}</span>
|
||||
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Restante: <span className="font-bold text-[#111827] dark:text-[#E2E8F0]">{formatoMoneda(restante)}</span>
|
||||
</p>
|
||||
<form
|
||||
className="mt-8 space-y-6"
|
||||
@@ -123,7 +123,7 @@ export default function ModalAbono({ pago, onCancelar, onGuardar }: Props) {
|
||||
type="button"
|
||||
onClick={onCancelar}
|
||||
aria-label="Cancelar y cerrar el registro de abono"
|
||||
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]"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
21
apps/cobranza/src/components/Skeleton.tsx
Normal file
21
apps/cobranza/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
export default function Skeleton({ className = "" }: { className?: string }) {
|
||||
return <div className={`animate-pulse rounded-lg bg-[#E2E8F0] dark:bg-[#334155] ${className}`} />
|
||||
}
|
||||
|
||||
export function SkeletonCard() {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<Skeleton className="h-4 w-1/3 mb-3" />
|
||||
<Skeleton className="h-3 w-2/3 mb-2" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonList({ count = 5 }: { count?: number }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: count }).map((_, i) => <SkeletonCard key={i} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import '@fontsource/inter/300.css'
|
||||
import '@fontsource/inter/400.css'
|
||||
import '@fontsource/inter/500.css'
|
||||
@@ -28,5 +29,6 @@ createRoot(document.getElementById('root')!).render(
|
||||
</Layout>
|
||||
</AuthGate>
|
||||
</BrowserRouter>
|
||||
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SkeletonList } from "../components/Skeleton"
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
@@ -162,7 +163,7 @@ export default function DashboardPage() {
|
||||
return errorCarga ? (
|
||||
<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-[#64748B]">Cargando...</p>
|
||||
<SkeletonList count={5} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -205,7 +206,7 @@ export default function DashboardPage() {
|
||||
type="button"
|
||||
onClick={() => alternarOrden(columna)}
|
||||
aria-label={`Ordenar por ${titulo}`}
|
||||
className="inline-flex items-center gap-1 font-semibold uppercase tracking-wider transition-colors duration-100 ease-out hover:text-[#111827]"
|
||||
className="inline-flex items-center gap-1 font-semibold uppercase tracking-wider transition-colors duration-100 ease-out hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
{titulo}
|
||||
{orden === columna && <span aria-hidden="true">{dir === 'asc' ? '↑' : '↓'}</span>}
|
||||
@@ -216,7 +217,7 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 className="text-xl font-semibold text-[#111827]">Módulo Cobranza — Planes de enganche</h1>
|
||||
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Módulo Cobranza — Planes de enganche</h1>
|
||||
<Link
|
||||
to="/nuevo"
|
||||
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
|
||||
@@ -235,7 +236,7 @@ export default function DashboardPage() {
|
||||
)}
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid grid-cols-2 gap-6 lg:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-4 md:gap-6 lg:grid-cols-4">
|
||||
<KpiCard titulo="Planes activos" valor={activos.length} />
|
||||
<KpiCard
|
||||
titulo="Al corriente / con atraso"
|
||||
@@ -247,9 +248,9 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Gráficas */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<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]">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Al corriente vs vencidos
|
||||
</h2>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
@@ -272,8 +273,8 @@ export default function DashboardPage() {
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<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]">
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Cobrado por mes (últimos 6 meses)
|
||||
</h2>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
@@ -293,9 +294,9 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Tarjeta unificada: toolbar de filtros/búsqueda + tabla de planes */}
|
||||
<div className="overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
<div className="overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
|
||||
{/* Filtros */}
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-[#E5E7EB] p-4 sm:gap-4 sm:px-5">
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-[#E5E7EB] p-4 md:gap-4 md:px-5">
|
||||
{/* Toggle Mis planes / Todos (?mios=1 filtra en el API por el vendedor del token) */}
|
||||
<div className="flex rounded-lg border border-[#D1D5DB] bg-white" role="group" aria-label="Alcance de planes">
|
||||
<button
|
||||
@@ -303,7 +304,7 @@ export default function DashboardPage() {
|
||||
onClick={() => actualizarParams({ vista: mios ? null : 'mios' })}
|
||||
aria-pressed={mios}
|
||||
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]'
|
||||
mios ? 'bg-accent text-white shadow-sm' : 'bg-white text-[#64748B] dark:text-[#94A3B8] hover:text-[#111827] dark:text-[#E2E8F0]'
|
||||
}`}
|
||||
>
|
||||
Mis planes
|
||||
@@ -313,7 +314,7 @@ export default function DashboardPage() {
|
||||
onClick={() => actualizarParams({ vista: mios ? 'todos' : null })}
|
||||
aria-pressed={!mios}
|
||||
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'
|
||||
mios ? 'bg-white text-[#64748B] dark:text-[#94A3B8] hover:text-[#111827] dark:text-[#E2E8F0]' : 'bg-accent text-white shadow-sm'
|
||||
}`}
|
||||
>
|
||||
Todos
|
||||
@@ -328,7 +329,7 @@ export default function DashboardPage() {
|
||||
value={busqueda}
|
||||
onChange={(e) => actualizarParams({ q: e.target.value })}
|
||||
placeholder="Buscar por cliente o unidad…"
|
||||
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"
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 sm:w-64"
|
||||
/>
|
||||
<label htmlFor="filtro-status" className="sr-only">
|
||||
Filtrar por status
|
||||
@@ -337,7 +338,7 @@ export default function DashboardPage() {
|
||||
id="filtro-status"
|
||||
value={filtroStatus}
|
||||
onChange={(e) => actualizarParams({ status: e.target.value === 'TODOS' ? null : e.target.value })}
|
||||
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"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] 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>
|
||||
@@ -351,7 +352,7 @@ export default function DashboardPage() {
|
||||
id="filtro-proyecto"
|
||||
value={filtroProyecto}
|
||||
onChange={(e) => actualizarParams({ proyecto: e.target.value === 'TODOS' ? null : e.target.value })}
|
||||
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"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] 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) => (
|
||||
@@ -366,7 +367,7 @@ export default function DashboardPage() {
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
<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>
|
||||
@@ -379,7 +380,7 @@ export default function DashboardPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#E5E7EB]">
|
||||
{ordenados.map((d) => (
|
||||
<tr key={d.plan.id} className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC]">
|
||||
<tr key={d.plan.id} className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/planes/${d.plan.id}`} className="font-medium text-accent hover:underline">
|
||||
{d.plan.clienteNombre}
|
||||
@@ -395,7 +396,7 @@ export default function DashboardPage() {
|
||||
style={{ width: `${Math.min(100, d.avance * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-[#64748B]">{formatoPorcentaje(d.avance)}</span>
|
||||
<span className="text-xs text-[#64748B] dark:text-[#94A3B8]">{formatoPorcentaje(d.avance)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SkeletonList } from "../components/Skeleton"
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { listarPlanes, type ResultadoPlanes } from '../services/repository'
|
||||
@@ -38,15 +39,15 @@ export default function HoyPage() {
|
||||
return errorCarga ? (
|
||||
<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-[#64748B]">Cargando...</p>
|
||||
<SkeletonList count={3} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-[#111827]">Cobranza del día</h1>
|
||||
<p className="mt-1 text-sm text-[#64748B]">
|
||||
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Cobranza del día</h1>
|
||||
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Pagos vencidos y vencimientos de los próximos 7 días, en todos los planes activos.
|
||||
</p>
|
||||
{resultado.origen === 'local' && (
|
||||
@@ -62,10 +63,10 @@ export default function HoyPage() {
|
||||
<KpiCard titulo="Monto por cobrar" valor={formatoMoneda(totalPorCobrar)} />
|
||||
</div>
|
||||
|
||||
<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 dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
<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>
|
||||
@@ -81,7 +82,7 @@ export default function HoyPage() {
|
||||
{items.map(({ plan, pago, atraso }) => (
|
||||
<tr
|
||||
key={`${plan.id}-${pago.id}`}
|
||||
className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC]"
|
||||
className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC] dark:bg-[#0F172A]"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium">{plan.clienteNombre}</td>
|
||||
<td className="px-4 py-3">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import toast from "react-hot-toast"
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
@@ -37,9 +38,9 @@ const esquema = z.object({
|
||||
type NuevoPlanForm = z.infer<typeof esquema>
|
||||
|
||||
const claseInput =
|
||||
'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'
|
||||
'mt-1 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] 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-medium text-[#4B5563]'
|
||||
const claseLabel = 'block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
||||
|
||||
export default function NuevoPlanPage() {
|
||||
const navigate = useNavigate()
|
||||
@@ -80,6 +81,7 @@ export default function NuevoPlanPage() {
|
||||
try {
|
||||
const { plan } = await crearPlanAsync(datos)
|
||||
void navigate(`/planes/${plan.id}`)
|
||||
toast.success("Plan creado correctamente")
|
||||
} catch (e) {
|
||||
setErrorGuardado(e instanceof Error ? e.message : 'Error al guardar el plan')
|
||||
} finally {
|
||||
@@ -88,16 +90,16 @@ export default function NuevoPlanPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<h1 className="text-xl font-semibold text-[#111827]">Nuevo plan de enganche</h1>
|
||||
<div className="mx-auto max-w-3xl space-y-6 px-4 sm:px-6">
|
||||
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Nuevo plan de enganche</h1>
|
||||
|
||||
<form
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm"
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm sm:p-8"
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(guardar)(e)
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="clienteNombre" className={claseLabel}>Cliente *</label>
|
||||
<input id="clienteNombre" type="text" className={claseInput} {...register('clienteNombre')} />
|
||||
@@ -177,12 +179,12 @@ export default function NuevoPlanPage() {
|
||||
{/* Vista previa del calendario */}
|
||||
{vistaPrevia && (
|
||||
<div className="mt-8 rounded-xl border border-[#E5E7EB] bg-[#F8FAFC] p-6">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
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-[#64748B]">
|
||||
<thead className="text-[#64748B] dark:text-[#94A3B8]">
|
||||
<tr className="border-b border-[#D1D5DB]">
|
||||
<th scope="col" className="py-1">#</th>
|
||||
<th scope="col" className="py-1">Vencimiento</th>
|
||||
@@ -203,12 +205,12 @@ export default function NuevoPlanPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex items-center justify-end gap-3">
|
||||
<div className="mt-8 flex flex-col-reverse items-center justify-end gap-3 sm:flex-row">
|
||||
{errorGuardado && <p className="text-xs text-red-700">{errorGuardado}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate('/')}
|
||||
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]"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function PlanDetallePage() {
|
||||
}, [id])
|
||||
|
||||
if (cargando) {
|
||||
return <p className="text-sm text-[#64748B]">Cargando...</p>
|
||||
return <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando...</p>
|
||||
}
|
||||
|
||||
if (errorCarga) {
|
||||
@@ -72,8 +72,8 @@ export default function PlanDetallePage() {
|
||||
|
||||
if (!plan) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-16 text-center shadow-sm">
|
||||
<p className="text-[#64748B]">Plan no encontrado.</p>
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p- dark:border-[#1E293B] dark:bg-[#1E293B] 16 text-center shadow-sm">
|
||||
<p className="text-[#64748B] dark:text-[#94A3B8]">Plan no encontrado.</p>
|
||||
<Link to="/" className="mt-4 inline-block text-accent hover:underline">
|
||||
Volver al dashboard
|
||||
</Link>
|
||||
@@ -141,13 +141,13 @@ export default function PlanDetallePage() {
|
||||
<Link to="/" className="text-sm text-accent hover:underline">
|
||||
← Volver al dashboard
|
||||
</Link>
|
||||
<h1 className="mt-2 text-xl font-semibold text-[#111827]">{plan.clienteNombre}</h1>
|
||||
<p className="mt-1 text-sm text-[#64748B]">
|
||||
<h1 className="mt-2 text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{plan.clienteNombre}</h1>
|
||||
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
|
||||
<span className="font-semibold text-[#111827]">{status}</span>
|
||||
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{status}</span>
|
||||
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[#64748B]">
|
||||
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Tel. {plan.clienteTelefono || '—'}
|
||||
{tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>}
|
||||
</p>
|
||||
@@ -169,10 +169,10 @@ export default function PlanDetallePage() {
|
||||
<div className="min-w-0 flex-1 space-y-6">
|
||||
{/* Resumen del plan */}
|
||||
<section
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm sm:p-6"
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm sm:p-6"
|
||||
aria-labelledby="titulo-resumen"
|
||||
>
|
||||
<h2 id="titulo-resumen" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<h2 id="titulo-resumen" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Resumen del plan
|
||||
</h2>
|
||||
<div className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
@@ -198,8 +198,8 @@ export default function PlanDetallePage() {
|
||||
{/* 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>
|
||||
<span className="font-medium text-[#64748B] dark:text-[#94A3B8]">Avance del plan</span>
|
||||
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{formatoPorcentaje(avance)}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 rounded-full bg-[#E5E7EB]">
|
||||
<div
|
||||
@@ -211,10 +211,10 @@ export default function PlanDetallePage() {
|
||||
</section>
|
||||
|
||||
{/* 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 dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
<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>
|
||||
@@ -234,7 +234,7 @@ export default function PlanDetallePage() {
|
||||
const expandido = expandidos.has(pago.id)
|
||||
return (
|
||||
<Fragment key={pago.id}>
|
||||
<tr className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC]">
|
||||
<tr className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<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>
|
||||
@@ -276,11 +276,11 @@ export default function PlanDetallePage() {
|
||||
</td>
|
||||
</tr>
|
||||
{expandido && (
|
||||
<tr className="bg-[#F8FAFC]">
|
||||
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<td colSpan={tasaMora > 0 ? 9 : 8} className="px-4 py-4">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<tr className="text-left font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
<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>
|
||||
@@ -289,7 +289,7 @@ export default function PlanDetallePage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{pago.aplicaciones.map((a) => (
|
||||
<tr key={a.id} className="border-t border-[#E5E7EB]">
|
||||
<tr key={a.id} className="border-t border-[#E5E7EB] dark:border-[#1E293B]">
|
||||
<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>
|
||||
@@ -320,16 +320,16 @@ export default function PlanDetallePage() {
|
||||
{/* Rail derecho: acciones y bitácora */}
|
||||
<aside className="space-y-6 lg:sticky lg:top-20 lg:w-[340px] lg:shrink-0">
|
||||
<section
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm"
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm"
|
||||
aria-labelledby="titulo-acciones"
|
||||
>
|
||||
<h2 id="titulo-acciones" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<h2 id="titulo-acciones" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
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]"
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Estado de cuenta PDF
|
||||
</button>
|
||||
@@ -345,19 +345,19 @@ export default function PlanDetallePage() {
|
||||
</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]">
|
||||
<section className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm" aria-labelledby="titulo-bitacora">
|
||||
<h2 id="titulo-bitacora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Bitácora
|
||||
</h2>
|
||||
{(plan.notas ?? []).length === 0 ? (
|
||||
<p className="mt-6 text-sm text-[#64748B]">Sin notas registradas.</p>
|
||||
<p className="mt-6 text-sm text-[#64748B] dark:text-[#94A3B8]">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-[#E5E7EB] pl-4">
|
||||
<p className="text-xs text-[#64748B]">
|
||||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
{new Date(nota.fecha).toLocaleString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
@@ -366,7 +366,7 @@ export default function PlanDetallePage() {
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[#111827]">{nota.texto}</p>
|
||||
<p className="mt-1 text-sm text-[#111827] dark:text-[#E2E8F0]">{nota.texto}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -380,7 +380,7 @@ export default function PlanDetallePage() {
|
||||
>
|
||||
<label
|
||||
htmlFor="nota-texto"
|
||||
className="block text-xs font-medium text-[#4B5563]"
|
||||
className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
||||
>
|
||||
Nueva nota
|
||||
</label>
|
||||
@@ -390,7 +390,7 @@ 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 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"
|
||||
className="mt-1 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] 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">
|
||||
@@ -410,7 +410,7 @@ export default function PlanDetallePage() {
|
||||
{status === 'CANCELADO' && (
|
||||
<button
|
||||
onClick={() => void navigate('/')}
|
||||
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]"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Volver
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user