diff --git a/README.md b/README.md index 822dee4..6c7097d 100644 --- a/README.md +++ b/README.md @@ -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/cobranza` | `/pay/` | Planes de enganche, vencidos, estados de cuenta | | `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 | +## PWA (instalabilidad básica) + +Las 7 apps son instalables (manifest + íconos): cada una tiene +`public/manifest.webmanifest` ("Frasa · \", `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 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`. diff --git a/api-gateway/src/controllers/auth.controller.test.ts b/api-gateway/src/controllers/auth.controller.test.ts new file mode 100644 index 0000000..ec9f039 --- /dev/null +++ b/api-gateway/src/controllers/auth.controller.test.ts @@ -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 { + 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 }) + }) +}) diff --git a/api-gateway/src/controllers/auth.controller.ts b/api-gateway/src/controllers/auth.controller.ts index c4b6a16..553a2a5 100644 --- a/api-gateway/src/controllers/auth.controller.ts +++ b/api-gateway/src/controllers/auth.controller.ts @@ -1,6 +1,6 @@ import type { Request, Response } from 'express' 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' const loginSchema = z.object({ @@ -20,3 +20,16 @@ export async function meEndpoint(req: Request, res: Response): Promise { if (!req.usuarioToken) throw new BadRequestError('Falta el token verificado') 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 { + 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() +} diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index c8da20c..009e348 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -62,7 +62,7 @@ import { obtenerConversacion, subirDocumento, } 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' export const apiRouter = Router() @@ -71,6 +71,7 @@ apiRouter.get('/health', health) apiRouter.post('/auth/login', loginEndpoint) apiRouter.get('/auth/me', meEndpoint) +apiRouter.post('/auth/cambiar-password', cambiarPasswordEndpoint) apiRouter.get('/unidades', listarUnidades) apiRouter.get('/unidades/:codigo', obtenerUnidad) diff --git a/api-gateway/src/services/auth.service.test.ts b/api-gateway/src/services/auth.service.test.ts index 6ce74a4..41d0bdd 100644 --- a/api-gateway/src/services/auth.service.test.ts +++ b/api-gateway/src/services/auth.service.test.ts @@ -1,13 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import bcrypt from 'bcryptjs' 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) ---------- const prismaMock = vi.hoisted(() => ({ usuario: { 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') }) }) + +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', + ) + }) +}) diff --git a/api-gateway/src/services/auth.service.ts b/api-gateway/src/services/auth.service.ts index 87d4971..8e33364 100644 --- a/api-gateway/src/services/auth.service.ts +++ b/api-gateway/src/services/auth.service.ts @@ -81,3 +81,40 @@ export async function me(payload: TokenPayload): Promise { if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas') 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 { + 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 } }) +} diff --git a/api-gateway/src/services/mi-dia.service.test.ts b/api-gateway/src/services/mi-dia.service.test.ts index dbfa859..6f24bc6 100644 --- a/api-gateway/src/services/mi-dia.service.test.ts +++ b/api-gateway/src/services/mi-dia.service.test.ts @@ -166,6 +166,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { lead({ id: '3', vendedorIds: [MI_ID] }), lead({ id: '4', vendedorIds: [MI_ID], estado: 'DESCARTADO' }), // terminal: no cuenta 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']]) @@ -176,8 +177,8 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { const rCoord = await resumenMiDia(monday, coordinador, nombres, AHORA) expect(rCoord.equipo?.leadsNuevosSinAsignar).toBe(1) expect(rCoord.equipo?.porVendedor).toEqual([ - { mondayUserId: MI_ID, nombre: 'Martin Zavala', leadsAsignados: 1 }, - { mondayUserId: OTRO_ID, nombre: null, leadsAsignados: 1 }, + { mondayUserId: OTRO_ID, nombre: null, leadsAsignados: 2, leadsNuevosSemana: 1 }, + { mondayUserId: MI_ID, nombre: 'Martin Zavala', leadsAsignados: 1, leadsNuevosSemana: 1 }, ]) }) diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts index 085aa7a..cdc0af3 100644 --- a/api-gateway/src/services/mi-dia.service.ts +++ b/api-gateway/src/services/mi-dia.service.ts @@ -64,7 +64,13 @@ export interface ResumenMiDia { // Solo roles coordinador/corporativo/admin equipo?: { 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)) { const sinAsignar = leads.filter((l) => l.vendedorIds.length === 0 && !esEstadoTerminalLead(l.estado)) const conteoPorId = new Map() + const nuevosPorId = new Map() for (const l of leads) { 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 = { leadsNuevosSinAsignar: sinAsignar.filter((l) => l.estado.trim().toUpperCase() === 'NUEVO').length, @@ -181,6 +192,7 @@ export async function resumenMiDia( mondayUserId, nombre: nombresPorMondayId.get(mondayUserId) ?? null, leadsAsignados, + leadsNuevosSemana: nuevosPorId.get(mondayUserId) ?? 0, })) .sort((a, b) => b.leadsAsignados - a.leadsAsignados), } diff --git a/apps/agente-ia/index.html b/apps/agente-ia/index.html index 18a7f84..5d49b99 100644 --- a/apps/agente-ia/index.html +++ b/apps/agente-ia/index.html @@ -3,6 +3,9 @@ + + + FRASA Desarrollos — Asistente IA diff --git a/apps/agente-ia/public/icons/icon-192.png b/apps/agente-ia/public/icons/icon-192.png new file mode 100644 index 0000000..41d9bab Binary files /dev/null and b/apps/agente-ia/public/icons/icon-192.png differ diff --git a/apps/agente-ia/public/icons/icon-512.png b/apps/agente-ia/public/icons/icon-512.png new file mode 100644 index 0000000..47693c3 Binary files /dev/null and b/apps/agente-ia/public/icons/icon-512.png differ diff --git a/apps/agente-ia/public/manifest.webmanifest b/apps/agente-ia/public/manifest.webmanifest new file mode 100644 index 0000000..bfe5e13 --- /dev/null +++ b/apps/agente-ia/public/manifest.webmanifest @@ -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" } + ] +} diff --git a/apps/agente-ia/src/components/AuthGate.tsx b/apps/agente-ia/src/components/AuthGate.tsx index cbe78f9..baba256 100644 --- a/apps/agente-ia/src/components/AuthGate.tsx +++ b/apps/agente-ia/src/components/AuthGate.tsx @@ -1,13 +1,16 @@ import { useState, type ReactNode } from 'react' import { getSesion, logout, type Sesion } from '../services/auth' +import CambiarPasswordModal from './CambiarPasswordModal' import LoginScreen from './LoginScreen' /** * Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la - * app con una barra discreta "nombre · rol" 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 }) { const [sesion, setSesion] = useState(() => getSesion()) + const [modalPassword, setModalPassword] = useState(false) if (!sesion) { return setSesion(getSesion())} /> @@ -20,19 +23,29 @@ export default function AuthGate({ children }: { children: ReactNode }) { {sesion.usuario.nombre} · {sesion.usuario.rol} - +
+ + +
{children} + {modalPassword && setModalPassword(false)} />} ) } diff --git a/apps/agente-ia/src/components/CambiarPasswordModal.tsx b/apps/agente-ia/src/components/CambiarPasswordModal.tsx new file mode 100644 index 0000000..1be9e05 --- /dev/null +++ b/apps/agente-ia/src/components/CambiarPasswordModal.tsx @@ -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(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 ( +
+
+

+ Cambiar contraseña +

+ {exito ? ( + <> +

+ Contraseña actualizada +

+ + + ) : ( +
+
+ + setActual(e.target.value)} + autoComplete="current-password" + autoFocus + required + className={CLASE_INPUT} + /> +
+
+ + setNueva(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +

Mínimo 8 caracteres y un número

+
+
+ + setConfirmar(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +
+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+ )} +
+
+ ) +} diff --git a/apps/agente-ia/src/lib/password.test.ts b/apps/agente-ia/src/lib/password.test.ts new file mode 100644 index 0000000..e5d540f --- /dev/null +++ b/apps/agente-ia/src/lib/password.test.ts @@ -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') + }) +}) diff --git a/apps/agente-ia/src/lib/password.ts b/apps/agente-ia/src/lib/password.ts new file mode 100644 index 0000000..a045f28 --- /dev/null +++ b/apps/agente-ia/src/lib/password.ts @@ -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' +} diff --git a/apps/agente-ia/src/services/auth.test.ts b/apps/agente-ia/src/services/auth.test.ts index 8884d13..e54961d 100644 --- a/apps/agente-ia/src/services/auth.test.ts +++ b/apps/agente-ia/src/services/auth.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ErrorLogin, + cambiarPassword, conAuth, getSesion, getToken, @@ -122,3 +123,37 @@ describe('manejar401', () => { 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') + }) +}) diff --git a/apps/agente-ia/src/services/auth.ts b/apps/agente-ia/src/services/auth.ts index a6919e1..70cd037 100644 --- a/apps/agente-ia/src/services/auth.ts +++ b/apps/agente-ia/src/services/auth.ts @@ -75,3 +75,28 @@ export function manejar401(res: Response): void { logout() 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 { + 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}`) + } +} diff --git a/apps/alta-clientes/index.html b/apps/alta-clientes/index.html index 89c95eb..5f80773 100644 --- a/apps/alta-clientes/index.html +++ b/apps/alta-clientes/index.html @@ -3,6 +3,9 @@ + + + FRASA Desarrollos — Alta de Clientes diff --git a/apps/alta-clientes/public/icons/icon-192.png b/apps/alta-clientes/public/icons/icon-192.png new file mode 100644 index 0000000..a15da42 Binary files /dev/null and b/apps/alta-clientes/public/icons/icon-192.png differ diff --git a/apps/alta-clientes/public/icons/icon-512.png b/apps/alta-clientes/public/icons/icon-512.png new file mode 100644 index 0000000..a3d9675 Binary files /dev/null and b/apps/alta-clientes/public/icons/icon-512.png differ diff --git a/apps/alta-clientes/public/manifest.webmanifest b/apps/alta-clientes/public/manifest.webmanifest new file mode 100644 index 0000000..54df02e --- /dev/null +++ b/apps/alta-clientes/public/manifest.webmanifest @@ -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" } + ] +} diff --git a/apps/alta-clientes/src/components/AuthGate.tsx b/apps/alta-clientes/src/components/AuthGate.tsx index cbe78f9..baba256 100644 --- a/apps/alta-clientes/src/components/AuthGate.tsx +++ b/apps/alta-clientes/src/components/AuthGate.tsx @@ -1,13 +1,16 @@ import { useState, type ReactNode } from 'react' import { getSesion, logout, type Sesion } from '../services/auth' +import CambiarPasswordModal from './CambiarPasswordModal' import LoginScreen from './LoginScreen' /** * Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la - * app con una barra discreta "nombre · rol" 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 }) { const [sesion, setSesion] = useState(() => getSesion()) + const [modalPassword, setModalPassword] = useState(false) if (!sesion) { return setSesion(getSesion())} /> @@ -20,19 +23,29 @@ export default function AuthGate({ children }: { children: ReactNode }) { {sesion.usuario.nombre} · {sesion.usuario.rol} - +
+ + +
{children} + {modalPassword && setModalPassword(false)} />} ) } diff --git a/apps/alta-clientes/src/components/CambiarPasswordModal.tsx b/apps/alta-clientes/src/components/CambiarPasswordModal.tsx new file mode 100644 index 0000000..1be9e05 --- /dev/null +++ b/apps/alta-clientes/src/components/CambiarPasswordModal.tsx @@ -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(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 ( +
+
+

+ Cambiar contraseña +

+ {exito ? ( + <> +

+ Contraseña actualizada +

+ + + ) : ( +
+
+ + setActual(e.target.value)} + autoComplete="current-password" + autoFocus + required + className={CLASE_INPUT} + /> +
+
+ + setNueva(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +

Mínimo 8 caracteres y un número

+
+
+ + setConfirmar(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +
+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+ )} +
+
+ ) +} diff --git a/apps/alta-clientes/src/lib/password.test.ts b/apps/alta-clientes/src/lib/password.test.ts new file mode 100644 index 0000000..e5d540f --- /dev/null +++ b/apps/alta-clientes/src/lib/password.test.ts @@ -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') + }) +}) diff --git a/apps/alta-clientes/src/lib/password.ts b/apps/alta-clientes/src/lib/password.ts new file mode 100644 index 0000000..a045f28 --- /dev/null +++ b/apps/alta-clientes/src/lib/password.ts @@ -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' +} diff --git a/apps/alta-clientes/src/services/auth.test.ts b/apps/alta-clientes/src/services/auth.test.ts index 8884d13..e54961d 100644 --- a/apps/alta-clientes/src/services/auth.test.ts +++ b/apps/alta-clientes/src/services/auth.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ErrorLogin, + cambiarPassword, conAuth, getSesion, getToken, @@ -122,3 +123,37 @@ describe('manejar401', () => { 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') + }) +}) diff --git a/apps/alta-clientes/src/services/auth.ts b/apps/alta-clientes/src/services/auth.ts index a6919e1..70cd037 100644 --- a/apps/alta-clientes/src/services/auth.ts +++ b/apps/alta-clientes/src/services/auth.ts @@ -75,3 +75,28 @@ export function manejar401(res: Response): void { logout() 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 { + 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}`) + } +} diff --git a/apps/cobranza/index.html b/apps/cobranza/index.html index 61c2647..6add54d 100644 --- a/apps/cobranza/index.html +++ b/apps/cobranza/index.html @@ -3,6 +3,9 @@ + + + FRASA Desarrollos — Cobranza diff --git a/apps/cobranza/public/icons/icon-192.png b/apps/cobranza/public/icons/icon-192.png new file mode 100644 index 0000000..6231754 Binary files /dev/null and b/apps/cobranza/public/icons/icon-192.png differ diff --git a/apps/cobranza/public/icons/icon-512.png b/apps/cobranza/public/icons/icon-512.png new file mode 100644 index 0000000..f5cf0bc Binary files /dev/null and b/apps/cobranza/public/icons/icon-512.png differ diff --git a/apps/cobranza/public/manifest.webmanifest b/apps/cobranza/public/manifest.webmanifest new file mode 100644 index 0000000..b6579dc --- /dev/null +++ b/apps/cobranza/public/manifest.webmanifest @@ -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" } + ] +} diff --git a/apps/cobranza/src/components/AuthGate.tsx b/apps/cobranza/src/components/AuthGate.tsx index cbe78f9..baba256 100644 --- a/apps/cobranza/src/components/AuthGate.tsx +++ b/apps/cobranza/src/components/AuthGate.tsx @@ -1,13 +1,16 @@ import { useState, type ReactNode } from 'react' import { getSesion, logout, type Sesion } from '../services/auth' +import CambiarPasswordModal from './CambiarPasswordModal' import LoginScreen from './LoginScreen' /** * Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la - * app con una barra discreta "nombre · rol" 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 }) { const [sesion, setSesion] = useState(() => getSesion()) + const [modalPassword, setModalPassword] = useState(false) if (!sesion) { return setSesion(getSesion())} /> @@ -20,19 +23,29 @@ export default function AuthGate({ children }: { children: ReactNode }) { {sesion.usuario.nombre} · {sesion.usuario.rol} - +
+ + +
{children} + {modalPassword && setModalPassword(false)} />} ) } diff --git a/apps/cobranza/src/components/CambiarPasswordModal.tsx b/apps/cobranza/src/components/CambiarPasswordModal.tsx new file mode 100644 index 0000000..1be9e05 --- /dev/null +++ b/apps/cobranza/src/components/CambiarPasswordModal.tsx @@ -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(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 ( +
+
+

+ Cambiar contraseña +

+ {exito ? ( + <> +

+ Contraseña actualizada +

+ + + ) : ( +
+
+ + setActual(e.target.value)} + autoComplete="current-password" + autoFocus + required + className={CLASE_INPUT} + /> +
+
+ + setNueva(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +

Mínimo 8 caracteres y un número

+
+
+ + setConfirmar(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +
+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+ )} +
+
+ ) +} diff --git a/apps/cobranza/src/lib/password.test.ts b/apps/cobranza/src/lib/password.test.ts new file mode 100644 index 0000000..e5d540f --- /dev/null +++ b/apps/cobranza/src/lib/password.test.ts @@ -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') + }) +}) diff --git a/apps/cobranza/src/lib/password.ts b/apps/cobranza/src/lib/password.ts new file mode 100644 index 0000000..a045f28 --- /dev/null +++ b/apps/cobranza/src/lib/password.ts @@ -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' +} diff --git a/apps/cobranza/src/services/auth.test.ts b/apps/cobranza/src/services/auth.test.ts index 8884d13..e54961d 100644 --- a/apps/cobranza/src/services/auth.test.ts +++ b/apps/cobranza/src/services/auth.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ErrorLogin, + cambiarPassword, conAuth, getSesion, getToken, @@ -122,3 +123,37 @@ describe('manejar401', () => { 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') + }) +}) diff --git a/apps/cobranza/src/services/auth.ts b/apps/cobranza/src/services/auth.ts index a6919e1..70cd037 100644 --- a/apps/cobranza/src/services/auth.ts +++ b/apps/cobranza/src/services/auth.ts @@ -75,3 +75,28 @@ export function manejar401(res: Response): void { logout() 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 { + 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}`) + } +} diff --git a/apps/cotizador/index.html b/apps/cotizador/index.html index a9995db..26e8784 100644 --- a/apps/cotizador/index.html +++ b/apps/cotizador/index.html @@ -3,6 +3,9 @@ + + + FRASA Desarrollos — Cotizador diff --git a/apps/cotizador/public/icons/icon-192.png b/apps/cotizador/public/icons/icon-192.png new file mode 100644 index 0000000..bfe09eb Binary files /dev/null and b/apps/cotizador/public/icons/icon-192.png differ diff --git a/apps/cotizador/public/icons/icon-512.png b/apps/cotizador/public/icons/icon-512.png new file mode 100644 index 0000000..7008a0d Binary files /dev/null and b/apps/cotizador/public/icons/icon-512.png differ diff --git a/apps/cotizador/public/manifest.webmanifest b/apps/cotizador/public/manifest.webmanifest new file mode 100644 index 0000000..9d10068 --- /dev/null +++ b/apps/cotizador/public/manifest.webmanifest @@ -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" } + ] +} diff --git a/apps/cotizador/src/components/AuthGate.tsx b/apps/cotizador/src/components/AuthGate.tsx index cbe78f9..baba256 100644 --- a/apps/cotizador/src/components/AuthGate.tsx +++ b/apps/cotizador/src/components/AuthGate.tsx @@ -1,13 +1,16 @@ import { useState, type ReactNode } from 'react' import { getSesion, logout, type Sesion } from '../services/auth' +import CambiarPasswordModal from './CambiarPasswordModal' import LoginScreen from './LoginScreen' /** * Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la - * app con una barra discreta "nombre · rol" 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 }) { const [sesion, setSesion] = useState(() => getSesion()) + const [modalPassword, setModalPassword] = useState(false) if (!sesion) { return setSesion(getSesion())} /> @@ -20,19 +23,29 @@ export default function AuthGate({ children }: { children: ReactNode }) { {sesion.usuario.nombre} · {sesion.usuario.rol} - +
+ + +
{children} + {modalPassword && setModalPassword(false)} />} ) } diff --git a/apps/cotizador/src/components/CambiarPasswordModal.tsx b/apps/cotizador/src/components/CambiarPasswordModal.tsx new file mode 100644 index 0000000..1be9e05 --- /dev/null +++ b/apps/cotizador/src/components/CambiarPasswordModal.tsx @@ -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(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 ( +
+
+

+ Cambiar contraseña +

+ {exito ? ( + <> +

+ Contraseña actualizada +

+ + + ) : ( +
+
+ + setActual(e.target.value)} + autoComplete="current-password" + autoFocus + required + className={CLASE_INPUT} + /> +
+
+ + setNueva(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +

Mínimo 8 caracteres y un número

+
+
+ + setConfirmar(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +
+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+ )} +
+
+ ) +} diff --git a/apps/cotizador/src/lib/password.test.ts b/apps/cotizador/src/lib/password.test.ts new file mode 100644 index 0000000..e5d540f --- /dev/null +++ b/apps/cotizador/src/lib/password.test.ts @@ -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') + }) +}) diff --git a/apps/cotizador/src/lib/password.ts b/apps/cotizador/src/lib/password.ts new file mode 100644 index 0000000..a045f28 --- /dev/null +++ b/apps/cotizador/src/lib/password.ts @@ -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' +} diff --git a/apps/cotizador/src/services/auth.test.ts b/apps/cotizador/src/services/auth.test.ts index 8884d13..e54961d 100644 --- a/apps/cotizador/src/services/auth.test.ts +++ b/apps/cotizador/src/services/auth.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ErrorLogin, + cambiarPassword, conAuth, getSesion, getToken, @@ -122,3 +123,37 @@ describe('manejar401', () => { 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') + }) +}) diff --git a/apps/cotizador/src/services/auth.ts b/apps/cotizador/src/services/auth.ts index a6919e1..70cd037 100644 --- a/apps/cotizador/src/services/auth.ts +++ b/apps/cotizador/src/services/auth.ts @@ -75,3 +75,28 @@ export function manejar401(res: Response): void { logout() 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 { + 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}`) + } +} diff --git a/apps/dashboards/index.html b/apps/dashboards/index.html index b6978a8..401b807 100644 --- a/apps/dashboards/index.html +++ b/apps/dashboards/index.html @@ -3,6 +3,9 @@ + + + FRASA Desarrollos — Tableros diff --git a/apps/dashboards/public/icons/icon-192.png b/apps/dashboards/public/icons/icon-192.png new file mode 100644 index 0000000..271bffc Binary files /dev/null and b/apps/dashboards/public/icons/icon-192.png differ diff --git a/apps/dashboards/public/icons/icon-512.png b/apps/dashboards/public/icons/icon-512.png new file mode 100644 index 0000000..367b5f3 Binary files /dev/null and b/apps/dashboards/public/icons/icon-512.png differ diff --git a/apps/dashboards/public/manifest.webmanifest b/apps/dashboards/public/manifest.webmanifest new file mode 100644 index 0000000..598ae70 --- /dev/null +++ b/apps/dashboards/public/manifest.webmanifest @@ -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" } + ] +} diff --git a/apps/dashboards/src/components/AuthGate.tsx b/apps/dashboards/src/components/AuthGate.tsx index cbe78f9..baba256 100644 --- a/apps/dashboards/src/components/AuthGate.tsx +++ b/apps/dashboards/src/components/AuthGate.tsx @@ -1,13 +1,16 @@ import { useState, type ReactNode } from 'react' import { getSesion, logout, type Sesion } from '../services/auth' +import CambiarPasswordModal from './CambiarPasswordModal' import LoginScreen from './LoginScreen' /** * Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la - * app con una barra discreta "nombre · rol" 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 }) { const [sesion, setSesion] = useState(() => getSesion()) + const [modalPassword, setModalPassword] = useState(false) if (!sesion) { return setSesion(getSesion())} /> @@ -20,19 +23,29 @@ export default function AuthGate({ children }: { children: ReactNode }) { {sesion.usuario.nombre} · {sesion.usuario.rol} - +
+ + +
{children} + {modalPassword && setModalPassword(false)} />} ) } diff --git a/apps/dashboards/src/components/CambiarPasswordModal.tsx b/apps/dashboards/src/components/CambiarPasswordModal.tsx new file mode 100644 index 0000000..1be9e05 --- /dev/null +++ b/apps/dashboards/src/components/CambiarPasswordModal.tsx @@ -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(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 ( +
+
+

+ Cambiar contraseña +

+ {exito ? ( + <> +

+ Contraseña actualizada +

+ + + ) : ( +
+
+ + setActual(e.target.value)} + autoComplete="current-password" + autoFocus + required + className={CLASE_INPUT} + /> +
+
+ + setNueva(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +

Mínimo 8 caracteres y un número

+
+
+ + setConfirmar(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +
+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+ )} +
+
+ ) +} diff --git a/apps/dashboards/src/components/MiTablero.tsx b/apps/dashboards/src/components/MiTablero.tsx new file mode 100644 index 0000000..3c2dd83 --- /dev/null +++ b/apps/dashboards/src/components/MiTablero.tsx @@ -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(null) + const [error, setError] = useState(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 ( +
+
+
+

FRASA Desarrollos

+

Mi tablero

+ {resumen && ( +

+ Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')} +

+ )} +
+ +
+ + {error && ( +

+ Sin datos del API — {error} +

+ )} + + {resumen?.sinMapeoMonday && ( +

+ Tu usuario aún no está ligado a Monday: las listas personales aparecen vacías +

+ )} + + {!resumen && !error && cargando && } + {!resumen && !error && !cargando && } + + {resumen && ( + <> +
+ + + +
+ +
+ Mis leads por estado + {funnel.length === 0 ? ( + + ) : ( +
+ + + + + {funnel.map((etapa, i) => ( + + ))} + + + + + +
+ )} +
+ + )} +
+ ) +} diff --git a/apps/dashboards/src/components/SeccionEquipo.tsx b/apps/dashboards/src/components/SeccionEquipo.tsx new file mode 100644 index 0000000..b796bff --- /dev/null +++ b/apps/dashboards/src/components/SeccionEquipo.tsx @@ -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(null) + const [error, setError] = useState(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 ( +
+ Equipo: leads por vendedor + {error && ( +

+ Sin datos del API — {error} +

+ )} + {!error && !equipo && } + {!error && equipo && ( + <> +

+ Leads nuevos sin asignar:{' '} + {equipo.leadsNuevosSinAsignar} +

+ {equipo.porVendedor.length === 0 ? ( + + ) : ( + + + + + + + + + + {equipo.porVendedor.map((v) => ( + + + + + + ))} + +
VendedorLeads asignadosNuevos esta semana
{v.nombre ?? `Monday #${v.mondayUserId}`}{v.leadsAsignados}{v.leadsNuevosSemana}
+ )} + + )} +
+ ) +} diff --git a/apps/dashboards/src/components/TableroGeneral.tsx b/apps/dashboards/src/components/TableroGeneral.tsx new file mode 100644 index 0000000..6c7155b --- /dev/null +++ b/apps/dashboards/src/components/TableroGeneral.tsx @@ -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 = { + 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(null) + const [error, setError] = useState(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 ( +
+
+
+

FRASA Desarrollos

+

Tablero comercial

+ {resumen && ( +

+ Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')} +

+ )} +
+ +
+ + {error && ( +

+ Sin datos del API — {error} +

+ )} + + {!resumen && !error && cargando && } + {!resumen && !error && !cargando && } + + {resumen && ( + <> +
+ + + + + + + + + +
+ +
+ Unidades por desarrollo y status + {unidades.length === 0 ? ( + + ) : ( +
+ + + + + + + + {Object.keys(COLORES_STATUS).map((status) => ( + + ))} + + +
+ )} +
+ +
+ Pipeline de ventas por etapa + {sinPipeline ? ( + + ) : ( +
+ + + + + + + + + +
+ )} +
+ +
+
+ Embudo: leads → oportunidades → ganado + {funnel.every((e) => e.valor === 0) ? ( + + ) : ( +
+ + + + + {funnel.map((etapa, i) => ( + + ))} + + + + + +
+ )} +
+ +
+ Cobranza: al corriente vs vencidos + {sinCobranza ? ( + + ) : ( +
+ + + `${name}: ${value}`} + fontSize={11} + > + {cobranza.map((rebanada, i) => ( + + ))} + + + + +
+ )} +
+
+ + {mostrarEquipo && } + + )} +
+ ) +} diff --git a/apps/dashboards/src/components/comunes.tsx b/apps/dashboards/src/components/comunes.tsx new file mode 100644 index 0000000..0590c3c --- /dev/null +++ b/apps/dashboards/src/components/comunes.tsx @@ -0,0 +1,13 @@ +// Piezas visuales compartidas por las vistas del tablero (StudioBlank) + +export function EstadoVacio({ mensaje }: { mensaje: string }) { + return ( +
+

{mensaje}

+
+ ) +} + +export function TituloSeccion({ children }: { children: string }) { + return

{children}

+} diff --git a/apps/dashboards/src/lib/mi-tablero.test.ts b/apps/dashboards/src/lib/mi-tablero.test.ts new file mode 100644 index 0000000..5639632 --- /dev/null +++ b/apps/dashboards/src/lib/mi-tablero.test.ts @@ -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 }]) + }) +}) diff --git a/apps/dashboards/src/lib/mi-tablero.ts b/apps/dashboards/src/lib/mi-tablero.ts new file mode 100644 index 0000000..7fd5621 --- /dev/null +++ b/apps/dashboards/src/lib/mi-tablero.ts @@ -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 } | 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() + 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)) +} diff --git a/apps/dashboards/src/lib/password.test.ts b/apps/dashboards/src/lib/password.test.ts new file mode 100644 index 0000000..e5d540f --- /dev/null +++ b/apps/dashboards/src/lib/password.test.ts @@ -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') + }) +}) diff --git a/apps/dashboards/src/lib/password.ts b/apps/dashboards/src/lib/password.ts new file mode 100644 index 0000000..a045f28 --- /dev/null +++ b/apps/dashboards/src/lib/password.ts @@ -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' +} diff --git a/apps/dashboards/src/pages/DashboardPage.tsx b/apps/dashboards/src/pages/DashboardPage.tsx index 1ba3a37..68c148a 100644 --- a/apps/dashboards/src/pages/DashboardPage.tsx +++ b/apps/dashboards/src/pages/DashboardPage.tsx @@ -1,255 +1,49 @@ -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 '../components/KpiCard' -import { - datosCobranza, - datosFunnel, - datosPipeline, - formatoMoneda, - formatoPorcentaje, - unidadesApiladas, - unidadesEnEtapa, - type ResumenReportes, -} from '../lib/reportes' -import { fetchResumen } from '../services/reportes' +import { useState } from 'react' +import MiTablero from '../components/MiTablero' +import TableroGeneral from '../components/TableroGeneral' +import { esRolConEquipo } from '../lib/mi-tablero' +import { getSesion } from '../services/auth' -// Paleta StudioBlank: acento verde + neutros (sin gradientes ni sombras) -const COLORES_STATUS: Record = { - DISPONIBLE: '#1B4332', - APARTADO: '#b45309', - VENDIDO: '#525252', - BLOQUEADO: '#b91c1c', - OTROS: '#d4d4d4', -} -const COLORES_FUNNEL = ['#a3a3a3', '#737373', '#52796f', '#1B4332'] -const COLORES_COBRANZA = ['#1B4332', '#b45309'] +type Vista = 'mia' | 'general' + +const VISTAS: { id: Vista; etiqueta: string }[] = [ + { id: 'mia', etiqueta: 'Mi tablero' }, + { id: 'general', etiqueta: 'General' }, +] + +/** + * Tableros con vista por rol: el vendedor entra a "Mi tablero" (sus KPIs y el + * funnel de sus leads); coordinador/corporativo/admin entran a "General" con + * la sección Equipo. El toggle permite cambiar de vista en cualquier momento. + */ +export default function DashboardPage() { + const rol = getSesion()?.usuario.rol ?? 'vendedor' + const conEquipo = esRolConEquipo(rol) + const [vista, setVista] = useState(conEquipo ? 'general' : 'mia') -function EstadoVacio({ mensaje }: { mensaje: string }) { return ( -
-

{mensaje}

+
+
+
+ {VISTAS.map((v) => ( + + ))} +
+
+ {vista === 'mia' ? : }
) } - -function TituloSeccion({ children }: { children: string }) { - return

{children}

-} - -export default function DashboardPage() { - const [resumen, setResumen] = useState(null) - const [error, setError] = useState(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 ( -
-
-
-

FRASA Desarrollos

-

Tablero comercial

- {resumen && ( -

- Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')} -

- )} -
- -
- - {error && ( -

- Sin datos del API — {error} -

- )} - - {!resumen && !error && cargando && } - {!resumen && !error && !cargando && } - - {resumen && ( - <> -
- - - - - - - - - -
- -
- Unidades por desarrollo y status - {unidades.length === 0 ? ( - - ) : ( -
- - - - - - - - {Object.keys(COLORES_STATUS).map((status) => ( - - ))} - - -
- )} -
- -
- Pipeline de ventas por etapa - {sinPipeline ? ( - - ) : ( -
- - - - - - - - - -
- )} -
- -
-
- Embudo: leads → oportunidades → ganado - {funnel.every((e) => e.valor === 0) ? ( - - ) : ( -
- - - - - {funnel.map((etapa, i) => ( - - ))} - - - - - -
- )} -
- -
- Cobranza: al corriente vs vencidos - {sinCobranza ? ( - - ) : ( -
- - - `${name}: ${value}`} - fontSize={11} - > - {cobranza.map((rebanada, i) => ( - - ))} - - - - -
- )} -
-
- - )} -
- ) -} diff --git a/apps/dashboards/src/services/auth.ts b/apps/dashboards/src/services/auth.ts index a6919e1..70cd037 100644 --- a/apps/dashboards/src/services/auth.ts +++ b/apps/dashboards/src/services/auth.ts @@ -75,3 +75,28 @@ export function manejar401(res: Response): void { logout() 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 { + 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}`) + } +} diff --git a/apps/dashboards/src/services/mi-dia.ts b/apps/dashboards/src/services/mi-dia.ts new file mode 100644 index 0000000..cd28f9a --- /dev/null +++ b/apps/dashboards/src/services/mi-dia.ts @@ -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 { + 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 +} diff --git a/apps/landing/index.html b/apps/landing/index.html index 5bb6aaa..ff1e395 100644 --- a/apps/landing/index.html +++ b/apps/landing/index.html @@ -3,6 +3,9 @@ + + + FRASA Desarrollos — Ecosistema comercial diff --git a/apps/landing/public/icons/icon-192.png b/apps/landing/public/icons/icon-192.png new file mode 100644 index 0000000..4172d74 Binary files /dev/null and b/apps/landing/public/icons/icon-192.png differ diff --git a/apps/landing/public/icons/icon-512.png b/apps/landing/public/icons/icon-512.png new file mode 100644 index 0000000..7eb1009 Binary files /dev/null and b/apps/landing/public/icons/icon-512.png differ diff --git a/apps/landing/public/manifest.webmanifest b/apps/landing/public/manifest.webmanifest new file mode 100644 index 0000000..a2fa0f6 --- /dev/null +++ b/apps/landing/public/manifest.webmanifest @@ -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" } + ] +} diff --git a/apps/mi-dia/index.html b/apps/mi-dia/index.html index 4f735ed..c02b879 100644 --- a/apps/mi-dia/index.html +++ b/apps/mi-dia/index.html @@ -3,6 +3,9 @@ + + + FRASA Desarrollos — Mi día diff --git a/apps/mi-dia/public/icons/icon-192.png b/apps/mi-dia/public/icons/icon-192.png new file mode 100644 index 0000000..1b55ac0 Binary files /dev/null and b/apps/mi-dia/public/icons/icon-192.png differ diff --git a/apps/mi-dia/public/icons/icon-512.png b/apps/mi-dia/public/icons/icon-512.png new file mode 100644 index 0000000..d44ad19 Binary files /dev/null and b/apps/mi-dia/public/icons/icon-512.png differ diff --git a/apps/mi-dia/public/manifest.webmanifest b/apps/mi-dia/public/manifest.webmanifest new file mode 100644 index 0000000..fabe7c2 --- /dev/null +++ b/apps/mi-dia/public/manifest.webmanifest @@ -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" } + ] +} diff --git a/apps/mi-dia/src/components/AuthGate.tsx b/apps/mi-dia/src/components/AuthGate.tsx index cbe78f9..baba256 100644 --- a/apps/mi-dia/src/components/AuthGate.tsx +++ b/apps/mi-dia/src/components/AuthGate.tsx @@ -1,13 +1,16 @@ import { useState, type ReactNode } from 'react' import { getSesion, logout, type Sesion } from '../services/auth' +import CambiarPasswordModal from './CambiarPasswordModal' import LoginScreen from './LoginScreen' /** * Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la - * app con una barra discreta "nombre · rol" 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 }) { const [sesion, setSesion] = useState(() => getSesion()) + const [modalPassword, setModalPassword] = useState(false) if (!sesion) { return setSesion(getSesion())} /> @@ -20,19 +23,29 @@ export default function AuthGate({ children }: { children: ReactNode }) { {sesion.usuario.nombre} · {sesion.usuario.rol} - +
+ + +
{children} + {modalPassword && setModalPassword(false)} />} ) } diff --git a/apps/mi-dia/src/components/CambiarPasswordModal.tsx b/apps/mi-dia/src/components/CambiarPasswordModal.tsx new file mode 100644 index 0000000..1be9e05 --- /dev/null +++ b/apps/mi-dia/src/components/CambiarPasswordModal.tsx @@ -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(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 ( +
+
+

+ Cambiar contraseña +

+ {exito ? ( + <> +

+ Contraseña actualizada +

+ + + ) : ( +
+
+ + setActual(e.target.value)} + autoComplete="current-password" + autoFocus + required + className={CLASE_INPUT} + /> +
+
+ + setNueva(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +

Mínimo 8 caracteres y un número

+
+
+ + setConfirmar(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +
+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+ )} +
+
+ ) +} diff --git a/apps/mi-dia/src/lib/mi-dia.ts b/apps/mi-dia/src/lib/mi-dia.ts index 7419ced..08e842b 100644 --- a/apps/mi-dia/src/lib/mi-dia.ts +++ b/apps/mi-dia/src/lib/mi-dia.ts @@ -41,7 +41,12 @@ export interface ResumenMiDia { } equipo?: { leadsNuevosSinAsignar: number - porVendedor: { mondayUserId: number; nombre: string | null; leadsAsignados: number }[] + porVendedor: { + mondayUserId: number + nombre: string | null + leadsAsignados: number + leadsNuevosSemana: number + }[] } } diff --git a/apps/mi-dia/src/lib/password.test.ts b/apps/mi-dia/src/lib/password.test.ts new file mode 100644 index 0000000..e5d540f --- /dev/null +++ b/apps/mi-dia/src/lib/password.test.ts @@ -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') + }) +}) diff --git a/apps/mi-dia/src/lib/password.ts b/apps/mi-dia/src/lib/password.ts new file mode 100644 index 0000000..a045f28 --- /dev/null +++ b/apps/mi-dia/src/lib/password.ts @@ -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' +} diff --git a/apps/mi-dia/src/services/auth.ts b/apps/mi-dia/src/services/auth.ts index a6919e1..70cd037 100644 --- a/apps/mi-dia/src/services/auth.ts +++ b/apps/mi-dia/src/services/auth.ts @@ -75,3 +75,28 @@ export function manejar401(res: Response): void { logout() 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 { + 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}`) + } +} diff --git a/scripts/generar-iconos-pwa.py b/scripts/generar-iconos-pwa.py new file mode 100644 index 0000000..32c8980 --- /dev/null +++ b/scripts/generar-iconos-pwa.py @@ -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//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()