feat: ecosistema FRASA — apps Cotizador, Alta Clientes, Cobranza y Landing

- Cotizador (/quote): 4 propuestas con formulas validadas del Excel, comparador,
  combobox de unidades, vigencia, PDF ANEXO A
- Alta Clientes (/client): wizard 8 pasos, validacion CURP RENAPO, OCR de INE,
  checklist de expediente, firma digital, 5 PDFs legales
- Cobranza (/pay): planes de enganche, mora, vista cobranza del dia, recibos PDF,
  bitacora, dashboard con filtros
- Landing (/): entrada al ecosistema
- Design system StudioBlank (monocromo, acento #1B4332) en las 4 apps
- 123 tests vitest
This commit is contained in:
2026-08-21 22:27:36 +00:00
commit ecd4edac02
147 changed files with 27345 additions and 0 deletions

13
apps/landing/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Página de entrada al ecosistema comercial de FRASA Desarrollos" />
<title>FRASA Desarrollos — Ecosistema comercial</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4589
apps/landing/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
apps/landing/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "landing",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"preview": "vite preview --port 3004"
},
"dependencies": {
"@fontsource/inter": "^5.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^2.1.8",
"tailwindcss": "^4.0.0",
"typescript": "~5.6.3",
"vite": "^6.0.7",
"vitest": "^2.1.8"
}
}

29
apps/landing/src/App.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { MODULOS } from './data/modulos'
import { TarjetaModulo } from './components/TarjetaModulo'
export default function App() {
return (
<div className="flex min-h-screen flex-col">
<header className="px-6 pt-20 pb-16 sm:px-12 lg:px-24">
<p className="text-xs font-bold tracking-widest text-accent uppercase">FRASA Desarrollos</p>
<h1 className="mt-4 text-4xl font-light text-neutral-900 sm:text-5xl">
Ecosistema comercial
</h1>
</header>
<main className="flex-1 px-6 sm:px-12 lg:px-24">
<nav aria-label="Módulos del ecosistema">
<ul className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
{MODULOS.map((modulo) => (
<TarjetaModulo key={modulo.id} modulo={modulo} />
))}
</ul>
</nav>
</main>
<footer className="px-6 pt-24 pb-10 sm:px-12 lg:px-24">
<p className="text-xs text-neutral-400">Consult (AS) Technology</p>
</footer>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import type { Modulo } from '../data/modulos'
interface Props {
modulo: Modulo
}
/** Tarjeta de acceso a un módulo del ecosistema */
export function TarjetaModulo({ modulo }: Props) {
return (
<li>
<a
href={modulo.url}
className="group flex h-full flex-col border border-neutral-200 bg-white p-8 transition-colors duration-150 hover:border-neutral-900"
>
<h2 className="text-2xl font-light text-neutral-900">{modulo.nombre}</h2>
<p className="mt-3 text-sm leading-relaxed text-neutral-500">{modulo.descripcion}</p>
<span className="mt-auto pt-8 text-xs font-bold tracking-widest text-accent uppercase">
Entrar <span aria-hidden="true">&rarr;</span>
</span>
</a>
</li>
)
}

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import { MODULOS } from './modulos'
describe('MODULOS', () => {
it('expone los 3 módulos del ecosistema', () => {
expect(MODULOS).toHaveLength(3)
expect(MODULOS.map((m) => m.id)).toEqual(['cotizador', 'alta-clientes', 'cobranza'])
})
it('cada módulo tiene nombre, descripción y URL de preview válida', () => {
for (const modulo of MODULOS) {
expect(modulo.nombre.length).toBeGreaterThan(0)
expect(modulo.descripcion.length).toBeGreaterThan(0)
expect(() => new URL(modulo.url)).not.toThrow()
expect(new URL(modulo.url).protocol).toBe('http:')
}
})
it('apunta a los puertos y prefijos de preview correctos', () => {
const urls = Object.fromEntries(MODULOS.map((m) => [m.id, m.url]))
expect(urls['cotizador']).toBe('http://192.168.10.123:3001/quote/')
expect(urls['alta-clientes']).toBe('http://192.168.10.123:3002/client/')
expect(urls['cobranza']).toBe('http://192.168.10.123:3003/pay/')
})
it('no hay ids ni URLs duplicadas', () => {
const ids = MODULOS.map((m) => m.id)
const urls = MODULOS.map((m) => m.url)
expect(new Set(ids).size).toBe(MODULOS.length)
expect(new Set(urls).size).toBe(MODULOS.length)
})
})

View File

@@ -0,0 +1,29 @@
export interface Modulo {
id: string
nombre: string
descripcion: string
url: string
}
// TODO: tras montar el NGINX de producción cambiar las URLs absolutas de
// preview por rutas relativas '/quote/', '/client/' y '/pay/'.
export const MODULOS: Modulo[] = [
{
id: 'cotizador',
nombre: 'Cotizador',
descripcion: 'Propuestas comerciales y PDF en tiempo real',
url: 'http://192.168.10.123:3001/quote/',
},
{
id: 'alta-clientes',
nombre: 'Alta de clientes',
descripcion: 'Expediente digital, firmas y formatos PLD',
url: 'http://192.168.10.123:3002/client/',
},
{
id: 'cobranza',
nombre: 'Cobranza',
descripcion: 'Planes de enganche, vencidos y estados de cuenta',
url: 'http://192.168.10.123:3003/pay/',
},
]

View File

@@ -0,0 +1,27 @@
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--color-accent: #1B4332;
--color-accent-hover: #132f23;
--radius-xs: 0;
--radius-sm: 0;
--radius-md: 0;
--radius-lg: 0;
--radius-xl: 0;
--radius-2xl: 0;
--radius-3xl: 0;
--radius-4xl: 0;
}
@layer base {
body {
@apply bg-white text-neutral-900 antialiased;
}
/* Foco visible accesible: outline en color de acento */
:focus-visible {
outline: 2px solid #1B4332;
outline-offset: 2px;
}
}

14
apps/landing/src/main.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@fontsource/inter/300.css'
import '@fontsource/inter/400.css'
import '@fontsource/inter/500.css'
import '@fontsource/inter/700.css'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"types": ["vite/client"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts", "vitest.config.ts"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// La landing es la entrada del ecosistema y se sirve en la raíz
export default defineConfig({
base: '/',
plugins: [react(), tailwindcss()],
})

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config'
// Config de tests separada para no mezclar los tipos de Vite y Vitest
export default defineConfig({
test: {
environment: 'node',
coverage: {
provider: 'v8',
},
},
})