Skip to content

Repository files navigation

Generador de Presupuestos — Braian Costa Construcciones

Aplicación web para armar y descargar presupuestos en PDF, pensada para usarse desde el celular directamente en la obra. Se puede usar sin cuenta (modo anónimo, 100% en el navegador, historial en localStorage) o con cuenta (registro/login, historial respaldado en MongoDB y accesible desde cualquier dispositivo). El backend es un puñado de funciones serverless de Vercel; no hay un servidor propio que mantener.

Índice

Qué hace la app

Un contratista (o quien cargue el presupuesto) completa un formulario corto con los datos de un trabajo, ve una vista previa en vivo con el mismo diseño que va a tener el PDF final, y con un botón descarga un PDF profesional listo para mandarle al cliente — o lo comparte directo por WhatsApp sin descargar nada. Cada presupuesto generado queda en un historial local, numerado correlativamente (N° 001, N° 002, ...), que se puede buscar, duplicar como base para uno nuevo, o exportar/importar como backup.

No hace falta conexión a internet para generar presupuestos (salvo la primera carga de la página): tanto el armado del PDF como el guardado del historial ocurren enteramente en el dispositivo.

Funcionalidades en detalle

1. Formulario de presupuesto

Campo Tipo Detalle
Obra / Cliente texto libre Nombre de la obra o del cliente (ej: "Laprida"). Es obligatorio.
Título del trabajo texto libre Ej: "Pintura exterior e impermeabilización".
Alcance del trabajo textarea Un ítem por renglón. Cada línea no vacía se convierte en un ítem con viñeta en la vista previa y el PDF.
Materiales incluidos checkbox Si está tildado, agrega automáticamente "Materiales incluidos" como último ítem del alcance (no hace falta escribirlo a mano).
Valor ($) numérico Monto base del presupuesto, sin IVA.
+ IVA checkbox Si está tildado, el valor final se calcula como el monto base + 21% de IVA, y tanto la vista previa como el PDF muestran el desglose Subtotal / IVA (21%) / Total. Si no está tildado, se muestra un único monto ("Valor total").
% Anticipo numérico (default 70) Porcentaje que se pide de anticipo. El resto ("% de saldo") se calcula solo como 100 - anticipo y aparece en el texto de condiciones de pago.
Validez (días) numérico (default 15) Cuántos días es válido el presupuesto desde la fecha de emisión.

El botón Descargar PDF y Compartir por WhatsApp quedan deshabilitados hasta que el formulario sea válido (obra cargada, valor mayor a 0, % de anticipo entre 0 y 100, validez mayor a 0 días).

2. Vista previa en vivo

A medida que se completa el formulario, a la derecha (o abajo en mobile) se actualiza en tiempo real una vista previa con estética de "ficha de obra": logo de la empresa, número de presupuesto, título y obra, lista de ítems del alcance, caja destacada con el valor (con desglose de IVA si corresponde), condiciones de pago, validez y fecha de emisión. El PDF descargado tiene exactamente el mismo contenido y orden que esta vista previa.

3. Generación de PDF

Un clic en Descargar PDF arma un documento en tamaño A4 con:

  • Logo de la empresa y número de presupuesto (N° 001, N° 002, ...).
  • Título del trabajo, obra, y lista con viñetas del alcance.
  • Caja destacada en rojo óxido con el valor (con desglose Subtotal / IVA / Total cuando corresponde).
  • Condiciones de pago (anticipo/saldo) y validez del presupuesto.
  • Línea de firma y aclaración para la empresa y para el cliente.
  • Pie de página fijo en todas las hojas con teléfono, Instagram y sitio web de la empresa, y numeración "Página X de Y" si el documento ocupa más de una hoja.
  • Salto de página automático: si el alcance tiene muchos ítems y no entra en una sola hoja, el PDF continúa en una página nueva con un mini-encabezado de continuación, sin cortar ningún ítem a la mitad.

El archivo se descarga con el nombre Presupuesto_<obra>_Braian_Costa_Construcciones.pdf (los espacios en el nombre de la obra se reemplazan por guiones bajos).

4. Compartir por WhatsApp

El botón Compartir por WhatsApp genera el PDF y abre la bandeja de compartir nativa del celular (Web Share API) con el archivo ya adjunto y un texto resumen — se elige WhatsApp (o cualquier otra app) y se envía el PDF real, sin pasar por la descarga manual. En navegadores que no soportan compartir archivos (por ejemplo, desktop), cae automáticamente al comportamiento anterior: abre wa.me con el texto del presupuesto precargado (ahí sí, sin el PDF adjunto, porque un link de WhatsApp no puede llevar un archivo).

5. Historial de presupuestos

Cada vez que se descarga un PDF, el presupuesto completo queda guardado en el historial, mostrando número, obra, valor y fecha, con paginado de a 10 por página. Incluye:

  • Buscador por obra: un campo de texto que filtra el historial a medida que se escribe (aparece solo si ya hay al menos un presupuesto guardado). En modo anónimo filtra en el momento sobre lo que hay en localStorage; con cuenta, la búsqueda se resuelve en el servidor contra MongoDB.
  • Duplicar: cada entrada del historial tiene un botón "Duplicar" que precarga el formulario con esos mismos datos (obra, título, alcance, valor, IVA, condiciones), manteniendo el número de ticket actual (no reutiliza el número viejo) — útil para trabajos que se repiten con variantes.

En modo anónimo el historial local sigue limitado a los últimos 20 presupuestos (los más viejos se descartan al agregar uno nuevo, ver Limitaciones conocidas). Con cuenta no hay ese límite: MongoDB guarda todo el historial y el panel lo recorre paginado, sin perder presupuestos viejos.

6. Backup (exportar / importar)

El panel de historial tiene:

  • Exportar backup: descarga un archivo JSON con todo el historial y el número de ticket actual.
  • Importar backup: permite volver a cargar ese archivo (por ejemplo, en otro celular, o después de borrar caché). Antes de importar pide confirmación porque reemplaza el historial y el número de ticket actuales, no los combina. El archivo se valida antes de aplicarse: si no tiene el formato esperado, se avisa con un mensaje y no se toca nada. Funciona igual en modo anónimo (contra localStorage) que logueado (contra la cuenta en MongoDB).

7. Botón "Nuevo presupuesto"

Limpia el formulario dejando precargados los ítems de alcance por defecto ("Provisión de materiales" y "Mano de obra"), e incrementa el número de ticket para el próximo presupuesto.

Cuentas: modo anónimo vs. con cuenta

En el header de la app se ve el estado de la sesión:

  • Modo anónimo (por defecto): "Modo anónimo: tus presupuestos no tienen respaldo" + un link para iniciar sesión o crear cuenta. El historial y el número de ticket viven solo en el localStorage de ese navegador — si se borra el caché o se cambia de dispositivo, se pierden (salvo que se haya exportado un backup a mano).
  • Con cuenta: email + contraseña, con confirmación de email obligatoria (ver abajo). El historial y el número de ticket quedan guardados en MongoDB, atados a la cuenta, y están disponibles desde cualquier dispositivo en el que se inicie sesión. Incluye recuperación de contraseña por email.
  • Migración al loguearse: si el dispositivo tenía presupuestos guardados en modo anónimo, la primera vez que se inicia sesión de verdad (después de confirmar el email) se pregunta si se quieren subir a la cuenta nueva (se suman al historial de la cuenta, no lo reemplazan).

Cambiar entre modo anónimo y con cuenta no mezcla los datos de ambos: cada uno tiene su propio historial y numeración independientes.

Confirmación de email

Registrarse no deja la cuenta lista para usar todavía: se manda un email con un link para confirmarla (vence a las 24 horas), y hasta que no se hace clic ahí, el login rechaza esa cuenta con el mensaje "Confirmá tu email para poder iniciar sesión". Si el link se perdió o venció, el propio formulario de login ofrece un botón "Reenviar email de verificación" para pedir uno nuevo (pide el reenvío por email, sin necesidad de estar logueado). Recuperar la contraseña por email también cuenta como confirmación, ya que implica probar que se controla esa casilla.

Esto evita que alguien registre una cuenta usable con un email que no le pertenece: la cuenta existe, pero no sirve para nada hasta que el dueño real del email confirme haciendo clic en el link.

Si ya tenías cuentas creadas en MongoDB antes de este cambio, van a quedar marcadas como no confirmadas (el campo no existía) y no van a poder loguearse hasta usar "Reenviar email de verificación" desde el login y confirmar. Las sesiones que ya estuvieran activas en el navegador siguen funcionando hasta que venzan (30 días) o se cierre sesión — el chequeo es solo al iniciar sesión, no en cada request.

Rate limiting

Los endpoints de auth que importan (los que verifican contraseña o mandan un email) tienen un límite de intentos por ventana de tiempo, contado en MongoDB (colección RateLimit, con TTL index para que los contadores se autolimpien):

Endpoint Por IP Por email
Login 20 / 15 min 8 / 15 min
Registro 10 / hora
Olvidé mi contraseña 10 / hora 4 / hora
Reenviar confirmación 10 / hora 4 / hora
Resetear contraseña (con token) 30 / hora
Confirmar email (con token) 30 / hora

El límite por email en "olvidé mi contraseña" y "reenviar confirmación" es lo que evita que alguien use esos formularios para bombardear de emails la casilla de otra persona, más allá de cuántas IPs use. Al pasarse el límite, la respuesta es un 429 con code: RATE_LIMITED y un mensaje tipo "Demasiados intentos. Probá de nuevo en 5 minutos.", que se muestra tal cual en el formulario.

Stack tecnológico

Herramienta Uso
Vite Build tool y dev server
React 19 + TypeScript (strict: true) UI, tipado estricto en todo el proyecto (sin any)
Tailwind CSS v4 Estilos, con una paleta de colores propia (ver Personalización)
jsPDF Generación del PDF, 100% en el navegador
vite-plugin-pwa Manifest + service worker para instalar la app y que funcione offline
Vercel Serverless Functions (/api) Backend: registro/login/logout/recuperación de contraseña y CRUD del historial de la cuenta
MongoDB + Mongoose Persistencia de usuarios y presupuestos de las cuentas registradas
bcryptjs + jsonwebtoken Hash de contraseñas y sesión vía JWT en cookie httpOnly
Resend Envío del email de recuperación de contraseña y del de confirmación de cuenta
ESLint (flat config) + Prettier Lint y formato de código

En modo anónimo no hay dependencias de red en tiempo de uso (más allá de la carga inicial de la página). Con cuenta, el historial y el número de ticket se leen/escriben contra /api/quotes/*.

Cómo correr el proyecto

Requiere Node.js (18+) y npm. El modo anónimo funciona con solo esto:

npm install
npm run dev

Abrí la URL que muestra la consola (por defecto http://localhost:5173).

Correr el backend (registro/login) en local

npm run dev solo levanta el frontend (Vite) — las funciones de /api no corren ahí. Para probar el flujo completo de cuentas en local hace falta el Vercel CLI, que sirve frontend y backend juntos:

  1. Copiá .env.example a .env y completá las variables (ver detalle de cada una en el archivo): MONGODB_URI (tu cluster de Atlas), JWT_SECRET, RESEND_API_KEY, EMAIL_FROM, APP_URL.
  2. Corré:
    npx vercel dev
  3. Abrí la URL que te muestre (por defecto http://localhost:3000).

Scripts disponibles

npm run dev       # Servidor de desarrollo con hot reload (solo frontend, sin /api)
npm run build     # Type-check (frontend + api) + build de producción en dist/
npm run preview   # Sirve el build de producción localmente (con el service worker activo, sin /api)
npm run lint      # ESLint sobre todo el proyecto (incluye /api)

Build de producción y deploy

npm run build

Genera la carpeta dist/ con el frontend estático; las funciones de /api se compilan aparte al deployar (no forman parte de dist/).

Deploy a Vercel

Al ser un proyecto Vite estándar, Vercel lo detecta automáticamente: solo hace falta conectar el repositorio (comando de build npm run build, carpeta de salida dist) y Vercel también deploya /api/** como funciones serverless sin configuración adicional — vercel.json solo agrega el rewrite de SPA necesario para que /reset-password no dé 404.

Antes de deployar hay que cargar en Project Settings → Environment Variables las mismas variables que en .env.example: MONGODB_URI, JWT_SECRET, RESEND_API_KEY, EMAIL_FROM y APP_URL (con la URL pública real del deploy). Sin MONGODB_URI/JWT_SECRET el registro/login devuelve error 500, pero el modo anónimo sigue funcionando igual.

Una vez deployado, el sitio ya sirve por HTTPS, así que la instalación como PWA, el modo offline y las cookies de sesión (que requieren HTTPS en producción) funcionan sin pasos extra.

Instalar como app (PWA) y modo offline

Una vez que la app se sirve por http/https (con npm run preview, o ya deployada), abrila desde el celular: el navegador va a ofrecer "Agregar a pantalla de inicio" / "Instalar app" (Chrome/Android) o "Compartir → Agregar a pantalla de inicio" (Safari/iOS).

Una vez instalada:

  • Abre como una app nativa, sin la barra de direcciones del navegador.
  • Sigue funcionando sin conexión: el shell de la app (HTML, JS, CSS, logo) queda cacheado por el service worker, así que se puede seguir generando y descargando presupuestos aunque no haya señal en la obra. El historial vive en localStorage, que tampoco depende de la conexión.

Estructura del proyecto

src/
  domain/              → Tipos, constantes, formateo y validaciones. Funciones puras,
                          sin dependencias de React ni del DOM (las usa también el backend).
    types.ts              Quote, ScopeItem, PaymentTerms, HistoryEntry, BackupPayload
    constants.ts           Nombre y contacto de la empresa (fijos), valores por defecto
    formatters.ts           formatMoney, formatDate, parseScopeText, buildWhatsappText, etc.
    validators.ts           Validación de campos + email/password + parseo seguro de un backup

  services/
    storage/QuoteRepository.ts    Interfaz async del historial y el contador de ticket
      LocalStorageQuoteRepository   Implementación en localStorage (modo anónimo)
    storage/ApiQuoteRepository.ts   Implementación contra /api/quotes/* (modo con cuenta)
    api/httpClient.ts                fetch con manejo de errores compartido por ambos
    pdf/                          Generación del PDF con jsPDF
      PdfGenerator.ts                Orquesta las secciones y arma el documento final
      sections/                      Cada función dibuja una parte del PDF (header, scope,
                                      pricing, footer, signature) y devuelve el Y donde
                                      sigue el contenido — agregar una sección nueva no
                                      requiere tocar las existentes
    share/ShareService.ts          Comparte el PDF vía Web Share API, con fallback a wa.me
    backup/BackupService.ts        Exporta/lee el archivo JSON de backup

  contexts/
    AuthContext.tsx      AuthProvider + useAuth(): estado de sesión (anónimo/logueado),
                         login/register/logout/forgotPassword/resetPassword

  hooks/
    useQuoteForm.ts        Estado del formulario, validación, y el Quote derivado
    useQuoteHistory.ts     Lectura/escritura del historial vía QuoteRepository
    useBackup.ts            Exportar/importar backup vía BackupService + QuoteRepository

  components/
    form/            Inputs del formulario (SiteInput, ScopeTextarea, AmountFields, etc.)
    preview/         El "ticket" de vista previa (QuotePreview, LogoHeader, AmountBox, etc.)
    layout/          AppLayout, Header (muestra el estado de sesión vía useAuth())
    history/         HistoryPanel, HistoryItem, BackupActions
    auth/            AuthModal (login/registro/olvidé mi contraseña), ResetPasswordPage,
                     VerifyEmailPage

  assets/logo.ts     Logo de la empresa embebido en base64 (usado por la vista previa y el PDF)
  App.tsx            Composition root: AuthProvider + elige QuoteRepository según la sesión
  main.tsx
  index.css          Directivas de Tailwind + paleta de colores propia

api/                 → Funciones serverless de Vercel (backend). No corren con `npm run dev`,
                        solo con `vercel dev` o ya deployadas.
  _lib/
    db.ts                Conexión a MongoDB (Mongoose) cacheada entre invocaciones; el nombre
                         de la base ("quote-generator") está fijo acá, no depende de la URI
    models/User.ts        email, passwordHash, currentNumber, emailVerified, tokens de
                          reset de contraseña y de confirmación de email
    models/Quote.ts        Un documento por presupuesto guardado, con userId
    models/RateLimit.ts     Contador por key (IP o email) con TTL index para el rate limiting
    auth.ts                Hash/verificación de contraseña, JWT, cookie de sesión httpOnly
    email.ts                Envío por Resend del email de recuperación de contraseña y
                            del de confirmación de cuenta
    rateLimit.ts             enforceRateLimit() — lo usan los endpoints de auth (ver
                             sección Rate limiting)
  auth/                register.ts, login.ts, logout.ts, me.ts, forgot-password.ts,
                        reset-password.ts, verify-email.ts, resend-verification.ts —
                        cada uno delega en api/_lib/authHandlers.ts
  quotes/
    index.ts             GET historial + número actual, POST agrega un presupuesto
    increment-number.ts   POST, incrementa el número de ticket de forma atómica
    import.ts             POST, reemplaza el historial (backup)
    migrate.ts             POST, suma el historial anónimo a la cuenta sin borrar nada

public/
  logo.png, favicon.png, pwa-*.png   Copias del logo para uso web/PWA

Arquitectura y principios de diseño

El código sigue SRP y Open/Closed de forma deliberada:

  • Los componentes de React son puramente presentacionales: reciben props y disparan callbacks; no llaman a jsPDF ni a wa.me directamente (Header y los componentes de auth/ son la excepción deliberada: consumen useAuth() directamente porque el estado de sesión es un concern transversal, no algo que tenga sentido pasar prop a prop desde App.tsx).
  • Toda la lógica de negocio y el formateo viven en domain/, como funciones puras sin dependencias de React — reutilizadas tal cual desde el backend (api/), testeables de forma aislada aunque hoy no haya tests escritos.
  • Los servicios se exponen como interfaces con una implementación por defecto (QuoteRepository, PdfGenerator, ShareService, BackupService), inyectadas con un valor por defecto en los hooks. QuoteRepository es async precisamente para poder tener dos implementaciones intercambiables sin tocar los hooks ni los componentes: LocalStorageQuoteRepository (anónimo) y ApiQuoteRepository (con cuenta) — App.tsx elige cuál inyectar según useAuth().status.
  • La generación del PDF está armada en secciones componibles (drawHeader, drawScope, drawPricing, drawFooter, drawSignature), cada una una función pura que dibuja su parte y devuelve dónde debe continuar la siguiente. Agregar una sección nueva, o cambiar el orden, no requiere reescribir las existentes.

Todo el código (variables, funciones, tipos, nombres de archivo) está en inglés; el único texto en español es el que ve el usuario final: labels del formulario, botones, mensajes de error/confirmación, el contenido del PDF, y el mensaje de WhatsApp.

Personalización

Qué cambiar Dónde
Nombre de la empresa COMPANY_NAME en src/domain/constants.ts
Teléfono / Instagram / sitio web COMPANY_CONTACT en src/domain/constants.ts
% de IVA TAX_PERCENTAGE en src/domain/formatters.ts
% de anticipo y días de validez por defecto DEFAULT_DEPOSIT_PERCENTAGE / DEFAULT_VALIDITY_DAYS en src/domain/constants.ts
Ítems de alcance precargados en "Nuevo presupuesto" DEFAULT_SCOPE_TEXT en src/domain/constants.ts
Logo Reemplazar public/logo.png, regenerar el base64 (base64 -i public/logo.png) y pegarlo en LOGO_BASE64 (src/assets/logo.ts). También conviene actualizar public/favicon.png y los íconos public/pwa-*.png para que coincidan.
Colores (paleta petróleo/hueso/óxido) Variables --color-petrol-*, --color-bone-*, --color-rust-* en src/index.css
Tipografías --font-display (títulos) y --font-mono (montos/números) en src/index.css

Datos y privacidad

Modo anónimo: no hay servidor involucrado — el historial de presupuestos y el número de ticket se guardan únicamente en el localStorage del navegador donde se usa la app. Esto significa que:

  • Los datos son locales a ese navegador/dispositivo — no se sincronizan solos entre celular y computadora, ni entre navegadores distintos en el mismo dispositivo.
  • Borrar el caché/datos del sitio en el navegador borra el historial. Por eso conviene exportar un backup (JSON) de vez en cuando, especialmente antes de cambiar de celular, o directamente crear una cuenta.
  • Nada de la información cargada se envía a ningún servidor de terceros (salvo, obviamente, al compartir manualmente por WhatsApp).

Con cuenta: el historial y el número de ticket se guardan en MongoDB, asociados a esa cuenta (no se comparten entre cuentas distintas). La contraseña se guarda hasheada (nunca en texto plano); la sesión se maneja con un JWT en una cookie httpOnly (no accesible desde JavaScript del lado del cliente). El único servicio de terceros involucrado es Resend, usado para el email de recuperación de contraseña y el de confirmación de cuenta (ver Confirmación de email).

Limitaciones conocidas

  • El bundle de producción incluye jsPDF (con sus dependencias html2canvas y dompurify), lo que deja el chunk principal en ~795 KB sin comprimir (~325 KB con gzip). No afecta la experiencia de uso normal, pero Vite avisa sobre el tamaño en el build.
  • El modo offline / instalación como PWA requiere que la app se sirva por http/https; no funciona abriendo dist/index.html como archivo local.
  • En modo anónimo el historial guarda como máximo los últimos 20 presupuestos en localStorage; los más viejos quedan fuera de la vista (y se pierden) al agregar uno nuevo. Con cuenta no hay este límite: MongoDB guarda todo el historial y se recorre paginado desde el panel.
  • Deployar la confirmación de email obligatoria (ver Confirmación de email) sobre una base con cuentas ya creadas las deja sin poder loguearse hasta que confirmen — es un cambio disruptivo si la app ya tiene usuarios reales, no algo transparente.
  • El rate limiting (ver Rate limiting) usa un documento por key en MongoDB, no una ventana deslizante perfecta: en el límite exacto de la ventana puede permitir algún intento de más. Suficiente para frenar abuso automatizado, no una defensa de nivel bancario.
  • El registro y la recuperación de contraseña dependen de Resend para mandar el email de confirmación de cuenta y el de recuperación (ver Confirmación de email). Con el remitente de prueba que trae EMAIL_FROM por defecto (onboarding@resend.dev), Resend solo entrega a la casilla verificada de la cuenta de Resend — hace falta verificar un dominio propio en resend.com/domains y actualizar EMAIL_FROM para poder registrar o recuperar la contraseña de cualquier usuario real.

About

PDF quote generator for a construction company — 100% client-side, installable as a PWA, with one-tap sharing to WhatsApp.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages