PDF quote generator for a construction company — 100% client-side, installable as a PWA, with one-tap sharing to WhatsApp.
README
Aplicación web para armar y descargar presupuestos en PDF, pensada para usarse desde el celular directamente en la obra. Corre 100% en el navegador: no hay backend, no hay base de datos, no hay servidor propio que mantener. Todo el estado (historial de presupuestos, número de ticket) se guarda en el localStorage del navegador.
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.
| 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).
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.
Un clic en Descargar PDF arma un documento en tamaño A4 con:
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).
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).
Cada vez que se descarga un PDF, el presupuesto completo queda guardado en un historial local (hasta los últimos 20), mostrando número, obra, valor y fecha. Incluye:
Como todo vive en el localStorage del navegador (atado a un solo dispositivo/navegador), el panel de historial tiene:
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.
| 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 |
| ESLint (flat config) + Prettier | Lint y formato de código |
No hay backend, base de datos, ni dependencias de red en tiempo de uso (más allá de la carga inicial de la página y, opcionalmente, de las tipografías de Google Fonts).
Requiere Node.js (18+) y npm.
npm install
npm run dev
Abrí la URL que muestra la consola (por defecto http://localhost:5173).
npm run dev # Servidor de desarrollo con hot reload
npm run build # Type-check + build de producción en dist/
npm run preview # Sirve el build de producción localmente (con el service worker activo)
npm run lint # ESLint sobre todo el proyecto
npm run build
Genera la carpeta dist/ con archivos 100% estáticos. Se puede:
dist/ a cualquier hosting estático — Vercel, Netlify, GitHub Pages, un servidor propio, etc. No requiere configuración especial de servidor (no hay rutas dinámicas ni API).dist/index.html directamente como archivo local. En este caso el service worker no se activa (requiere http/https, no file://), así que la app funciona igual pero sin instalación como PWA ni modo offline.Para previsualizar el build de producción localmente con el service worker activo:
npm run preview
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. Una vez deployado, el sitio ya sirve por HTTPS, así que la instalación como PWA y el modo offline funcionan sin pasos extra.
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:
localStorage, que tampoco depende de la conexión.src/
domain/ → Tipos, constantes, formateo y validaciones. Funciones puras,
sin dependencias de React ni del DOM.
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 + parseo seguro de un backup importado
services/
storage/QuoteRepository.ts Interfaz + implementación en localStorage del historial
y el contador de ticket (reemplazable por una API real
sin tocar el resto de la app)
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
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
history/ HistoryPanel, HistoryItem, BackupActions
assets/logo.ts Logo de la empresa embebido en base64 (usado por la vista previa y el PDF)
App.tsx Composition root: conecta los hooks con los servicios de PDF/WhatsApp/backup
main.tsx
index.css Directivas de Tailwind + paleta de colores propia
public/
logo.png, favicon.png, pwa-*.png Copias del logo para uso web/PWA
El código sigue SRP y Open/Closed de forma deliberada:
jsPDF, localStorage, ni wa.me directamente.domain/, como funciones puras sin dependencias de React — testeables de forma aislada aunque hoy no haya tests escritos.QuoteRepository, PdfGenerator, ShareService, BackupService), inyectadas con un valor por defecto en los hooks. Esto permite reemplazar, por ejemplo, localStorage por una API real sin tocar componentes ni hooks.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.
| 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 |
No hay servidor: 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:
html2canvas y dompurify), lo que deja el chunk principal en ~770 KB sin comprimir (~320 KB con gzip). No afecta la experiencia de uso normal, pero Vite avisa sobre el tamaño en el build.http/https; no funciona abriendo dist/index.html como archivo local.