feat: PWA instalable, cambio de contraseña y dashboards por rol

- POST /api/auth/cambiar-password (bcrypt 12, política ≥8+1 número) +
  modal en las 6 apps con auth
- PWA: manifest + íconos de marca en las 7 apps (sin service worker a
  propósito, documentado); instalable en celular
- Dashboards: 'Mi tablero' para vendedor (sus KPIs y funnel) y sección
  Equipo para coordinador/corporativo/admin
- mi-dia: equipo.porVendedor gana leadsNuevosSemana
- Tests: api 271, cotizador 88, alta 88, cobranza 81, agente-ia 19,
  dashboards 15, mi-dia 11, landing 4
This commit is contained in:
2026-08-28 08:37:41 +00:00
parent aa27e89c75
commit cb86a5190a
80 changed files with 2533 additions and 323 deletions

View File

@@ -11,8 +11,23 @@ Ecosistema comercial de Frasa Desarrollos S.A.P.I. de C.V.
| `apps/alta-clientes` | `/client/` | Alta de clientes (wizard, firma digital, PDFs PLD) | | `apps/alta-clientes` | `/client/` | Alta de clientes (wizard, firma digital, PDFs PLD) |
| `apps/cobranza` | `/pay/` | Planes de enganche, vencidos, estados de cuenta | | `apps/cobranza` | `/pay/` | Planes de enganche, vencidos, estados de cuenta |
| `apps/dashboards` | `/dash/` | Tableros KPI (leads, oportunidades, inventario, cobranza) | | `apps/dashboards` | `/dash/` | Tableros KPI (leads, oportunidades, inventario, cobranza) |
| `apps/mi-dia` | `/hoy/` | Agenda del vendedor (mis leads, actividades, acciones CRM) |
| `apps/agente-ia` | `/ai/` | Asistente IA con documentos del ecosistema |
| `api-gateway` | `/api/` | Express 5 + Prisma + integración Monday.com | | `api-gateway` | `/api/` | Express 5 + Prisma + integración Monday.com |
## PWA (instalabilidad básica)
Las 7 apps son instalables (manifest + íconos): cada una tiene
`public/manifest.webmanifest` ("Frasa · \<app\>", `display: standalone`,
`theme_color`/`background_color`) e íconos 192/512 en `public/icons/`
(generados con `scripts/generar-iconos-pwa.py`, venv `/root/.venv-tools`).
**Decisión: NO hay service worker.** Solo se busca instalabilidad básica
(ícono en el teléfono que abre la app a pantalla completa). Un SW con caché
arriesga servir bundles viejos tras cada deploy (los usuarios son vendedores
en campo; no van a limpiar caché a mano). Si algún día se quiere modo offline,
hay que diseñar primero la estrategia de invalidación de caché.
## Desarrollo local ## Desarrollo local
Cada app es un proyecto Vite independiente: `npm install && npm run dev` (o `npm run preview` tras `npm run build`). Tests: `npx vitest run` en cada paquete. El API: `cd api-gateway && npm install && npx prisma migrate dev && npm run dev`. Cada app es un proyecto Vite independiente: `npm install && npm run dev` (o `npm run preview` tras `npm run build`). Tests: `npx vitest run` en cada paquete. El API: `cd api-gateway && npm install && npx prisma migrate dev && npm run dev`.

View File

@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { cambiarPasswordEndpoint } from './auth.controller.js'
import { AppError } from '../utils/errors.js'
// ---------- Servicio mockeado (la lógica se prueba en auth.service.test) ----------
const servicioMock = vi.hoisted(() => ({ cambiarPassword: vi.fn() }))
vi.mock('../services/auth.service.js', () => ({
cambiarPassword: servicioMock.cambiarPassword,
}))
function reqFake(overrides: Partial<Request> = {}): Request {
return {
body: { actual: 'Frasa2026', nueva: 'NuevaPass2026' },
usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' },
...overrides,
} as Request
}
function resFake() {
const json = vi.fn()
const end = vi.fn()
const status = vi.fn().mockReturnValue({ json, end })
return { res: { json, status } as unknown as Response, status, json, end }
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('cambiarPasswordEndpoint', () => {
it('204 sin cuerpo cuando el servicio cambia la contraseña', async () => {
servicioMock.cambiarPassword.mockResolvedValue(undefined)
const { res, status, end } = resFake()
await cambiarPasswordEndpoint(reqFake(), res)
expect(servicioMock.cambiarPassword).toHaveBeenCalledWith(
{ sub: 'u-1', username: 'asesor uno', rol: 'vendedor' },
'Frasa2026',
'NuevaPass2026',
)
expect(status).toHaveBeenCalledWith(204)
expect(end).toHaveBeenCalled()
})
it('sin token verificado: 400', async () => {
const { res } = resFake()
await expect(cambiarPasswordEndpoint(reqFake({ usuarioToken: undefined }), res)).rejects.toThrow(
'Falta el token verificado',
)
expect(servicioMock.cambiarPassword).not.toHaveBeenCalled()
})
it('body inválido: ZodError (el error handler lo traduce a 400)', async () => {
const { res } = resFake()
await expect(
cambiarPasswordEndpoint(reqFake({ body: { actual: '' } }), res),
).rejects.toThrow()
expect(servicioMock.cambiarPassword).not.toHaveBeenCalled()
})
it('propaga el AppError del servicio (actual incorrecta → 400)', async () => {
servicioMock.cambiarPassword.mockRejectedValue(
new AppError(400, 'La contraseña actual no es correcta'),
)
const { res } = resFake()
const error = await cambiarPasswordEndpoint(reqFake(), res).catch((e: unknown) => e)
expect(error).toMatchObject({ status: 400 })
})
})

View File

@@ -1,6 +1,6 @@
import type { Request, Response } from 'express' import type { Request, Response } from 'express'
import { z } from 'zod' import { z } from 'zod'
import { login, me } from '../services/auth.service.js' import { cambiarPassword, login, me } from '../services/auth.service.js'
import { BadRequestError } from '../utils/errors.js' import { BadRequestError } from '../utils/errors.js'
const loginSchema = z.object({ const loginSchema = z.object({
@@ -20,3 +20,16 @@ export async function meEndpoint(req: Request, res: Response): Promise<void> {
if (!req.usuarioToken) throw new BadRequestError('Falta el token verificado') if (!req.usuarioToken) throw new BadRequestError('Falta el token verificado')
res.json(await me(req.usuarioToken)) res.json(await me(req.usuarioToken))
} }
const cambiarPasswordSchema = z.object({
actual: z.string().min(1),
nueva: z.string().min(1),
})
/** POST /api/auth/cambiar-password (Bearer) { actual, nueva } → 204 */
export async function cambiarPasswordEndpoint(req: Request, res: Response): Promise<void> {
if (!req.usuarioToken) throw new BadRequestError('Falta el token verificado')
const { actual, nueva } = cambiarPasswordSchema.parse(req.body)
await cambiarPassword(req.usuarioToken, actual, nueva)
res.status(204).end()
}

View File

@@ -62,7 +62,7 @@ import {
obtenerConversacion, obtenerConversacion,
subirDocumento, subirDocumento,
} from '../controllers/ai.controller.js' } from '../controllers/ai.controller.js'
import { loginEndpoint, meEndpoint } from '../controllers/auth.controller.js' import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js'
import { resumenReportes } from '../controllers/reportes.controller.js' import { resumenReportes } from '../controllers/reportes.controller.js'
export const apiRouter = Router() export const apiRouter = Router()
@@ -71,6 +71,7 @@ apiRouter.get('/health', health)
apiRouter.post('/auth/login', loginEndpoint) apiRouter.post('/auth/login', loginEndpoint)
apiRouter.get('/auth/me', meEndpoint) apiRouter.get('/auth/me', meEndpoint)
apiRouter.post('/auth/cambiar-password', cambiarPasswordEndpoint)
apiRouter.get('/unidades', listarUnidades) apiRouter.get('/unidades', listarUnidades)
apiRouter.get('/unidades/:codigo', obtenerUnidad) apiRouter.get('/unidades/:codigo', obtenerUnidad)

View File

@@ -1,13 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import type { Usuario } from '@prisma/client' import type { Usuario } from '@prisma/client'
import { login, me, normalizarUsername, verificarToken } from './auth.service.js' import { cambiarPassword, login, me, normalizarUsername, validarPasswordNueva, verificarToken } from './auth.service.js'
// ---------- Prisma mockeado (los tests no tocan la BD ni la red) ---------- // ---------- Prisma mockeado (los tests no tocan la BD ni la red) ----------
const prismaMock = vi.hoisted(() => ({ const prismaMock = vi.hoisted(() => ({
usuario: { usuario: {
findUnique: vi.fn(), findUnique: vi.fn(),
update: vi.fn(),
}, },
})) }))
@@ -102,3 +103,63 @@ describe('me', () => {
await expect(me({ sub: 'u-1', username: 'x', rol: 'vendedor' })).rejects.toThrow('Credenciales inválidas') await expect(me({ sub: 'u-1', username: 'x', rol: 'vendedor' })).rejects.toThrow('Credenciales inválidas')
}) })
}) })
describe('validarPasswordNueva', () => {
it('exige al menos 8 caracteres y un número', () => {
expect(validarPasswordNueva('corta1')).toContain('8 caracteres')
expect(validarPasswordNueva('sinnumeros')).toContain('número')
expect(validarPasswordNueva('')).toBeTruthy()
})
it('acepta passwords que cumplen la regla', () => {
expect(validarPasswordNueva('Frasa2026')).toBeNull()
expect(validarPasswordNueva('12345678')).toBeNull()
})
})
describe('cambiarPassword', () => {
const payload = { sub: 'u-1', username: 'erick israel pena ramos', rol: 'vendedor' }
it('con actual correcta re-hashea y guarda la nueva (cost 12)', async () => {
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake())
prismaMock.usuario.update.mockResolvedValue(usuarioFake())
await cambiarPassword(payload, 'Frasa2026', 'NuevaPass2026')
expect(prismaMock.usuario.update).toHaveBeenCalledTimes(1)
const llamada = prismaMock.usuario.update.mock.calls[0]?.[0] as {
where: { id: string }
data: { passwordHash: string }
}
expect(llamada.where).toEqual({ id: 'u-1' })
expect(llamada.data.passwordHash).not.toBe('NuevaPass2026')
expect(await bcrypt.compare('NuevaPass2026', llamada.data.passwordHash)).toBe(true)
// Cost 12: el hash bcrypt lleva el factor en el propio string ($2b$12$…)
expect(llamada.data.passwordHash).toMatch(/^\$2[aby]\$12\$/)
})
it('rechaza una nueva que no cumple la política sin tocar la BD', async () => {
await expect(cambiarPassword(payload, 'Frasa2026', 'corta')).rejects.toThrow('8 caracteres')
expect(prismaMock.usuario.findUnique).not.toHaveBeenCalled()
expect(prismaMock.usuario.update).not.toHaveBeenCalled()
})
it('con actual incorrecta responde 400 y no actualiza', async () => {
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake())
const error = await cambiarPassword(payload, 'mala', 'NuevaPass2026').catch((e: unknown) => e)
expect(error).toMatchObject({ status: 400, message: 'La contraseña actual no es correcta' })
expect(prismaMock.usuario.update).not.toHaveBeenCalled()
})
it('usuario inexistente o inactivo: 401 genérico', async () => {
prismaMock.usuario.findUnique.mockResolvedValue(null)
await expect(cambiarPassword(payload, 'Frasa2026', 'NuevaPass2026')).rejects.toThrow(
'Credenciales inválidas',
)
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ activo: false }))
await expect(cambiarPassword(payload, 'Frasa2026', 'NuevaPass2026')).rejects.toThrow(
'Credenciales inválidas',
)
})
})

View File

@@ -81,3 +81,40 @@ export async function me(payload: TokenPayload): Promise<UsuarioPublico> {
if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas') if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas')
return aPublico(usuario) return aPublico(usuario)
} }
const BCRYPT_COST = 12
/**
* Regla de la contraseña nueva: ≥8 caracteres y al menos 1 número.
* Devuelve el mensaje de error o null si es válida (la app cliente replica
* esta misma regla para validar antes de llamar al API).
*/
export function validarPasswordNueva(nueva: string): string | null {
if (nueva.length < 8 || !/\d/.test(nueva)) {
return 'La nueva contraseña debe tener al menos 8 caracteres y un número'
}
return null
}
/**
* POST /auth/cambiar-password: verifica la contraseña actual contra bcrypt y
* re-hashea la nueva (cost 12). Errores genéricos: no se filtra si el usuario
* existe; una actual incorrecta responde 400 sin más detalle.
*/
export async function cambiarPassword(
payload: TokenPayload,
actual: string,
nueva: string,
): Promise<void> {
const errorPolitica = validarPasswordNueva(nueva)
if (errorPolitica) throw new AppError(400, errorPolitica)
const usuario = await prisma.usuario.findUnique({ where: { id: payload.sub } })
if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas')
const ok = await bcrypt.compare(actual, usuario.passwordHash)
if (!ok) throw new AppError(400, 'La contraseña actual no es correcta')
const passwordHash = await bcrypt.hash(nueva, BCRYPT_COST)
await prisma.usuario.update({ where: { id: usuario.id }, data: { passwordHash } })
}

View File

@@ -166,6 +166,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
lead({ id: '3', vendedorIds: [MI_ID] }), lead({ id: '3', vendedorIds: [MI_ID] }),
lead({ id: '4', vendedorIds: [MI_ID], estado: 'DESCARTADO' }), // terminal: no cuenta lead({ id: '4', vendedorIds: [MI_ID], estado: 'DESCARTADO' }), // terminal: no cuenta
lead({ id: '5', vendedorIds: [OTRO_ID] }), lead({ id: '5', vendedorIds: [OTRO_ID] }),
lead({ id: '6', vendedorIds: [OTRO_ID], createdAt: '2026-08-10T08:00:00' }), // viejo: no es nuevo de la semana
], ],
}) })
const nombres = new Map([[MI_ID, 'Martin Zavala']]) const nombres = new Map([[MI_ID, 'Martin Zavala']])
@@ -176,8 +177,8 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
const rCoord = await resumenMiDia(monday, coordinador, nombres, AHORA) const rCoord = await resumenMiDia(monday, coordinador, nombres, AHORA)
expect(rCoord.equipo?.leadsNuevosSinAsignar).toBe(1) expect(rCoord.equipo?.leadsNuevosSinAsignar).toBe(1)
expect(rCoord.equipo?.porVendedor).toEqual([ expect(rCoord.equipo?.porVendedor).toEqual([
{ mondayUserId: MI_ID, nombre: 'Martin Zavala', leadsAsignados: 1 }, { mondayUserId: OTRO_ID, nombre: null, leadsAsignados: 2, leadsNuevosSemana: 1 },
{ mondayUserId: OTRO_ID, nombre: null, leadsAsignados: 1 }, { mondayUserId: MI_ID, nombre: 'Martin Zavala', leadsAsignados: 1, leadsNuevosSemana: 1 },
]) ])
}) })

View File

@@ -64,7 +64,13 @@ export interface ResumenMiDia {
// Solo roles coordinador/corporativo/admin // Solo roles coordinador/corporativo/admin
equipo?: { equipo?: {
leadsNuevosSinAsignar: number leadsNuevosSinAsignar: number
porVendedor: { mondayUserId: number; nombre: string | null; leadsAsignados: number }[] porVendedor: {
mondayUserId: number
nombre: string | null
leadsAsignados: number
/** Leads del vendedor creados en los últimos 7 días naturales */
leadsNuevosSemana: number
}[]
} }
} }
@@ -170,9 +176,14 @@ export async function resumenMiDia(
if (ROLES_CON_EQUIPO.includes(usuario.rol)) { if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
const sinAsignar = leads.filter((l) => l.vendedorIds.length === 0 && !esEstadoTerminalLead(l.estado)) const sinAsignar = leads.filter((l) => l.vendedorIds.length === 0 && !esEstadoTerminalLead(l.estado))
const conteoPorId = new Map<number, number>() const conteoPorId = new Map<number, number>()
const nuevosPorId = new Map<number, number>()
for (const l of leads) { for (const l of leads) {
if (esEstadoTerminalLead(l.estado)) continue if (esEstadoTerminalLead(l.estado)) continue
for (const id of l.vendedorIds) conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1) const esNuevoSemana = Date.parse(l.createdAt) >= hace7DiasMs
for (const id of l.vendedorIds) {
conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1)
if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1)
}
} }
resumen.equipo = { resumen.equipo = {
leadsNuevosSinAsignar: sinAsignar.filter((l) => l.estado.trim().toUpperCase() === 'NUEVO').length, leadsNuevosSinAsignar: sinAsignar.filter((l) => l.estado.trim().toUpperCase() === 'NUEVO').length,
@@ -181,6 +192,7 @@ export async function resumenMiDia(
mondayUserId, mondayUserId,
nombre: nombresPorMondayId.get(mondayUserId) ?? null, nombre: nombresPorMondayId.get(mondayUserId) ?? null,
leadsAsignados, leadsAsignados,
leadsNuevosSemana: nuevosPorId.get(mondayUserId) ?? 0,
})) }))
.sort((a, b) => b.leadsAsignados - a.leadsAsignados), .sort((a, b) => b.leadsAsignados - a.leadsAsignados),
} }

View File

@@ -3,6 +3,9 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1B4332" />
<link rel="manifest" href="/ai/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/ai/icons/icon-192.png" />
<title>FRASA Desarrollos — Asistente IA</title> <title>FRASA Desarrollos — Asistente IA</title>
</head> </head>
<body> <body>

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,13 @@
{
"name": "Frasa · Asistente IA",
"short_name": "Asistente IA",
"start_url": "/ai/",
"scope": "/ai/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1B4332",
"icons": [
{ "src": "/ai/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/ai/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}

View File

@@ -1,13 +1,16 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { getSesion, logout, type Sesion } from '../services/auth' import { getSesion, logout, type Sesion } from '../services/auth'
import CambiarPasswordModal from './CambiarPasswordModal'
import LoginScreen from './LoginScreen' import LoginScreen from './LoginScreen'
/** /**
* Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la * Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la
* app con una barra discreta "nombre · rol" y botón "Salir". * app con una barra discreta "nombre · rol", "Contraseña" (modal de cambio)
* y botón "Salir".
*/ */
export default function AuthGate({ children }: { children: ReactNode }) { export default function AuthGate({ children }: { children: ReactNode }) {
const [sesion, setSesion] = useState<Sesion | null>(() => getSesion()) const [sesion, setSesion] = useState<Sesion | null>(() => getSesion())
const [modalPassword, setModalPassword] = useState(false)
if (!sesion) { if (!sesion) {
return <LoginScreen onLogin={() => setSesion(getSesion())} /> return <LoginScreen onLogin={() => setSesion(getSesion())} />
@@ -20,6 +23,14 @@ export default function AuthGate({ children }: { children: ReactNode }) {
<span className="text-xs font-bold uppercase tracking-widest text-neutral-500"> <span className="text-xs font-bold uppercase tracking-widest text-neutral-500">
{sesion.usuario.nombre} · {sesion.usuario.rol} {sesion.usuario.nombre} · {sesion.usuario.rol}
</span> </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"
>
Contraseña
</button>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -32,7 +43,9 @@ export default function AuthGate({ children }: { children: ReactNode }) {
</button> </button>
</div> </div>
</div> </div>
</div>
{children} {children}
{modalPassword && <CambiarPasswordModal onCerrar={() => setModalPassword(false)} />}
</div> </div>
) )
} }

View File

@@ -0,0 +1,140 @@
import { useState, type FormEvent } from 'react'
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'
/**
* Modal "Cambiar contraseña": actual + nueva + confirmar. Valida en cliente
* con la misma regla del API y muestra el mensaje del API si la actual no es
* correcta. Al éxito la sesión sigue intacta (el JWT no cambia).
*/
export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => void }) {
const [actual, setActual] = useState('')
const [nueva, setNueva] = useState('')
const [confirmar, setConfirmar] = useState('')
const [error, setError] = useState<string | null>(null)
const [exito, setExito] = useState(false)
const [cargando, setCargando] = useState(false)
async function enviar(e: FormEvent) {
e.preventDefault()
const errorCliente = validarPasswordNueva(nueva) ?? validarConfirmacion(nueva, confirmar)
if (errorCliente) {
setError(errorCliente)
return
}
setError(null)
setCargando(true)
try {
await cambiarPassword(actual, nueva)
setExito(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cambiar la contraseña')
} finally {
setCargando(false)
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
role="dialog"
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">
Cambiar contraseña
</h2>
{exito ? (
<>
<p className="border border-accent px-4 py-3 text-xs font-bold uppercase tracking-widest text-accent">
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"
>
Cerrar
</button>
</>
) : (
<form onSubmit={enviar} className="space-y-6">
<div>
<label htmlFor="password-actual" className={CLASE_LABEL}>
Contraseña actual
</label>
<input
id="password-actual"
type="password"
value={actual}
onChange={(e) => setActual(e.target.value)}
autoComplete="current-password"
autoFocus
required
className={CLASE_INPUT}
/>
</div>
<div>
<label htmlFor="password-nueva" className={CLASE_LABEL}>
Nueva contraseña
</label>
<input
id="password-nueva"
type="password"
value={nueva}
onChange={(e) => setNueva(e.target.value)}
autoComplete="new-password"
required
className={CLASE_INPUT}
/>
<p className="mt-2 text-xs text-neutral-400">Mínimo 8 caracteres y un número</p>
</div>
<div>
<label htmlFor="password-confirmar" className={CLASE_LABEL}>
Confirmar nueva contraseña
</label>
<input
id="password-confirmar"
type="password"
value={confirmar}
onChange={(e) => setConfirmar(e.target.value)}
autoComplete="new-password"
required
className={CLASE_INPUT}
/>
</div>
{error && (
<p
role="alert"
className="border border-red-700 px-4 py-3 text-xs font-bold uppercase tracking-widest text-red-700"
>
{error}
</p>
)}
<div className="flex gap-3">
<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"
>
{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"
>
Cancelar
</button>
</div>
</form>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { validarConfirmacion, validarPasswordNueva } from './password'
describe('validarPasswordNueva', () => {
it('exige ≥8 caracteres y al menos un número (misma regla que el API)', () => {
expect(validarPasswordNueva('corta1')).toContain('8 caracteres')
expect(validarPasswordNueva('sinnumeros')).toContain('número')
expect(validarPasswordNueva('')).toBeTruthy()
})
it('acepta contraseñas que cumplen la regla', () => {
expect(validarPasswordNueva('Frasa2026')).toBeNull()
expect(validarPasswordNueva('12345678')).toBeNull()
})
})
describe('validarConfirmacion', () => {
it('null si coinciden; mensaje si difieren', () => {
expect(validarConfirmacion('abc12345', 'abc12345')).toBeNull()
expect(validarConfirmacion('abc12345', 'abc12346')).toContain('no coincide')
})
})

View File

@@ -0,0 +1,16 @@
// Validación cliente de la contraseña nueva: misma regla que el API
// (api-gateway/src/services/auth.service.ts). El API vuelve a validar;
// esto solo evita un viaje de red con errores obvios.
/** null si cumple; mensaje si no (≥8 caracteres y al menos 1 número) */
export function validarPasswordNueva(nueva: string): string | null {
if (nueva.length < 8 || !/\d/.test(nueva)) {
return 'La nueva contraseña debe tener al menos 8 caracteres y un número'
}
return null
}
/** null si la confirmación coincide con la nueva; mensaje si difiere */
export function validarConfirmacion(nueva: string, confirmar: string): string | null {
return nueva === confirmar ? null : 'La confirmación no coincide con la nueva contraseña'
}

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { import {
ErrorLogin, ErrorLogin,
cambiarPassword,
conAuth, conAuth,
getSesion, getSesion,
getToken, getToken,
@@ -122,3 +123,37 @@ describe('manejar401', () => {
expect(getSesion()).not.toBeNull() expect(getSesion()).not.toBeNull()
}) })
}) })
describe('cambiarPassword', () => {
it('204: resuelve sin tocar la sesión y manda Bearer + ambas contraseñas', async () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
vi.stubGlobal('fetch', fetchMock)
await cambiarPassword('Frasa2026', 'NuevaPass2026')
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('/auth/cambiar-password')
expect(init.method).toBe('POST')
expect(init.headers).toMatchObject({ Authorization: 'Bearer jwt-de-prueba' })
expect(JSON.parse(init.body as string)).toEqual({ actual: 'Frasa2026', nueva: 'NuevaPass2026' })
expect(getSesion()).not.toBeNull()
})
it('400 del API: lanza Error con el mensaje del API (p. ej. actual incorrecta)', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response('{"error":"La contraseña actual no es correcta"}', { status: 400 }),
),
)
await expect(cambiarPassword('mala', 'NuevaPass2026')).rejects.toThrow(
'La contraseña actual no es correcta',
)
})
it('sin red: Error de conexión', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')))
await expect(cambiarPassword('a', 'b12345678')).rejects.toThrow('No se pudo conectar')
})
})

View File

@@ -75,3 +75,28 @@ export function manejar401(res: Response): void {
logout() logout()
if (typeof window !== 'undefined') window.location.reload() if (typeof window !== 'undefined') window.location.reload()
} }
/**
* POST /auth/cambiar-password. Lanza Error con el mensaje del API cuando
* falla (p. ej. "La contraseña actual no es correcta"); no toca la sesión.
*/
export async function cambiarPassword(actual: string, nueva: string): Promise<void> {
let res: Response
try {
res = await fetch(
`${API_URL}/auth/cambiar-password`,
conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actual, nueva }),
}),
)
} catch {
throw new Error('No se pudo conectar con el servidor')
}
manejar401(res)
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `Error ${res.status}`)
}
}

View File

@@ -3,6 +3,9 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1B4332" />
<link rel="manifest" href="/client/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/client/icons/icon-192.png" />
<title>FRASA Desarrollos — Alta de Clientes</title> <title>FRASA Desarrollos — Alta de Clientes</title>
</head> </head>
<body> <body>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -0,0 +1,13 @@
{
"name": "Frasa · Alta de clientes",
"short_name": "Alta clientes",
"start_url": "/client/",
"scope": "/client/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1B4332",
"icons": [
{ "src": "/client/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/client/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}

View File

@@ -1,13 +1,16 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { getSesion, logout, type Sesion } from '../services/auth' import { getSesion, logout, type Sesion } from '../services/auth'
import CambiarPasswordModal from './CambiarPasswordModal'
import LoginScreen from './LoginScreen' import LoginScreen from './LoginScreen'
/** /**
* Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la * Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la
* app con una barra discreta "nombre · rol" y botón "Salir". * app con una barra discreta "nombre · rol", "Contraseña" (modal de cambio)
* y botón "Salir".
*/ */
export default function AuthGate({ children }: { children: ReactNode }) { export default function AuthGate({ children }: { children: ReactNode }) {
const [sesion, setSesion] = useState<Sesion | null>(() => getSesion()) const [sesion, setSesion] = useState<Sesion | null>(() => getSesion())
const [modalPassword, setModalPassword] = useState(false)
if (!sesion) { if (!sesion) {
return <LoginScreen onLogin={() => setSesion(getSesion())} /> return <LoginScreen onLogin={() => setSesion(getSesion())} />
@@ -20,6 +23,14 @@ export default function AuthGate({ children }: { children: ReactNode }) {
<span className="text-xs font-bold uppercase tracking-widest text-neutral-500"> <span className="text-xs font-bold uppercase tracking-widest text-neutral-500">
{sesion.usuario.nombre} · {sesion.usuario.rol} {sesion.usuario.nombre} · {sesion.usuario.rol}
</span> </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"
>
Contraseña
</button>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -32,7 +43,9 @@ export default function AuthGate({ children }: { children: ReactNode }) {
</button> </button>
</div> </div>
</div> </div>
</div>
{children} {children}
{modalPassword && <CambiarPasswordModal onCerrar={() => setModalPassword(false)} />}
</div> </div>
) )
} }

View File

@@ -0,0 +1,140 @@
import { useState, type FormEvent } from 'react'
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'
/**
* Modal "Cambiar contraseña": actual + nueva + confirmar. Valida en cliente
* con la misma regla del API y muestra el mensaje del API si la actual no es
* correcta. Al éxito la sesión sigue intacta (el JWT no cambia).
*/
export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => void }) {
const [actual, setActual] = useState('')
const [nueva, setNueva] = useState('')
const [confirmar, setConfirmar] = useState('')
const [error, setError] = useState<string | null>(null)
const [exito, setExito] = useState(false)
const [cargando, setCargando] = useState(false)
async function enviar(e: FormEvent) {
e.preventDefault()
const errorCliente = validarPasswordNueva(nueva) ?? validarConfirmacion(nueva, confirmar)
if (errorCliente) {
setError(errorCliente)
return
}
setError(null)
setCargando(true)
try {
await cambiarPassword(actual, nueva)
setExito(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cambiar la contraseña')
} finally {
setCargando(false)
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
role="dialog"
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">
Cambiar contraseña
</h2>
{exito ? (
<>
<p className="border border-accent px-4 py-3 text-xs font-bold uppercase tracking-widest text-accent">
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"
>
Cerrar
</button>
</>
) : (
<form onSubmit={enviar} className="space-y-6">
<div>
<label htmlFor="password-actual" className={CLASE_LABEL}>
Contraseña actual
</label>
<input
id="password-actual"
type="password"
value={actual}
onChange={(e) => setActual(e.target.value)}
autoComplete="current-password"
autoFocus
required
className={CLASE_INPUT}
/>
</div>
<div>
<label htmlFor="password-nueva" className={CLASE_LABEL}>
Nueva contraseña
</label>
<input
id="password-nueva"
type="password"
value={nueva}
onChange={(e) => setNueva(e.target.value)}
autoComplete="new-password"
required
className={CLASE_INPUT}
/>
<p className="mt-2 text-xs text-neutral-400">Mínimo 8 caracteres y un número</p>
</div>
<div>
<label htmlFor="password-confirmar" className={CLASE_LABEL}>
Confirmar nueva contraseña
</label>
<input
id="password-confirmar"
type="password"
value={confirmar}
onChange={(e) => setConfirmar(e.target.value)}
autoComplete="new-password"
required
className={CLASE_INPUT}
/>
</div>
{error && (
<p
role="alert"
className="border border-red-700 px-4 py-3 text-xs font-bold uppercase tracking-widest text-red-700"
>
{error}
</p>
)}
<div className="flex gap-3">
<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"
>
{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"
>
Cancelar
</button>
</div>
</form>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { validarConfirmacion, validarPasswordNueva } from './password'
describe('validarPasswordNueva', () => {
it('exige ≥8 caracteres y al menos un número (misma regla que el API)', () => {
expect(validarPasswordNueva('corta1')).toContain('8 caracteres')
expect(validarPasswordNueva('sinnumeros')).toContain('número')
expect(validarPasswordNueva('')).toBeTruthy()
})
it('acepta contraseñas que cumplen la regla', () => {
expect(validarPasswordNueva('Frasa2026')).toBeNull()
expect(validarPasswordNueva('12345678')).toBeNull()
})
})
describe('validarConfirmacion', () => {
it('null si coinciden; mensaje si difieren', () => {
expect(validarConfirmacion('abc12345', 'abc12345')).toBeNull()
expect(validarConfirmacion('abc12345', 'abc12346')).toContain('no coincide')
})
})

View File

@@ -0,0 +1,16 @@
// Validación cliente de la contraseña nueva: misma regla que el API
// (api-gateway/src/services/auth.service.ts). El API vuelve a validar;
// esto solo evita un viaje de red con errores obvios.
/** null si cumple; mensaje si no (≥8 caracteres y al menos 1 número) */
export function validarPasswordNueva(nueva: string): string | null {
if (nueva.length < 8 || !/\d/.test(nueva)) {
return 'La nueva contraseña debe tener al menos 8 caracteres y un número'
}
return null
}
/** null si la confirmación coincide con la nueva; mensaje si difiere */
export function validarConfirmacion(nueva: string, confirmar: string): string | null {
return nueva === confirmar ? null : 'La confirmación no coincide con la nueva contraseña'
}

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { import {
ErrorLogin, ErrorLogin,
cambiarPassword,
conAuth, conAuth,
getSesion, getSesion,
getToken, getToken,
@@ -122,3 +123,37 @@ describe('manejar401', () => {
expect(getSesion()).not.toBeNull() expect(getSesion()).not.toBeNull()
}) })
}) })
describe('cambiarPassword', () => {
it('204: resuelve sin tocar la sesión y manda Bearer + ambas contraseñas', async () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
vi.stubGlobal('fetch', fetchMock)
await cambiarPassword('Frasa2026', 'NuevaPass2026')
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('/auth/cambiar-password')
expect(init.method).toBe('POST')
expect(init.headers).toMatchObject({ Authorization: 'Bearer jwt-de-prueba' })
expect(JSON.parse(init.body as string)).toEqual({ actual: 'Frasa2026', nueva: 'NuevaPass2026' })
expect(getSesion()).not.toBeNull()
})
it('400 del API: lanza Error con el mensaje del API (p. ej. actual incorrecta)', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response('{"error":"La contraseña actual no es correcta"}', { status: 400 }),
),
)
await expect(cambiarPassword('mala', 'NuevaPass2026')).rejects.toThrow(
'La contraseña actual no es correcta',
)
})
it('sin red: Error de conexión', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')))
await expect(cambiarPassword('a', 'b12345678')).rejects.toThrow('No se pudo conectar')
})
})

View File

@@ -75,3 +75,28 @@ export function manejar401(res: Response): void {
logout() logout()
if (typeof window !== 'undefined') window.location.reload() if (typeof window !== 'undefined') window.location.reload()
} }
/**
* POST /auth/cambiar-password. Lanza Error con el mensaje del API cuando
* falla (p. ej. "La contraseña actual no es correcta"); no toca la sesión.
*/
export async function cambiarPassword(actual: string, nueva: string): Promise<void> {
let res: Response
try {
res = await fetch(
`${API_URL}/auth/cambiar-password`,
conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actual, nueva }),
}),
)
} catch {
throw new Error('No se pudo conectar con el servidor')
}
manejar401(res)
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `Error ${res.status}`)
}
}

View File

@@ -3,6 +3,9 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1B4332" />
<link rel="manifest" href="/pay/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/pay/icons/icon-192.png" />
<title>FRASA Desarrollos — Cobranza</title> <title>FRASA Desarrollos — Cobranza</title>
</head> </head>
<body> <body>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,13 @@
{
"name": "Frasa · Cobranza",
"short_name": "Cobranza",
"start_url": "/pay/",
"scope": "/pay/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1B4332",
"icons": [
{ "src": "/pay/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/pay/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}

View File

@@ -1,13 +1,16 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { getSesion, logout, type Sesion } from '../services/auth' import { getSesion, logout, type Sesion } from '../services/auth'
import CambiarPasswordModal from './CambiarPasswordModal'
import LoginScreen from './LoginScreen' import LoginScreen from './LoginScreen'
/** /**
* Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la * Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la
* app con una barra discreta "nombre · rol" y botón "Salir". * app con una barra discreta "nombre · rol", "Contraseña" (modal de cambio)
* y botón "Salir".
*/ */
export default function AuthGate({ children }: { children: ReactNode }) { export default function AuthGate({ children }: { children: ReactNode }) {
const [sesion, setSesion] = useState<Sesion | null>(() => getSesion()) const [sesion, setSesion] = useState<Sesion | null>(() => getSesion())
const [modalPassword, setModalPassword] = useState(false)
if (!sesion) { if (!sesion) {
return <LoginScreen onLogin={() => setSesion(getSesion())} /> return <LoginScreen onLogin={() => setSesion(getSesion())} />
@@ -20,6 +23,14 @@ export default function AuthGate({ children }: { children: ReactNode }) {
<span className="text-xs font-bold uppercase tracking-widest text-neutral-500"> <span className="text-xs font-bold uppercase tracking-widest text-neutral-500">
{sesion.usuario.nombre} · {sesion.usuario.rol} {sesion.usuario.nombre} · {sesion.usuario.rol}
</span> </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"
>
Contraseña
</button>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -32,7 +43,9 @@ export default function AuthGate({ children }: { children: ReactNode }) {
</button> </button>
</div> </div>
</div> </div>
</div>
{children} {children}
{modalPassword && <CambiarPasswordModal onCerrar={() => setModalPassword(false)} />}
</div> </div>
) )
} }

View File

@@ -0,0 +1,140 @@
import { useState, type FormEvent } from 'react'
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'
/**
* Modal "Cambiar contraseña": actual + nueva + confirmar. Valida en cliente
* con la misma regla del API y muestra el mensaje del API si la actual no es
* correcta. Al éxito la sesión sigue intacta (el JWT no cambia).
*/
export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => void }) {
const [actual, setActual] = useState('')
const [nueva, setNueva] = useState('')
const [confirmar, setConfirmar] = useState('')
const [error, setError] = useState<string | null>(null)
const [exito, setExito] = useState(false)
const [cargando, setCargando] = useState(false)
async function enviar(e: FormEvent) {
e.preventDefault()
const errorCliente = validarPasswordNueva(nueva) ?? validarConfirmacion(nueva, confirmar)
if (errorCliente) {
setError(errorCliente)
return
}
setError(null)
setCargando(true)
try {
await cambiarPassword(actual, nueva)
setExito(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cambiar la contraseña')
} finally {
setCargando(false)
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
role="dialog"
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">
Cambiar contraseña
</h2>
{exito ? (
<>
<p className="border border-accent px-4 py-3 text-xs font-bold uppercase tracking-widest text-accent">
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"
>
Cerrar
</button>
</>
) : (
<form onSubmit={enviar} className="space-y-6">
<div>
<label htmlFor="password-actual" className={CLASE_LABEL}>
Contraseña actual
</label>
<input
id="password-actual"
type="password"
value={actual}
onChange={(e) => setActual(e.target.value)}
autoComplete="current-password"
autoFocus
required
className={CLASE_INPUT}
/>
</div>
<div>
<label htmlFor="password-nueva" className={CLASE_LABEL}>
Nueva contraseña
</label>
<input
id="password-nueva"
type="password"
value={nueva}
onChange={(e) => setNueva(e.target.value)}
autoComplete="new-password"
required
className={CLASE_INPUT}
/>
<p className="mt-2 text-xs text-neutral-400">Mínimo 8 caracteres y un número</p>
</div>
<div>
<label htmlFor="password-confirmar" className={CLASE_LABEL}>
Confirmar nueva contraseña
</label>
<input
id="password-confirmar"
type="password"
value={confirmar}
onChange={(e) => setConfirmar(e.target.value)}
autoComplete="new-password"
required
className={CLASE_INPUT}
/>
</div>
{error && (
<p
role="alert"
className="border border-red-700 px-4 py-3 text-xs font-bold uppercase tracking-widest text-red-700"
>
{error}
</p>
)}
<div className="flex gap-3">
<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"
>
{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"
>
Cancelar
</button>
</div>
</form>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { validarConfirmacion, validarPasswordNueva } from './password'
describe('validarPasswordNueva', () => {
it('exige ≥8 caracteres y al menos un número (misma regla que el API)', () => {
expect(validarPasswordNueva('corta1')).toContain('8 caracteres')
expect(validarPasswordNueva('sinnumeros')).toContain('número')
expect(validarPasswordNueva('')).toBeTruthy()
})
it('acepta contraseñas que cumplen la regla', () => {
expect(validarPasswordNueva('Frasa2026')).toBeNull()
expect(validarPasswordNueva('12345678')).toBeNull()
})
})
describe('validarConfirmacion', () => {
it('null si coinciden; mensaje si difieren', () => {
expect(validarConfirmacion('abc12345', 'abc12345')).toBeNull()
expect(validarConfirmacion('abc12345', 'abc12346')).toContain('no coincide')
})
})

View File

@@ -0,0 +1,16 @@
// Validación cliente de la contraseña nueva: misma regla que el API
// (api-gateway/src/services/auth.service.ts). El API vuelve a validar;
// esto solo evita un viaje de red con errores obvios.
/** null si cumple; mensaje si no (≥8 caracteres y al menos 1 número) */
export function validarPasswordNueva(nueva: string): string | null {
if (nueva.length < 8 || !/\d/.test(nueva)) {
return 'La nueva contraseña debe tener al menos 8 caracteres y un número'
}
return null
}
/** null si la confirmación coincide con la nueva; mensaje si difiere */
export function validarConfirmacion(nueva: string, confirmar: string): string | null {
return nueva === confirmar ? null : 'La confirmación no coincide con la nueva contraseña'
}

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { import {
ErrorLogin, ErrorLogin,
cambiarPassword,
conAuth, conAuth,
getSesion, getSesion,
getToken, getToken,
@@ -122,3 +123,37 @@ describe('manejar401', () => {
expect(getSesion()).not.toBeNull() expect(getSesion()).not.toBeNull()
}) })
}) })
describe('cambiarPassword', () => {
it('204: resuelve sin tocar la sesión y manda Bearer + ambas contraseñas', async () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
vi.stubGlobal('fetch', fetchMock)
await cambiarPassword('Frasa2026', 'NuevaPass2026')
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('/auth/cambiar-password')
expect(init.method).toBe('POST')
expect(init.headers).toMatchObject({ Authorization: 'Bearer jwt-de-prueba' })
expect(JSON.parse(init.body as string)).toEqual({ actual: 'Frasa2026', nueva: 'NuevaPass2026' })
expect(getSesion()).not.toBeNull()
})
it('400 del API: lanza Error con el mensaje del API (p. ej. actual incorrecta)', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response('{"error":"La contraseña actual no es correcta"}', { status: 400 }),
),
)
await expect(cambiarPassword('mala', 'NuevaPass2026')).rejects.toThrow(
'La contraseña actual no es correcta',
)
})
it('sin red: Error de conexión', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')))
await expect(cambiarPassword('a', 'b12345678')).rejects.toThrow('No se pudo conectar')
})
})

View File

@@ -75,3 +75,28 @@ export function manejar401(res: Response): void {
logout() logout()
if (typeof window !== 'undefined') window.location.reload() if (typeof window !== 'undefined') window.location.reload()
} }
/**
* POST /auth/cambiar-password. Lanza Error con el mensaje del API cuando
* falla (p. ej. "La contraseña actual no es correcta"); no toca la sesión.
*/
export async function cambiarPassword(actual: string, nueva: string): Promise<void> {
let res: Response
try {
res = await fetch(
`${API_URL}/auth/cambiar-password`,
conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actual, nueva }),
}),
)
} catch {
throw new Error('No se pudo conectar con el servidor')
}
manejar401(res)
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `Error ${res.status}`)
}
}

View File

@@ -3,6 +3,9 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1B4332" />
<link rel="manifest" href="/quote/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/quote/icons/icon-192.png" />
<title>FRASA Desarrollos — Cotizador</title> <title>FRASA Desarrollos — Cotizador</title>
</head> </head>
<body> <body>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@@ -0,0 +1,13 @@
{
"name": "Frasa · Cotizador",
"short_name": "Cotizador",
"start_url": "/quote/",
"scope": "/quote/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1B4332",
"icons": [
{ "src": "/quote/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/quote/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}

View File

@@ -1,13 +1,16 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { getSesion, logout, type Sesion } from '../services/auth' import { getSesion, logout, type Sesion } from '../services/auth'
import CambiarPasswordModal from './CambiarPasswordModal'
import LoginScreen from './LoginScreen' import LoginScreen from './LoginScreen'
/** /**
* Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la * Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la
* app con una barra discreta "nombre · rol" y botón "Salir". * app con una barra discreta "nombre · rol", "Contraseña" (modal de cambio)
* y botón "Salir".
*/ */
export default function AuthGate({ children }: { children: ReactNode }) { export default function AuthGate({ children }: { children: ReactNode }) {
const [sesion, setSesion] = useState<Sesion | null>(() => getSesion()) const [sesion, setSesion] = useState<Sesion | null>(() => getSesion())
const [modalPassword, setModalPassword] = useState(false)
if (!sesion) { if (!sesion) {
return <LoginScreen onLogin={() => setSesion(getSesion())} /> return <LoginScreen onLogin={() => setSesion(getSesion())} />
@@ -20,6 +23,14 @@ export default function AuthGate({ children }: { children: ReactNode }) {
<span className="text-xs font-bold uppercase tracking-widest text-neutral-500"> <span className="text-xs font-bold uppercase tracking-widest text-neutral-500">
{sesion.usuario.nombre} · {sesion.usuario.rol} {sesion.usuario.nombre} · {sesion.usuario.rol}
</span> </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"
>
Contraseña
</button>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -32,7 +43,9 @@ export default function AuthGate({ children }: { children: ReactNode }) {
</button> </button>
</div> </div>
</div> </div>
</div>
{children} {children}
{modalPassword && <CambiarPasswordModal onCerrar={() => setModalPassword(false)} />}
</div> </div>
) )
} }

View File

@@ -0,0 +1,140 @@
import { useState, type FormEvent } from 'react'
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'
/**
* Modal "Cambiar contraseña": actual + nueva + confirmar. Valida en cliente
* con la misma regla del API y muestra el mensaje del API si la actual no es
* correcta. Al éxito la sesión sigue intacta (el JWT no cambia).
*/
export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => void }) {
const [actual, setActual] = useState('')
const [nueva, setNueva] = useState('')
const [confirmar, setConfirmar] = useState('')
const [error, setError] = useState<string | null>(null)
const [exito, setExito] = useState(false)
const [cargando, setCargando] = useState(false)
async function enviar(e: FormEvent) {
e.preventDefault()
const errorCliente = validarPasswordNueva(nueva) ?? validarConfirmacion(nueva, confirmar)
if (errorCliente) {
setError(errorCliente)
return
}
setError(null)
setCargando(true)
try {
await cambiarPassword(actual, nueva)
setExito(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cambiar la contraseña')
} finally {
setCargando(false)
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
role="dialog"
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">
Cambiar contraseña
</h2>
{exito ? (
<>
<p className="border border-accent px-4 py-3 text-xs font-bold uppercase tracking-widest text-accent">
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"
>
Cerrar
</button>
</>
) : (
<form onSubmit={enviar} className="space-y-6">
<div>
<label htmlFor="password-actual" className={CLASE_LABEL}>
Contraseña actual
</label>
<input
id="password-actual"
type="password"
value={actual}
onChange={(e) => setActual(e.target.value)}
autoComplete="current-password"
autoFocus
required
className={CLASE_INPUT}
/>
</div>
<div>
<label htmlFor="password-nueva" className={CLASE_LABEL}>
Nueva contraseña
</label>
<input
id="password-nueva"
type="password"
value={nueva}
onChange={(e) => setNueva(e.target.value)}
autoComplete="new-password"
required
className={CLASE_INPUT}
/>
<p className="mt-2 text-xs text-neutral-400">Mínimo 8 caracteres y un número</p>
</div>
<div>
<label htmlFor="password-confirmar" className={CLASE_LABEL}>
Confirmar nueva contraseña
</label>
<input
id="password-confirmar"
type="password"
value={confirmar}
onChange={(e) => setConfirmar(e.target.value)}
autoComplete="new-password"
required
className={CLASE_INPUT}
/>
</div>
{error && (
<p
role="alert"
className="border border-red-700 px-4 py-3 text-xs font-bold uppercase tracking-widest text-red-700"
>
{error}
</p>
)}
<div className="flex gap-3">
<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"
>
{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"
>
Cancelar
</button>
</div>
</form>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { validarConfirmacion, validarPasswordNueva } from './password'
describe('validarPasswordNueva', () => {
it('exige ≥8 caracteres y al menos un número (misma regla que el API)', () => {
expect(validarPasswordNueva('corta1')).toContain('8 caracteres')
expect(validarPasswordNueva('sinnumeros')).toContain('número')
expect(validarPasswordNueva('')).toBeTruthy()
})
it('acepta contraseñas que cumplen la regla', () => {
expect(validarPasswordNueva('Frasa2026')).toBeNull()
expect(validarPasswordNueva('12345678')).toBeNull()
})
})
describe('validarConfirmacion', () => {
it('null si coinciden; mensaje si difieren', () => {
expect(validarConfirmacion('abc12345', 'abc12345')).toBeNull()
expect(validarConfirmacion('abc12345', 'abc12346')).toContain('no coincide')
})
})

View File

@@ -0,0 +1,16 @@
// Validación cliente de la contraseña nueva: misma regla que el API
// (api-gateway/src/services/auth.service.ts). El API vuelve a validar;
// esto solo evita un viaje de red con errores obvios.
/** null si cumple; mensaje si no (≥8 caracteres y al menos 1 número) */
export function validarPasswordNueva(nueva: string): string | null {
if (nueva.length < 8 || !/\d/.test(nueva)) {
return 'La nueva contraseña debe tener al menos 8 caracteres y un número'
}
return null
}
/** null si la confirmación coincide con la nueva; mensaje si difiere */
export function validarConfirmacion(nueva: string, confirmar: string): string | null {
return nueva === confirmar ? null : 'La confirmación no coincide con la nueva contraseña'
}

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { import {
ErrorLogin, ErrorLogin,
cambiarPassword,
conAuth, conAuth,
getSesion, getSesion,
getToken, getToken,
@@ -122,3 +123,37 @@ describe('manejar401', () => {
expect(getSesion()).not.toBeNull() expect(getSesion()).not.toBeNull()
}) })
}) })
describe('cambiarPassword', () => {
it('204: resuelve sin tocar la sesión y manda Bearer + ambas contraseñas', async () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
vi.stubGlobal('fetch', fetchMock)
await cambiarPassword('Frasa2026', 'NuevaPass2026')
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('/auth/cambiar-password')
expect(init.method).toBe('POST')
expect(init.headers).toMatchObject({ Authorization: 'Bearer jwt-de-prueba' })
expect(JSON.parse(init.body as string)).toEqual({ actual: 'Frasa2026', nueva: 'NuevaPass2026' })
expect(getSesion()).not.toBeNull()
})
it('400 del API: lanza Error con el mensaje del API (p. ej. actual incorrecta)', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response('{"error":"La contraseña actual no es correcta"}', { status: 400 }),
),
)
await expect(cambiarPassword('mala', 'NuevaPass2026')).rejects.toThrow(
'La contraseña actual no es correcta',
)
})
it('sin red: Error de conexión', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')))
await expect(cambiarPassword('a', 'b12345678')).rejects.toThrow('No se pudo conectar')
})
})

View File

@@ -75,3 +75,28 @@ export function manejar401(res: Response): void {
logout() logout()
if (typeof window !== 'undefined') window.location.reload() if (typeof window !== 'undefined') window.location.reload()
} }
/**
* POST /auth/cambiar-password. Lanza Error con el mensaje del API cuando
* falla (p. ej. "La contraseña actual no es correcta"); no toca la sesión.
*/
export async function cambiarPassword(actual: string, nueva: string): Promise<void> {
let res: Response
try {
res = await fetch(
`${API_URL}/auth/cambiar-password`,
conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actual, nueva }),
}),
)
} catch {
throw new Error('No se pudo conectar con el servidor')
}
manejar401(res)
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `Error ${res.status}`)
}
}

View File

@@ -3,6 +3,9 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1B4332" />
<link rel="manifest" href="/dash/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/dash/icons/icon-192.png" />
<title>FRASA Desarrollos — Tableros</title> <title>FRASA Desarrollos — Tableros</title>
</head> </head>
<body> <body>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -0,0 +1,13 @@
{
"name": "Frasa · Tableros",
"short_name": "Tableros",
"start_url": "/dash/",
"scope": "/dash/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1B4332",
"icons": [
{ "src": "/dash/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/dash/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}

View File

@@ -1,13 +1,16 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { getSesion, logout, type Sesion } from '../services/auth' import { getSesion, logout, type Sesion } from '../services/auth'
import CambiarPasswordModal from './CambiarPasswordModal'
import LoginScreen from './LoginScreen' import LoginScreen from './LoginScreen'
/** /**
* Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la * Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la
* app con una barra discreta "nombre · rol" y botón "Salir". * app con una barra discreta "nombre · rol", "Contraseña" (modal de cambio)
* y botón "Salir".
*/ */
export default function AuthGate({ children }: { children: ReactNode }) { export default function AuthGate({ children }: { children: ReactNode }) {
const [sesion, setSesion] = useState<Sesion | null>(() => getSesion()) const [sesion, setSesion] = useState<Sesion | null>(() => getSesion())
const [modalPassword, setModalPassword] = useState(false)
if (!sesion) { if (!sesion) {
return <LoginScreen onLogin={() => setSesion(getSesion())} /> return <LoginScreen onLogin={() => setSesion(getSesion())} />
@@ -20,6 +23,14 @@ export default function AuthGate({ children }: { children: ReactNode }) {
<span className="text-xs font-bold uppercase tracking-widest text-neutral-500"> <span className="text-xs font-bold uppercase tracking-widest text-neutral-500">
{sesion.usuario.nombre} · {sesion.usuario.rol} {sesion.usuario.nombre} · {sesion.usuario.rol}
</span> </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"
>
Contraseña
</button>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -32,7 +43,9 @@ export default function AuthGate({ children }: { children: ReactNode }) {
</button> </button>
</div> </div>
</div> </div>
</div>
{children} {children}
{modalPassword && <CambiarPasswordModal onCerrar={() => setModalPassword(false)} />}
</div> </div>
) )
} }

View File

@@ -0,0 +1,140 @@
import { useState, type FormEvent } from 'react'
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'
/**
* Modal "Cambiar contraseña": actual + nueva + confirmar. Valida en cliente
* con la misma regla del API y muestra el mensaje del API si la actual no es
* correcta. Al éxito la sesión sigue intacta (el JWT no cambia).
*/
export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => void }) {
const [actual, setActual] = useState('')
const [nueva, setNueva] = useState('')
const [confirmar, setConfirmar] = useState('')
const [error, setError] = useState<string | null>(null)
const [exito, setExito] = useState(false)
const [cargando, setCargando] = useState(false)
async function enviar(e: FormEvent) {
e.preventDefault()
const errorCliente = validarPasswordNueva(nueva) ?? validarConfirmacion(nueva, confirmar)
if (errorCliente) {
setError(errorCliente)
return
}
setError(null)
setCargando(true)
try {
await cambiarPassword(actual, nueva)
setExito(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cambiar la contraseña')
} finally {
setCargando(false)
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
role="dialog"
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">
Cambiar contraseña
</h2>
{exito ? (
<>
<p className="border border-accent px-4 py-3 text-xs font-bold uppercase tracking-widest text-accent">
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"
>
Cerrar
</button>
</>
) : (
<form onSubmit={enviar} className="space-y-6">
<div>
<label htmlFor="password-actual" className={CLASE_LABEL}>
Contraseña actual
</label>
<input
id="password-actual"
type="password"
value={actual}
onChange={(e) => setActual(e.target.value)}
autoComplete="current-password"
autoFocus
required
className={CLASE_INPUT}
/>
</div>
<div>
<label htmlFor="password-nueva" className={CLASE_LABEL}>
Nueva contraseña
</label>
<input
id="password-nueva"
type="password"
value={nueva}
onChange={(e) => setNueva(e.target.value)}
autoComplete="new-password"
required
className={CLASE_INPUT}
/>
<p className="mt-2 text-xs text-neutral-400">Mínimo 8 caracteres y un número</p>
</div>
<div>
<label htmlFor="password-confirmar" className={CLASE_LABEL}>
Confirmar nueva contraseña
</label>
<input
id="password-confirmar"
type="password"
value={confirmar}
onChange={(e) => setConfirmar(e.target.value)}
autoComplete="new-password"
required
className={CLASE_INPUT}
/>
</div>
{error && (
<p
role="alert"
className="border border-red-700 px-4 py-3 text-xs font-bold uppercase tracking-widest text-red-700"
>
{error}
</p>
)}
<div className="flex gap-3">
<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"
>
{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"
>
Cancelar
</button>
</div>
</form>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,121 @@
import { useCallback, useEffect, useState } from 'react'
import { Cell, Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } from 'recharts'
import KpiCard from './KpiCard'
import { EstadoVacio, TituloSeccion } from './comunes'
import { funnelMisLeads, type ResumenMiDia } from '../lib/mi-tablero'
import { fetchResumenMiDia } from '../services/mi-dia'
// Misma paleta del funnel del tablero general
const COLORES_FUNNEL = ['#a3a3a3', '#737373', '#52796f', '#1B4332']
/**
* "Mi tablero": KPIs personales del vendedor logueado (mis leads, actividades,
* apartados por vencer) y un funnel simple de sus leads por estado.
* Fuente: GET /api/mi-dia/resumen (filtra por su mondayUserId en el API).
*/
export default function MiTablero() {
const [resumen, setResumen] = useState<ResumenMiDia | null>(null)
const [error, setError] = useState<string | null>(null)
const [cargando, setCargando] = useState(true)
const cargar = useCallback(async () => {
setCargando(true)
try {
setResumen(await fetchResumenMiDia())
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero')
} finally {
setCargando(false)
}
}, [])
useEffect(() => {
void cargar()
}, [cargar])
const funnel = resumen ? funnelMisLeads(resumen.misLeads) : []
return (
<main className="mx-auto max-w-6xl px-4 py-8">
<div className="mb-8 flex items-end justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-accent">FRASA Desarrollos</p>
<h1 className="mt-2 text-3xl font-light text-neutral-900">Mi tablero</h1>
{resumen && (
<p className="mt-1 text-xs text-neutral-400">
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
</p>
)}
</div>
<button
type="button"
onClick={() => void cargar()}
disabled={cargando}
className="border border-accent px-5 py-2 text-xs font-bold uppercase tracking-widest text-accent transition-colors duration-150 hover:bg-accent hover:text-white disabled:opacity-50"
>
{cargando ? 'Actualizando…' : 'Actualizar'}
</button>
</div>
{error && (
<p role="alert" className="mb-8 border border-amber-700 px-4 py-3 text-xs font-bold uppercase tracking-widest text-amber-700">
Sin datos del API — {error}
</p>
)}
{resumen?.sinMapeoMonday && (
<p className="mb-8 border border-amber-700 px-4 py-3 text-xs font-bold uppercase tracking-widest text-amber-700">
Tu usuario aún no está ligado a Monday: las listas personales aparecen vacías
</p>
)}
{!resumen && !error && cargando && <EstadoVacio mensaje="Cargando…" />}
{!resumen && !error && !cargando && <EstadoVacio mensaje="Sin datos" />}
{resumen && (
<>
<section aria-label="Mis indicadores" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<KpiCard
titulo="Mis leads activos"
valor={resumen.misLeads.length}
detalle={`${resumen.conteos.leadsNuevosSemana} nuevo(s) esta semana`}
/>
<KpiCard
titulo="Mis actividades abiertas"
valor={resumen.conteos.actividadesAbiertas}
detalle={`${resumen.misActividadesHoy.length} con fecha de hoy o antes`}
/>
<KpiCard
titulo="Apartados por vencer"
valor={resumen.apartadosPorVencer.length}
detalle="próximos 7 días (pipeline completo)"
/>
</section>
<section aria-label="Mis leads por estado" className="mt-12">
<TituloSeccion>Mis leads por estado</TituloSeccion>
{funnel.length === 0 ? (
<EstadoVacio mensaje="Sin leads asignados" />
) : (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<FunnelChart>
<Tooltip />
<Funnel dataKey="valor" data={funnel} nameKey="etapa" isAnimationActive={false}>
{funnel.map((etapa, i) => (
<Cell key={etapa.etapa} fill={COLORES_FUNNEL[i % COLORES_FUNNEL.length]} />
))}
<LabelList position="right" dataKey="etapa" stroke="none" fontSize={11} />
<LabelList position="center" dataKey="valor" stroke="none" fill="#fff" fontSize={12} />
</Funnel>
</FunnelChart>
</ResponsiveContainer>
</div>
)}
</section>
</>
)}
</main>
)
}

View File

@@ -0,0 +1,74 @@
import { useCallback, useEffect, useState } from 'react'
import { EstadoVacio, TituloSeccion } from './comunes'
import type { ResumenMiDia } from '../lib/mi-tablero'
import { fetchResumenMiDia } from '../services/mi-dia'
const CLASE_CELDA = 'px-4 py-3 text-sm text-neutral-900'
const CLASE_ENCABEZADO =
'px-4 py-2 text-left text-xs font-bold uppercase tracking-widest text-neutral-500'
/**
* Sección "Equipo" (solo coordinador/corporativo/admin): tabla vendedor →
* leads asignados y nuevos esta semana, más los leads nuevos sin asignar.
* Fuente: `equipo` de GET /api/mi-dia/resumen (el API solo la incluye para
* esos roles).
*/
export default function SeccionEquipo() {
const [equipo, setEquipo] = useState<ResumenMiDia['equipo'] | null>(null)
const [error, setError] = useState<string | null>(null)
const cargar = useCallback(async () => {
try {
setEquipo((await fetchResumenMiDia()).equipo ?? null)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cargar el equipo')
}
}, [])
useEffect(() => {
void cargar()
}, [cargar])
return (
<section aria-label="Equipo" className="mt-12">
<TituloSeccion>Equipo: leads por vendedor</TituloSeccion>
{error && (
<p role="alert" className="border border-amber-700 px-4 py-3 text-xs font-bold uppercase tracking-widest text-amber-700">
Sin datos del API — {error}
</p>
)}
{!error && !equipo && <EstadoVacio mensaje="Sin datos de equipo" />}
{!error && equipo && (
<>
<p className="mb-4 text-xs text-neutral-500">
Leads nuevos sin asignar:{' '}
<span className="font-bold text-neutral-900">{equipo.leadsNuevosSinAsignar}</span>
</p>
{equipo.porVendedor.length === 0 ? (
<EstadoVacio mensaje="Sin leads asignados al equipo" />
) : (
<table className="w-full border border-neutral-200 bg-white">
<thead className="border-b border-neutral-200">
<tr>
<th className={CLASE_ENCABEZADO}>Vendedor</th>
<th className={CLASE_ENCABEZADO}>Leads asignados</th>
<th className={CLASE_ENCABEZADO}>Nuevos esta semana</th>
</tr>
</thead>
<tbody>
{equipo.porVendedor.map((v) => (
<tr key={v.mondayUserId} className="border-b border-neutral-100 last:border-b-0">
<td className={CLASE_CELDA}>{v.nombre ?? `Monday #${v.mondayUserId}`}</td>
<td className={CLASE_CELDA}>{v.leadsAsignados}</td>
<td className={CLASE_CELDA}>{v.leadsNuevosSemana}</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
</section>
)
}

View File

@@ -0,0 +1,252 @@
import { useCallback, useEffect, useState } from 'react'
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Funnel,
FunnelChart,
LabelList,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import KpiCard from './KpiCard'
import SeccionEquipo from './SeccionEquipo'
import { EstadoVacio, TituloSeccion } from './comunes'
import {
datosCobranza,
datosFunnel,
datosPipeline,
formatoMoneda,
formatoPorcentaje,
unidadesApiladas,
unidadesEnEtapa,
type ResumenReportes,
} from '../lib/reportes'
import { fetchResumen } from '../services/reportes'
// Paleta StudioBlank: acento verde + neutros (sin gradientes ni sombras)
const COLORES_STATUS: Record<string, string> = {
DISPONIBLE: '#1B4332',
APARTADO: '#b45309',
VENDIDO: '#525252',
BLOQUEADO: '#b91c1c',
OTROS: '#d4d4d4',
}
const COLORES_FUNNEL = ['#a3a3a3', '#737373', '#52796f', '#1B4332']
const COLORES_COBRANZA = ['#1B4332', '#b45309']
/**
* Vista "General": el tablero comercial de todo el ecosistema (KPIs, inventario,
* pipeline, funnel y cobranza). Con `mostrarEquipo` (coordinador/corporativo/
* admin) agrega la sección Equipo con leads por vendedor.
*/
export default function TableroGeneral({ mostrarEquipo }: { mostrarEquipo: boolean }) {
const [resumen, setResumen] = useState<ResumenReportes | null>(null)
const [error, setError] = useState<string | null>(null)
const [cargando, setCargando] = useState(true)
const cargar = useCallback(async () => {
setCargando(true)
try {
setResumen(await fetchResumen())
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero')
} finally {
setCargando(false)
}
}, [])
useEffect(() => {
void cargar()
}, [cargar])
const unidades = resumen ? unidadesApiladas(resumen) : []
const funnel = resumen ? datosFunnel(resumen) : []
const cobranza = resumen ? datosCobranza(resumen) : []
const pipeline = resumen ? datosPipeline(resumen) : []
const sinPipeline = pipeline.every((e) => e.valor === 0)
const sinCobranza = resumen !== null && resumen.cobranza.planesAlCorriente + resumen.cobranza.planesConVencidos === 0
return (
<main className="mx-auto max-w-6xl px-4 py-8">
<div className="mb-8 flex items-end justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-accent">FRASA Desarrollos</p>
<h1 className="mt-2 text-3xl font-light text-neutral-900">Tablero comercial</h1>
{resumen && (
<p className="mt-1 text-xs text-neutral-400">
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
</p>
)}
</div>
<button
type="button"
onClick={() => void cargar()}
disabled={cargando}
className="border border-accent px-5 py-2 text-xs font-bold uppercase tracking-widest text-accent transition-colors duration-150 hover:bg-accent hover:text-white disabled:opacity-50"
>
{cargando ? 'Actualizando…' : 'Actualizar'}
</button>
</div>
{error && (
<p role="alert" className="mb-8 border border-amber-700 px-4 py-3 text-xs font-bold uppercase tracking-widest text-amber-700">
Sin datos del API — {error}
</p>
)}
{!resumen && !error && cargando && <EstadoVacio mensaje="Cargando…" />}
{!resumen && !error && !cargando && <EstadoVacio mensaje="Sin datos" />}
{resumen && (
<>
<section aria-label="Indicadores clave" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard
titulo="Leads totales"
valor={resumen.leads.total}
detalle={`${resumen.leads.nuevosHoy} hoy · ${resumen.leads.nuevosSemana} esta semana`}
/>
<KpiCard
titulo="Oportunidades activas"
valor={resumen.oportunidades.activas}
detalle={`${resumen.oportunidades.total} en total`}
/>
<KpiCard
titulo="Conversión Aparto / Ganado"
valor={`${formatoPorcentaje(resumen.oportunidades.tasaAparto)} / ${formatoPorcentaje(resumen.oportunidades.tasaGanado)}`}
/>
<KpiCard
titulo="Unidades disponibles"
valor={resumen.unidades.disponibles}
detalle={`${resumen.unidades.total} en inventario`}
/>
<KpiCard
titulo="Cartera vencida"
valor={formatoMoneda(resumen.cobranza.montoVencido)}
detalle={`${resumen.cobranza.pagosVencidos} pago(s) vencido(s)`}
/>
<KpiCard
titulo="Planes activos"
valor={resumen.cobranza.planes.activos}
detalle={`${resumen.cobranza.planes.liquidados} liquidados · ${resumen.cobranza.planes.cancelados} cancelados`}
/>
<KpiCard titulo="Actividades abiertas" valor={resumen.actividadesAbiertas} />
<KpiCard
titulo="En notaría"
valor={unidadesEnEtapa(resumen, 'Notaría')}
detalle="unidades en etapa Notaría"
/>
<KpiCard
titulo="Leads por contactar"
valor={resumen.leads.porEstado['NUEVO'] ?? 0}
detalle="en estado NUEVO"
/>
</section>
<section aria-label="Inventario por desarrollo" className="mt-12">
<TituloSeccion>Unidades por desarrollo y status</TituloSeccion>
{unidades.length === 0 ? (
<EstadoVacio mensaje="Sin inventario" />
) : (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={unidades} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="desarrollo" tick={{ fontSize: 11 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 11 }} />
{Object.keys(COLORES_STATUS).map((status) => (
<Bar key={status} dataKey={status} stackId="unidades" fill={COLORES_STATUS[status]} />
))}
</BarChart>
</ResponsiveContainer>
</div>
)}
</section>
<section aria-label="Pipeline de ventas por etapa" className="mt-12">
<TituloSeccion>Pipeline de ventas por etapa</TituloSeccion>
{sinPipeline ? (
<EstadoVacio mensaje="Sin unidades en el pipeline" />
) : (
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={pipeline} layout="vertical" margin={{ top: 8, right: 24, left: 24, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} />
<YAxis type="category" dataKey="etapa" width={90} tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="valor" name="Unidades" fill="#1B4332" isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</section>
<div className="mt-12 grid gap-12 lg:grid-cols-2">
<section aria-label="Embudo comercial">
<TituloSeccion>Embudo: leads → oportunidades → ganado</TituloSeccion>
{funnel.every((e) => e.valor === 0) ? (
<EstadoVacio mensaje="Sin movimiento comercial" />
) : (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<FunnelChart>
<Tooltip />
<Funnel dataKey="valor" data={funnel} nameKey="etapa" isAnimationActive={false}>
{funnel.map((etapa, i) => (
<Cell key={etapa.etapa} fill={COLORES_FUNNEL[i % COLORES_FUNNEL.length]} />
))}
<LabelList position="right" dataKey="etapa" stroke="none" fontSize={11} />
<LabelList position="center" dataKey="valor" stroke="none" fill="#fff" fontSize={12} />
</Funnel>
</FunnelChart>
</ResponsiveContainer>
</div>
)}
</section>
<section aria-label="Estado de la cobranza">
<TituloSeccion>Cobranza: al corriente vs vencidos</TituloSeccion>
{sinCobranza ? (
<EstadoVacio mensaje="Sin planes activos" />
) : (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={cobranza}
dataKey="valor"
nameKey="nombre"
innerRadius={60}
outerRadius={90}
isAnimationActive={false}
label={({ name, value }) => `${name}: ${value}`}
fontSize={11}
>
{cobranza.map((rebanada, i) => (
<Cell key={rebanada.nombre} fill={COLORES_COBRANZA[i % COLORES_COBRANZA.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
)}
</section>
</div>
{mostrarEquipo && <SeccionEquipo />}
</>
)}
</main>
)
}

View File

@@ -0,0 +1,13 @@
// Piezas visuales compartidas por las vistas del tablero (StudioBlank)
export function EstadoVacio({ mensaje }: { mensaje: string }) {
return (
<div className="flex h-64 items-center justify-center border border-dashed border-neutral-300">
<p className="text-xs font-bold uppercase tracking-widest text-neutral-400">{mensaje}</p>
</div>
)
}
export function TituloSeccion({ children }: { children: string }) {
return <h2 className="mb-4 text-xs font-bold uppercase tracking-widest text-neutral-500">{children}</h2>
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { esRolConEquipo, funnelMisLeads, type LeadMiDia } from './mi-tablero'
function lead(id: string, estado: string): LeadMiDia {
return { id, nombre: `Lead ${id}`, telefono: '', email: '', estado, origen: 'Web', updatedAt: '' }
}
describe('esRolConEquipo', () => {
it('coordinador, corporativo y admin ven equipo; vendedor no', () => {
expect(esRolConEquipo('coordinador')).toBe(true)
expect(esRolConEquipo('corporativo')).toBe(true)
expect(esRolConEquipo('admin')).toBe(true)
expect(esRolConEquipo('vendedor')).toBe(false)
})
})
describe('funnelMisLeads', () => {
it('agrupa por estado normalizado y ordena de mayor a menor', () => {
const funnel = funnelMisLeads([
lead('1', 'NUEVO'),
lead('2', 'nuevo'),
lead('3', ' Contactado '),
lead('4', 'CITA'),
])
expect(funnel).toEqual([
{ etapa: 'NUEVO', valor: 2 },
{ etapa: 'CITA', valor: 1 },
{ etapa: 'CONTACTADO', valor: 1 },
])
})
it('lista vacía → funnel vacío; estado vacío → SIN ESTADO', () => {
expect(funnelMisLeads([])).toEqual([])
expect(funnelMisLeads([lead('1', ' ')])).toEqual([{ etapa: 'SIN ESTADO', valor: 1 }])
})
})

View File

@@ -0,0 +1,81 @@
// Tipos del endpoint GET /api/mi-dia/resumen (espejo del contrato del API) y
// transformaciones puras para la vista "Mi tablero" y la sección "Equipo".
export interface LeadMiDia {
id: string
nombre: string
telefono: string
email: string
estado: string
origen: string
updatedAt: string
}
export interface ActividadMiDia {
id: string
nombre: string
tipo: string
fecha: string // 'YYYY-MM-DD'
estado: string
}
export interface ApartadoPorVencer {
id: string
nombre: string
unidadCodigo: string
fechaLimite: string // 'YYYY-MM-DD'
estado: string
diasRestantes: number
}
export interface VendedorEquipo {
mondayUserId: number
nombre: string | null
leadsAsignados: number
leadsNuevosSemana: number
}
export interface ResumenMiDia {
generadoEn: string
sinMapeoMonday: boolean
misLeads: LeadMiDia[]
misActividadesHoy: ActividadMiDia[]
apartadosPorVencer: ApartadoPorVencer[]
misOportunidades: { porEtapa: Record<string, number> } | null
notaOportunidades: string | null
conteos: {
leadsNuevosSemana: number
actividadesAbiertas: number
}
equipo?: {
leadsNuevosSinAsignar: number
porVendedor: VendedorEquipo[]
}
}
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
/** ¿El rol ve el tablero general por defecto y la sección de equipo? (misma regla que el API) */
export function esRolConEquipo(rol: string): boolean {
return ROLES_CON_EQUIPO.includes(rol)
}
export interface EtapaFunnelPersonal {
etapa: string
valor: number
}
/**
* Funnel personal: conteo de mis leads por estado (etiqueta normalizada en
* mayúsculas), ordenado de mayor a menor para que la gráfica baje.
*/
export function funnelMisLeads(misLeads: LeadMiDia[]): EtapaFunnelPersonal[] {
const conteo = new Map<string, number>()
for (const lead of misLeads) {
const estado = lead.estado.trim().toUpperCase() || 'SIN ESTADO'
conteo.set(estado, (conteo.get(estado) ?? 0) + 1)
}
return [...conteo.entries()]
.map(([etapa, valor]) => ({ etapa, valor }))
.sort((a, b) => b.valor - a.valor || a.etapa.localeCompare(b.etapa))
}

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { validarConfirmacion, validarPasswordNueva } from './password'
describe('validarPasswordNueva', () => {
it('exige ≥8 caracteres y al menos un número (misma regla que el API)', () => {
expect(validarPasswordNueva('corta1')).toContain('8 caracteres')
expect(validarPasswordNueva('sinnumeros')).toContain('número')
expect(validarPasswordNueva('')).toBeTruthy()
})
it('acepta contraseñas que cumplen la regla', () => {
expect(validarPasswordNueva('Frasa2026')).toBeNull()
expect(validarPasswordNueva('12345678')).toBeNull()
})
})
describe('validarConfirmacion', () => {
it('null si coinciden; mensaje si difieren', () => {
expect(validarConfirmacion('abc12345', 'abc12345')).toBeNull()
expect(validarConfirmacion('abc12345', 'abc12346')).toContain('no coincide')
})
})

View File

@@ -0,0 +1,16 @@
// Validación cliente de la contraseña nueva: misma regla que el API
// (api-gateway/src/services/auth.service.ts). El API vuelve a validar;
// esto solo evita un viaje de red con errores obvios.
/** null si cumple; mensaje si no (≥8 caracteres y al menos 1 número) */
export function validarPasswordNueva(nueva: string): string | null {
if (nueva.length < 8 || !/\d/.test(nueva)) {
return 'La nueva contraseña debe tener al menos 8 caracteres y un número'
}
return null
}
/** null si la confirmación coincide con la nueva; mensaje si difiere */
export function validarConfirmacion(nueva: string, confirmar: string): string | null {
return nueva === confirmar ? null : 'La confirmación no coincide con la nueva contraseña'
}

View File

@@ -1,255 +1,49 @@
import { useCallback, useEffect, useState } from 'react' import { useState } from 'react'
import { import MiTablero from '../components/MiTablero'
Bar, import TableroGeneral from '../components/TableroGeneral'
BarChart, import { esRolConEquipo } from '../lib/mi-tablero'
CartesianGrid, import { getSesion } from '../services/auth'
Cell,
Funnel,
FunnelChart,
LabelList,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import KpiCard from '../components/KpiCard'
import {
datosCobranza,
datosFunnel,
datosPipeline,
formatoMoneda,
formatoPorcentaje,
unidadesApiladas,
unidadesEnEtapa,
type ResumenReportes,
} from '../lib/reportes'
import { fetchResumen } from '../services/reportes'
// Paleta StudioBlank: acento verde + neutros (sin gradientes ni sombras) type Vista = 'mia' | 'general'
const COLORES_STATUS: Record<string, string> = {
DISPONIBLE: '#1B4332',
APARTADO: '#b45309',
VENDIDO: '#525252',
BLOQUEADO: '#b91c1c',
OTROS: '#d4d4d4',
}
const COLORES_FUNNEL = ['#a3a3a3', '#737373', '#52796f', '#1B4332']
const COLORES_COBRANZA = ['#1B4332', '#b45309']
function EstadoVacio({ mensaje }: { mensaje: string }) { const VISTAS: { id: Vista; etiqueta: string }[] = [
return ( { id: 'mia', etiqueta: 'Mi tablero' },
<div className="flex h-64 items-center justify-center border border-dashed border-neutral-300"> { id: 'general', etiqueta: 'General' },
<p className="text-xs font-bold uppercase tracking-widest text-neutral-400">{mensaje}</p> ]
</div>
)
}
function TituloSeccion({ children }: { children: string }) {
return <h2 className="mb-4 text-xs font-bold uppercase tracking-widest text-neutral-500">{children}</h2>
}
/**
* Tableros con vista por rol: el vendedor entra a "Mi tablero" (sus KPIs y el
* funnel de sus leads); coordinador/corporativo/admin entran a "General" con
* la sección Equipo. El toggle permite cambiar de vista en cualquier momento.
*/
export default function DashboardPage() { export default function DashboardPage() {
const [resumen, setResumen] = useState<ResumenReportes | null>(null) const rol = getSesion()?.usuario.rol ?? 'vendedor'
const [error, setError] = useState<string | null>(null) const conEquipo = esRolConEquipo(rol)
const [cargando, setCargando] = useState(true) const [vista, setVista] = useState<Vista>(conEquipo ? 'general' : 'mia')
const cargar = useCallback(async () => {
setCargando(true)
try {
setResumen(await fetchResumen())
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero')
} finally {
setCargando(false)
}
}, [])
useEffect(() => {
void cargar()
}, [cargar])
const unidades = resumen ? unidadesApiladas(resumen) : []
const funnel = resumen ? datosFunnel(resumen) : []
const cobranza = resumen ? datosCobranza(resumen) : []
const pipeline = resumen ? datosPipeline(resumen) : []
const sinPipeline = pipeline.every((e) => e.valor === 0)
const sinCobranza = resumen !== null && resumen.cobranza.planesAlCorriente + resumen.cobranza.planesConVencidos === 0
return ( return (
<main className="mx-auto max-w-6xl px-4 py-8">
<div className="mb-8 flex items-end justify-between">
<div> <div>
<p className="text-xs font-bold uppercase tracking-widest text-accent">FRASA Desarrollos</p> <div className="border-b border-neutral-200 bg-white">
<h1 className="mt-2 text-3xl font-light text-neutral-900">Tablero comercial</h1> <div className="mx-auto flex max-w-6xl gap-2 px-4 py-3" role="tablist" aria-label="Vista del tablero">
{resumen && ( {VISTAS.map((v) => (
<p className="mt-1 text-xs text-neutral-400">
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
</p>
)}
</div>
<button <button
key={v.id}
type="button" type="button"
onClick={() => void cargar()} role="tab"
disabled={cargando} aria-selected={vista === v.id}
className="border border-accent px-5 py-2 text-xs font-bold uppercase tracking-widest text-accent transition-colors duration-150 hover:bg-accent hover:text-white disabled:opacity-50" onClick={() => setVista(v.id)}
className={
vista === v.id
? 'bg-accent px-5 py-2 text-xs font-bold uppercase tracking-widest text-white'
: 'border border-neutral-300 px-5 py-2 text-xs font-bold uppercase tracking-widest text-neutral-500 transition-colors duration-150 hover:border-neutral-500'
}
> >
{cargando ? 'Actualizando…' : 'Actualizar'} {v.etiqueta}
</button> </button>
</div>
{error && (
<p role="alert" className="mb-8 border border-amber-700 px-4 py-3 text-xs font-bold uppercase tracking-widest text-amber-700">
Sin datos del API — {error}
</p>
)}
{!resumen && !error && cargando && <EstadoVacio mensaje="Cargando…" />}
{!resumen && !error && !cargando && <EstadoVacio mensaje="Sin datos" />}
{resumen && (
<>
<section aria-label="Indicadores clave" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard
titulo="Leads totales"
valor={resumen.leads.total}
detalle={`${resumen.leads.nuevosHoy} hoy · ${resumen.leads.nuevosSemana} esta semana`}
/>
<KpiCard
titulo="Oportunidades activas"
valor={resumen.oportunidades.activas}
detalle={`${resumen.oportunidades.total} en total`}
/>
<KpiCard
titulo="Conversión Aparto / Ganado"
valor={`${formatoPorcentaje(resumen.oportunidades.tasaAparto)} / ${formatoPorcentaje(resumen.oportunidades.tasaGanado)}`}
/>
<KpiCard
titulo="Unidades disponibles"
valor={resumen.unidades.disponibles}
detalle={`${resumen.unidades.total} en inventario`}
/>
<KpiCard
titulo="Cartera vencida"
valor={formatoMoneda(resumen.cobranza.montoVencido)}
detalle={`${resumen.cobranza.pagosVencidos} pago(s) vencido(s)`}
/>
<KpiCard
titulo="Planes activos"
valor={resumen.cobranza.planes.activos}
detalle={`${resumen.cobranza.planes.liquidados} liquidados · ${resumen.cobranza.planes.cancelados} cancelados`}
/>
<KpiCard titulo="Actividades abiertas" valor={resumen.actividadesAbiertas} />
<KpiCard
titulo="En notaría"
valor={unidadesEnEtapa(resumen, 'Notaría')}
detalle="unidades en etapa Notaría"
/>
<KpiCard
titulo="Leads por contactar"
valor={resumen.leads.porEstado['NUEVO'] ?? 0}
detalle="en estado NUEVO"
/>
</section>
<section aria-label="Inventario por desarrollo" className="mt-12">
<TituloSeccion>Unidades por desarrollo y status</TituloSeccion>
{unidades.length === 0 ? (
<EstadoVacio mensaje="Sin inventario" />
) : (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={unidades} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="desarrollo" tick={{ fontSize: 11 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 11 }} />
{Object.keys(COLORES_STATUS).map((status) => (
<Bar key={status} dataKey={status} stackId="unidades" fill={COLORES_STATUS[status]} />
))} ))}
</BarChart>
</ResponsiveContainer>
</div> </div>
)}
</section>
<section aria-label="Pipeline de ventas por etapa" className="mt-12">
<TituloSeccion>Pipeline de ventas por etapa</TituloSeccion>
{sinPipeline ? (
<EstadoVacio mensaje="Sin unidades en el pipeline" />
) : (
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={pipeline} layout="vertical" margin={{ top: 8, right: 24, left: 24, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} />
<YAxis type="category" dataKey="etapa" width={90} tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="valor" name="Unidades" fill="#1B4332" isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div> </div>
)} {vista === 'mia' ? <MiTablero /> : <TableroGeneral mostrarEquipo={conEquipo} />}
</section>
<div className="mt-12 grid gap-12 lg:grid-cols-2">
<section aria-label="Embudo comercial">
<TituloSeccion>Embudo: leads → oportunidades → ganado</TituloSeccion>
{funnel.every((e) => e.valor === 0) ? (
<EstadoVacio mensaje="Sin movimiento comercial" />
) : (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<FunnelChart>
<Tooltip />
<Funnel dataKey="valor" data={funnel} nameKey="etapa" isAnimationActive={false}>
{funnel.map((etapa, i) => (
<Cell key={etapa.etapa} fill={COLORES_FUNNEL[i % COLORES_FUNNEL.length]} />
))}
<LabelList position="right" dataKey="etapa" stroke="none" fontSize={11} />
<LabelList position="center" dataKey="valor" stroke="none" fill="#fff" fontSize={12} />
</Funnel>
</FunnelChart>
</ResponsiveContainer>
</div> </div>
)}
</section>
<section aria-label="Estado de la cobranza">
<TituloSeccion>Cobranza: al corriente vs vencidos</TituloSeccion>
{sinCobranza ? (
<EstadoVacio mensaje="Sin planes activos" />
) : (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={cobranza}
dataKey="valor"
nameKey="nombre"
innerRadius={60}
outerRadius={90}
isAnimationActive={false}
label={({ name, value }) => `${name}: ${value}`}
fontSize={11}
>
{cobranza.map((rebanada, i) => (
<Cell key={rebanada.nombre} fill={COLORES_COBRANZA[i % COLORES_COBRANZA.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
)}
</section>
</div>
</>
)}
</main>
) )
} }

View File

@@ -75,3 +75,28 @@ export function manejar401(res: Response): void {
logout() logout()
if (typeof window !== 'undefined') window.location.reload() if (typeof window !== 'undefined') window.location.reload()
} }
/**
* POST /auth/cambiar-password. Lanza Error con el mensaje del API cuando
* falla (p. ej. "La contraseña actual no es correcta"); no toca la sesión.
*/
export async function cambiarPassword(actual: string, nueva: string): Promise<void> {
let res: Response
try {
res = await fetch(
`${API_URL}/auth/cambiar-password`,
conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actual, nueva }),
}),
)
} catch {
throw new Error('No se pudo conectar con el servidor')
}
manejar401(res)
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `Error ${res.status}`)
}
}

View File

@@ -0,0 +1,18 @@
// Cliente del endpoint "Mi día" del API Gateway (para Mi tablero y la sección Equipo)
import { conAuth, manejar401 } from './auth'
import type { ResumenMiDia } from '../lib/mi-tablero'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
/** GET /mi-dia/resumen; lanza Error con mensaje legible si el API falla */
export async function fetchResumenMiDia(): Promise<ResumenMiDia> {
let res: Response
try {
res = await fetch(`${API_URL}/mi-dia/resumen`, conAuth())
} catch {
throw new Error('No se pudo conectar con el servidor')
}
manejar401(res)
if (!res.ok) throw new Error(`El API respondió ${res.status}`)
return (await res.json()) as ResumenMiDia
}

View File

@@ -3,6 +3,9 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1B4332" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<meta name="description" content="Página de entrada al ecosistema comercial de FRASA Desarrollos" /> <meta name="description" content="Página de entrada al ecosistema comercial de FRASA Desarrollos" />
<title>FRASA Desarrollos — Ecosistema comercial</title> <title>FRASA Desarrollos — Ecosistema comercial</title>
</head> </head>

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,13 @@
{
"name": "Frasa Desarrollos",
"short_name": "Frasa",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1B4332",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}

View File

@@ -3,6 +3,9 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1B4332" />
<link rel="manifest" href="/hoy/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/hoy/icons/icon-192.png" />
<title>FRASA Desarrollos — Mi día</title> <title>FRASA Desarrollos — Mi día</title>
</head> </head>
<body> <body>

Binary file not shown.

After

Width:  |  Height:  |  Size: 643 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,13 @@
{
"name": "Frasa · Mi día",
"short_name": "Mi día",
"start_url": "/hoy/",
"scope": "/hoy/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1B4332",
"icons": [
{ "src": "/hoy/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/hoy/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}

View File

@@ -1,13 +1,16 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { getSesion, logout, type Sesion } from '../services/auth' import { getSesion, logout, type Sesion } from '../services/auth'
import CambiarPasswordModal from './CambiarPasswordModal'
import LoginScreen from './LoginScreen' import LoginScreen from './LoginScreen'
/** /**
* Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la * Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la
* app con una barra discreta "nombre · rol" y botón "Salir". * app con una barra discreta "nombre · rol", "Contraseña" (modal de cambio)
* y botón "Salir".
*/ */
export default function AuthGate({ children }: { children: ReactNode }) { export default function AuthGate({ children }: { children: ReactNode }) {
const [sesion, setSesion] = useState<Sesion | null>(() => getSesion()) const [sesion, setSesion] = useState<Sesion | null>(() => getSesion())
const [modalPassword, setModalPassword] = useState(false)
if (!sesion) { if (!sesion) {
return <LoginScreen onLogin={() => setSesion(getSesion())} /> return <LoginScreen onLogin={() => setSesion(getSesion())} />
@@ -20,6 +23,14 @@ export default function AuthGate({ children }: { children: ReactNode }) {
<span className="text-xs font-bold uppercase tracking-widest text-neutral-500"> <span className="text-xs font-bold uppercase tracking-widest text-neutral-500">
{sesion.usuario.nombre} · {sesion.usuario.rol} {sesion.usuario.nombre} · {sesion.usuario.rol}
</span> </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"
>
Contraseña
</button>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -32,7 +43,9 @@ export default function AuthGate({ children }: { children: ReactNode }) {
</button> </button>
</div> </div>
</div> </div>
</div>
{children} {children}
{modalPassword && <CambiarPasswordModal onCerrar={() => setModalPassword(false)} />}
</div> </div>
) )
} }

View File

@@ -0,0 +1,140 @@
import { useState, type FormEvent } from 'react'
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'
/**
* Modal "Cambiar contraseña": actual + nueva + confirmar. Valida en cliente
* con la misma regla del API y muestra el mensaje del API si la actual no es
* correcta. Al éxito la sesión sigue intacta (el JWT no cambia).
*/
export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => void }) {
const [actual, setActual] = useState('')
const [nueva, setNueva] = useState('')
const [confirmar, setConfirmar] = useState('')
const [error, setError] = useState<string | null>(null)
const [exito, setExito] = useState(false)
const [cargando, setCargando] = useState(false)
async function enviar(e: FormEvent) {
e.preventDefault()
const errorCliente = validarPasswordNueva(nueva) ?? validarConfirmacion(nueva, confirmar)
if (errorCliente) {
setError(errorCliente)
return
}
setError(null)
setCargando(true)
try {
await cambiarPassword(actual, nueva)
setExito(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cambiar la contraseña')
} finally {
setCargando(false)
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
role="dialog"
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">
Cambiar contraseña
</h2>
{exito ? (
<>
<p className="border border-accent px-4 py-3 text-xs font-bold uppercase tracking-widest text-accent">
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"
>
Cerrar
</button>
</>
) : (
<form onSubmit={enviar} className="space-y-6">
<div>
<label htmlFor="password-actual" className={CLASE_LABEL}>
Contraseña actual
</label>
<input
id="password-actual"
type="password"
value={actual}
onChange={(e) => setActual(e.target.value)}
autoComplete="current-password"
autoFocus
required
className={CLASE_INPUT}
/>
</div>
<div>
<label htmlFor="password-nueva" className={CLASE_LABEL}>
Nueva contraseña
</label>
<input
id="password-nueva"
type="password"
value={nueva}
onChange={(e) => setNueva(e.target.value)}
autoComplete="new-password"
required
className={CLASE_INPUT}
/>
<p className="mt-2 text-xs text-neutral-400">Mínimo 8 caracteres y un número</p>
</div>
<div>
<label htmlFor="password-confirmar" className={CLASE_LABEL}>
Confirmar nueva contraseña
</label>
<input
id="password-confirmar"
type="password"
value={confirmar}
onChange={(e) => setConfirmar(e.target.value)}
autoComplete="new-password"
required
className={CLASE_INPUT}
/>
</div>
{error && (
<p
role="alert"
className="border border-red-700 px-4 py-3 text-xs font-bold uppercase tracking-widest text-red-700"
>
{error}
</p>
)}
<div className="flex gap-3">
<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"
>
{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"
>
Cancelar
</button>
</div>
</form>
)}
</div>
</div>
)
}

View File

@@ -41,7 +41,12 @@ export interface ResumenMiDia {
} }
equipo?: { equipo?: {
leadsNuevosSinAsignar: number leadsNuevosSinAsignar: number
porVendedor: { mondayUserId: number; nombre: string | null; leadsAsignados: number }[] porVendedor: {
mondayUserId: number
nombre: string | null
leadsAsignados: number
leadsNuevosSemana: number
}[]
} }
} }

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { validarConfirmacion, validarPasswordNueva } from './password'
describe('validarPasswordNueva', () => {
it('exige ≥8 caracteres y al menos un número (misma regla que el API)', () => {
expect(validarPasswordNueva('corta1')).toContain('8 caracteres')
expect(validarPasswordNueva('sinnumeros')).toContain('número')
expect(validarPasswordNueva('')).toBeTruthy()
})
it('acepta contraseñas que cumplen la regla', () => {
expect(validarPasswordNueva('Frasa2026')).toBeNull()
expect(validarPasswordNueva('12345678')).toBeNull()
})
})
describe('validarConfirmacion', () => {
it('null si coinciden; mensaje si difieren', () => {
expect(validarConfirmacion('abc12345', 'abc12345')).toBeNull()
expect(validarConfirmacion('abc12345', 'abc12346')).toContain('no coincide')
})
})

View File

@@ -0,0 +1,16 @@
// Validación cliente de la contraseña nueva: misma regla que el API
// (api-gateway/src/services/auth.service.ts). El API vuelve a validar;
// esto solo evita un viaje de red con errores obvios.
/** null si cumple; mensaje si no (≥8 caracteres y al menos 1 número) */
export function validarPasswordNueva(nueva: string): string | null {
if (nueva.length < 8 || !/\d/.test(nueva)) {
return 'La nueva contraseña debe tener al menos 8 caracteres y un número'
}
return null
}
/** null si la confirmación coincide con la nueva; mensaje si difiere */
export function validarConfirmacion(nueva: string, confirmar: string): string | null {
return nueva === confirmar ? null : 'La confirmación no coincide con la nueva contraseña'
}

View File

@@ -75,3 +75,28 @@ export function manejar401(res: Response): void {
logout() logout()
if (typeof window !== 'undefined') window.location.reload() if (typeof window !== 'undefined') window.location.reload()
} }
/**
* POST /auth/cambiar-password. Lanza Error con el mensaje del API cuando
* falla (p. ej. "La contraseña actual no es correcta"); no toca la sesión.
*/
export async function cambiarPassword(actual: string, nueva: string): Promise<void> {
let res: Response
try {
res = await fetch(
`${API_URL}/auth/cambiar-password`,
conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actual, nueva }),
}),
)
} catch {
throw new Error('No se pudo conectar con el servidor')
}
manejar401(res)
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `Error ${res.status}`)
}
}

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Genera los íconos PWA de las 7 apps del ecosistema FRASA.
Ícono: cuadrado #1B4332 (verde marca) con la inicial del módulo en blanco,
en 192×192 y 512×512 PNG, en apps/<app>/public/icons/.
Uso: /root/.venv-tools/bin/python scripts/generar-iconos-pwa.py
"""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
VERDE = (0x1B, 0x43, 0x32) # #1B4332, --color-accent StudioBlank
BLANCO = (0xFF, 0xFF, 0xFF)
TAMANOS = (192, 512)
FUENTE = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
# app → letra del ícono
APPS = {
"landing": "F",
"cotizador": "C",
"alta-clientes": "A",
"cobranza": "$",
"agente-ia": "I",
"dashboards": "D",
"mi-dia": "H",
}
RAIZ = Path(__file__).resolve().parent.parent
def generar(letra: str, tamano: int, destino: Path) -> None:
imagen = Image.new("RGB", (tamano, tamano), VERDE)
dibujo = ImageDraw.Draw(imagen)
fuente = ImageFont.truetype(FUENTE, size=int(tamano * 0.55))
caja = dibujo.textbbox((0, 0), letra, font=fuente)
ancho, alto = caja[2] - caja[0], caja[3] - caja[1]
x = (tamano - ancho) / 2 - caja[0]
y = (tamano - alto) / 2 - caja[1]
dibujo.text((x, y), letra, font=fuente, fill=BLANCO)
imagen.save(destino, "PNG")
def main() -> None:
for app, letra in APPS.items():
directorio = RAIZ / "apps" / app / "public" / "icons"
directorio.mkdir(parents=True, exist_ok=True)
for tamano in TAMANOS:
destino = directorio / f"icon-{tamano}.png"
generar(letra, tamano, destino)
print(f"{app}: {destino.relative_to(RAIZ)}")
if __name__ == "__main__":
main()