Añade revisión pre-envío del reformista y PDF de presupuesto pulido

Adelanta de F1.5 a F2 la validación pre-envío: el panel permite elegir
modo de envío (automático/revisión), editar los conceptos del
presupuesto y enviar al cliente por WhatsApp (simulado).

Añade datos de empresa y logo configurables en /panel/empresa y genera
el presupuesto como PDF real descargable con esa marca vía
@react-pdf/renderer, sustituyendo la vista HTML imprimible.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-05-30 22:27:05 +02:00
parent b84b2f37a2
commit ec141cdd6e
26 changed files with 3961 additions and 59 deletions

View File

@@ -0,0 +1,2 @@
CREATE TYPE "public"."envio_presupuesto_mode" AS ENUM('automatico', 'revision');--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "envio_presupuesto" "envio_presupuesto_mode" DEFAULT 'automatico' NOT NULL;

View File

@@ -0,0 +1,5 @@
ALTER TABLE "tenants" ADD COLUMN "cif" text;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "direccion" text;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "telefono" text;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "email" text;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "web" text;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,20 @@
"when": 1780162638625,
"tag": "0002_overjoyed_the_renegades",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1780169328805,
"tag": "0003_youthful_white_queen",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1780170597963,
"tag": "0004_even_stranger",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// @react-pdf/renderer usa módulos nativos/wasm (yoga, fontkit) que no deben bundlearse.
serverExternalPackages: ['@react-pdf/renderer'],
async rewrites() {
return [
// Landing B2B estática (mvp/b2b) servida en /b2b. El fichero vive en public/b2b.html.

View File

@@ -8,6 +8,7 @@
"name": "landing-page",
"version": "0.1.0",
"dependencies": {
"@react-pdf/renderer": "^4.5.1",
"@tailwindcss/postcss": "^4.3.0",
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.2",
@@ -237,6 +238,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -2094,6 +2104,30 @@
"node": ">= 10"
}
},
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2152,6 +2186,183 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@react-pdf/fns": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.3.tgz",
"integrity": "sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==",
"license": "MIT"
},
"node_modules/@react-pdf/font": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.8.tgz",
"integrity": "sha512-deNd+emtZAJho1IlzKL9bRoLAGv/6oXOIKO2oZfs4RuXUrK1onLHbJO7e2YoVLPFP/sQxisRTnzdJFtd35iKwA==",
"license": "MIT",
"dependencies": {
"@react-pdf/pdfkit": "^5.1.1",
"@react-pdf/types": "^2.11.1",
"fontkit": "^2.0.2",
"is-url": "^1.2.4"
}
},
"node_modules/@react-pdf/image": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.1.0.tgz",
"integrity": "sha512-ks7Ry8v711r8NvKWSELehj0BXBNPRihSnWsM09nDD8Ur175zbWBCK217LLwQMKDNYDVpkZaipdoJPom1LGaE9g==",
"license": "MIT",
"dependencies": {
"@react-pdf/svg": "^1.1.0",
"jay-peg": "^1.1.1",
"png-js": "^2.0.0"
}
},
"node_modules/@react-pdf/layout": {
"version": "4.6.1",
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.6.1.tgz",
"integrity": "sha512-gN6PmWoEffvlIkifLfEhMsVucRywVMyH3rnxdyOVOhGy0nWJKKGpHyPc4plbDdpP6EfZ0r8prHXujDSkIG2nSA==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.3",
"@react-pdf/image": "^3.1.0",
"@react-pdf/primitives": "^4.3.0",
"@react-pdf/stylesheet": "^6.2.1",
"@react-pdf/textkit": "^6.3.0",
"@react-pdf/types": "^2.11.1",
"emoji-regex-xs": "^1.0.0",
"queue": "^6.0.1",
"yoga-layout": "^3.2.1"
}
},
"node_modules/@react-pdf/pdfkit": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-5.1.1.tgz",
"integrity": "sha512-wNcdSsNlNYyGHGAgIdt453egBF7fiF9UxpRlklUfVvu8OWCrUppG9xiUrPLVoKiqWet5tMi0w6LmuFUJuYqjEg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@noble/ciphers": "^1.0.0",
"@noble/hashes": "^1.6.0",
"browserify-zlib": "^0.2.0",
"fontkit": "^2.0.2",
"jay-peg": "^1.1.1",
"js-md5": "^0.8.3",
"linebreak": "^1.1.0",
"png-js": "^2.0.0",
"vite-compatible-readable-stream": "^3.6.1"
}
},
"node_modules/@react-pdf/primitives": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.3.0.tgz",
"integrity": "sha512-nYXoZ36pvwNzbc54+DbL8RCn15jU7woJ9D/svnh5tpUXekJ+CbI4mZLo6boSv24CvJgychOu6h7gxX03B4ps0A==",
"license": "MIT"
},
"node_modules/@react-pdf/reconciler": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-2.0.0.tgz",
"integrity": "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4.1.1",
"scheduler": "0.25.0-rc-603e6108-20241029"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
"version": "0.25.0-rc-603e6108-20241029",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
"license": "MIT"
},
"node_modules/@react-pdf/render": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.5.1.tgz",
"integrity": "sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.3",
"@react-pdf/primitives": "^4.3.0",
"@react-pdf/textkit": "^6.3.0",
"@react-pdf/types": "^2.11.1",
"abs-svg-path": "^0.1.1",
"color-string": "^2.1.4",
"normalize-svg-path": "^1.1.0",
"parse-svg-path": "^0.1.2",
"svg-arc-to-cubic-bezier": "^3.2.0"
}
},
"node_modules/@react-pdf/renderer": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.5.1.tgz",
"integrity": "sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.3",
"@react-pdf/font": "^4.0.8",
"@react-pdf/layout": "^4.6.1",
"@react-pdf/pdfkit": "^5.1.1",
"@react-pdf/primitives": "^4.3.0",
"@react-pdf/reconciler": "^2.0.0",
"@react-pdf/render": "^4.5.1",
"@react-pdf/types": "^2.11.1",
"events": "^3.3.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2",
"queue": "^6.0.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/stylesheet": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.2.1.tgz",
"integrity": "sha512-2+UEk+7e+z8baaWi2l5kPLWmwtJeOI+T5wW9GGeN3iDH7vd3kbTqOpN1yt9mmfNVZFxQsnDHpznFb5v5UF983A==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.3",
"@react-pdf/types": "^2.11.1",
"color-string": "^2.1.4",
"hsl-to-hex": "^1.0.0",
"media-engine": "^1.0.3",
"postcss-value-parser": "^4.1.0"
}
},
"node_modules/@react-pdf/svg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@react-pdf/svg/-/svg-1.1.0.tgz",
"integrity": "sha512-cTIHXiz9x1HrbfqzfxfZP3FRdDwUXG77QWF6Fb5MP/lV3ONxR+g0Z3hwtBatCS9HeGBQCpxX/Lzb8wHE+co1PA==",
"license": "MIT",
"dependencies": {
"@react-pdf/primitives": "^4.3.0"
}
},
"node_modules/@react-pdf/textkit": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.3.0.tgz",
"integrity": "sha512-v6+V8nAcVwm7s2s1jIG2MD3Iw//x/k+XrH1foWOELBE4b32pyDgKyPXN/6KJE0dnX7+fVy27uctLNCLNMvzKzQ==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.3",
"bidi-js": "^1.0.2",
"hyphen": "^1.6.4",
"unicode-properties": "^1.4.1"
}
},
"node_modules/@react-pdf/types": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.11.1.tgz",
"integrity": "sha512-i9xQgfaDU9QoeNnbp6rltXCWg1huEh195rpOuN8cE4BZ2FuLdQrsIcb2dhFF9aOxXf+XBA6LOSpIW051MDD/bw==",
"license": "MIT",
"dependencies": {
"@react-pdf/font": "^4.0.8",
"@react-pdf/primitives": "^4.3.0",
"@react-pdf/stylesheet": "^6.2.1"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
@@ -3499,6 +3710,12 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/abs-svg-path": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -3821,6 +4038,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.32",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
@@ -3842,6 +4079,15 @@
"bcrypt": "bin/bcrypt"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
@@ -3866,6 +4112,24 @@
"node": ">=8"
}
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserify-zlib": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"license": "MIT",
"dependencies": {
"pako": "~1.0.5"
}
},
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
@@ -4020,6 +4284,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4040,6 +4313,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/color-string": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
"license": "MIT",
"dependencies": {
"color-name": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/color-string/node_modules/color-name": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
"license": "MIT",
"engines": {
"node": ">=12.20"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4207,6 +4501,12 @@
"node": ">=8"
}
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -4403,6 +4703,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/emoji-regex-xs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz",
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
"license": "MIT"
},
"node_modules/enhanced-resolve": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz",
@@ -5081,6 +5387,15 @@
"node": ">=0.10.0"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -5095,7 +5410,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
@@ -5152,6 +5466,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -5216,6 +5536,23 @@
"dev": true,
"license": "ISC"
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -5551,6 +5888,21 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/hsl-to-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
"license": "MIT",
"dependencies": {
"hsl-to-rgb-for-reals": "^1.1.0"
}
},
"node_modules/hsl-to-rgb-for-reals": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
"license": "ISC"
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -5558,6 +5910,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/hyphen": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz",
"integrity": "sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==",
"license": "ISC"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -5595,6 +5953,12 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -5979,6 +6343,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/is-weakmap": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -6096,6 +6466,15 @@
"node": ">= 0.4"
}
},
"node_modules/jay-peg": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
"license": "MIT",
"dependencies": {
"restructure": "^3.0.0"
}
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -6105,11 +6484,16 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-md5": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -6481,6 +6865,25 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -6508,7 +6911,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -6587,6 +6989,12 @@
"node": ">= 0.4"
}
},
"node_modules/media-engine": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz",
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -6792,11 +7200,19 @@
"node": ">=18"
}
},
"node_modules/normalize-svg-path": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
"license": "MIT",
"dependencies": {
"svg-arc-to-cubic-bezier": "^3.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -6994,6 +7410,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -7007,6 +7429,12 @@
"node": ">=6"
}
},
"node_modules/parse-svg-path": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
"license": "MIT"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -7060,6 +7488,14 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/png-js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
"integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==",
"dependencies": {
"fflate": "^0.8.2"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -7098,6 +7534,12 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/postgres": {
"version": "3.4.9",
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
@@ -7125,7 +7567,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -7143,6 +7584,15 @@
"node": ">=6"
}
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -7189,7 +7639,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/reflect.getprototypeof": {
@@ -7236,6 +7685,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "2.0.0-next.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
@@ -7280,6 +7738,12 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -7369,6 +7833,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -7698,6 +8182,15 @@
"node": ">= 0.4"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -7883,6 +8376,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svg-arc-to-cubic-bezier": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
"license": "ISC"
},
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
@@ -7902,6 +8401,12 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -8693,6 +9198,32 @@
"dev": true,
"license": "MIT"
},
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/unrs-resolver": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz",
@@ -8772,6 +9303,26 @@
"punycode": "^2.1.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vite-compatible-readable-stream": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
"integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/vitest": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
@@ -9644,6 +10195,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yoga-layout": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
"license": "MIT"
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",

View File

@@ -17,6 +17,7 @@
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@react-pdf/renderer": "^4.5.1",
"@tailwindcss/postcss": "^4.3.0",
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.2",

View File

@@ -2,6 +2,7 @@ import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getLead } from '@/db/queries';
import EstadoControl from '@/components/panel/EstadoControl';
import ConceptosEditor from '@/components/panel/ConceptosEditor';
import {
PIPELINE_LABEL,
PIPELINE_NEXT,
@@ -10,7 +11,7 @@ import {
formatEuros,
formatFecha,
} from '@/lib/funnel';
import { recalcularPresupuesto } from '../actions';
import { recalcularPresupuesto, enviarPresupuesto } from '../actions';
import type { BudgetResult } from '@/budget/types';
export const dynamic = 'force-dynamic';
@@ -34,6 +35,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
const desglose = snapshot?.result ?? null;
const yaEnviado = lead.pipelineStage === 'whatsapp_entregado';
return (
<div className="flex flex-col gap-6">
@@ -216,52 +218,42 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
{/* Presupuesto desglosado */}
<Section title="Presupuesto desglosado">
<form action={recalcularPresupuesto.bind(null, lead.id)}>
<button
type="submit"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-black text-white text-sm font-semibold w-fit hover:bg-gray-800"
>
Recalcular presupuesto
</button>
</form>
<div className="flex flex-wrap items-center gap-3">
<form action={recalcularPresupuesto.bind(null, lead.id)}>
<button
type="submit"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-black text-white text-sm font-semibold w-fit hover:bg-gray-800"
>
Recalcular desde el catálogo
</button>
</form>
{desglose && (
<a
href={`/panel/${lead.id}/presupuesto`}
target="_blank"
rel="noopener"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-300 text-sm font-semibold text-gray-700 hover:border-gray-500"
>
Revisar PDF
</a>
)}
</div>
{desglose ? (
<div className="flex flex-col gap-4 mt-2">
{/* Partidas */}
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-400 uppercase tracking-wide border-b border-gray-100">
<th className="pb-2 font-semibold">Partida</th>
<th className="pb-2 font-semibold text-right">Importe</th>
</tr>
</thead>
<tbody>
{desglose.partidas.map((partida) => (
<tr key={partida.key} className="border-b border-gray-50">
<td className="py-1.5 text-gray-700">{partida.label}</td>
<td className="py-1.5 text-right text-black font-medium">
{formatEuros(partida.importe)}
</td>
</tr>
))}
</tbody>
</table>
{yaEnviado && (
<div className="text-sm font-medium text-green-700 bg-green-50 rounded-lg px-3 py-2">
Presupuesto enviado al cliente por WhatsApp
</div>
)}
{/* Subtotal, factor zona, total */}
<div className="flex flex-col gap-1 text-sm border-t border-gray-200 pt-3">
<div className="flex justify-between">
<span className="text-gray-500">Subtotal</span>
<span className="text-black font-medium">{formatEuros(desglose.subtotal)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Factor de zona</span>
<span className="text-black font-medium">×{desglose.factorZona.toFixed(2)}</span>
</div>
<div className="flex justify-between mt-1 pt-2 border-t border-gray-200">
<span className="text-black font-bold">Total estimado</span>
<span className="text-black font-bold text-lg">{formatEuros(desglose.total)}</span>
</div>
</div>
{/* Conceptos editables + subtotal/factor zona/total */}
<ConceptosEditor
leadId={lead.id}
partidas={desglose.partidas}
factorZona={desglose.factorZona}
bloqueado={yaEnviado}
/>
{/* Rango */}
<div className="flex justify-between text-sm">
@@ -300,6 +292,18 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
<p className="text-xs text-gray-400">
Presupuesto orientativo. El precio final puede variar según la visita técnica.
</p>
{/* Enviar al cliente (envío simulado: registra la entrega por WhatsApp) */}
{!yaEnviado && (
<form action={enviarPresupuesto.bind(null, lead.id)} className="border-t border-gray-200 pt-4">
<button
type="submit"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600 text-white text-sm font-semibold w-fit hover:bg-green-700"
>
Enviar al cliente por WhatsApp
</button>
</form>
)}
</div>
) : (
<p className="text-sm text-gray-400">Aún no se ha calculado el presupuesto.</p>

View File

@@ -0,0 +1,46 @@
import { notFound } from 'next/navigation';
import { renderToBuffer } from '@react-pdf/renderer';
import { getLead } from '@/db/queries';
import { getTenantPerfil } from '@/db/tenant-queries';
import { TIPO_LABEL } from '@/lib/funnel';
import { PresupuestoDoc } from '@/lib/pdf/PresupuestoDoc';
import type { BudgetResult } from '@/budget/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const data = await getLead(id);
if (!data) notFound();
const { lead } = data;
const empresa = await getTenantPerfil();
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
const desglose = snapshot?.result ?? null;
const buffer = await renderToBuffer(
PresupuestoDoc({
empresa,
cliente: { nombre: lead.nombre, telefono: lead.telefono, provincia: lead.provincia },
reforma: {
tipoLabel: lead.tipoReforma ? TIPO_LABEL[lead.tipoReforma] : 'Reforma',
fecha: lead.createdAt,
},
desglose,
})
);
const slug = lead.nombre.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
return new Response(new Uint8Array(buffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="presupuesto-${slug || lead.id}.pdf"`,
'Cache-Control': 'no-store',
},
});
}

View File

@@ -7,7 +7,8 @@ import { leads, leadEstadoHistory, leadPipelineEventos, precisionHistory } from
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
import { getPricingConfig, getCatalog } from '@/db/pricing-queries';
import { computeBudget } from '@/budget';
import type { BudgetInputs } from '@/budget/types';
import { applyConceptoEdits } from '@/budget/edit';
import type { BudgetInputs, BudgetResult, PartidaResult } from '@/budget/types';
type Estado = (typeof leads.estado.enumValues)[number];
@@ -61,6 +62,73 @@ export async function marcarGanado(leadId: string, precioFinalEuros: number) {
revalidatePath(`/panel/${leadId}`);
}
export async function editarConceptos(leadId: string, formData: FormData) {
const tenantId = await getTenantId();
const [lead] = await db
.select()
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
.limit(1);
if (!lead) throw new Error('Lead no encontrado.');
const snapshot = lead.desgloseSnapshot as ({ result: BudgetResult } & Record<string, unknown>) | null;
if (!snapshot?.result) {
throw new Error('El lead no tiene presupuesto que editar.');
}
const keys = formData.getAll('key').map(String);
const labels = formData.getAll('label').map(String);
const importes = formData.getAll('importeEuros').map((v) => Number(v));
const partidas: PartidaResult[] = labels.map((label, i) => {
const euros = importes[i];
const importe = Number.isFinite(euros) ? Math.round(euros * 100) : 0;
return { key: keys[i] || `custom-${i}`, label, importe };
});
const edited = applyConceptoEdits(snapshot.result, partidas);
await db
.update(leads)
.set({
presupuestoEstimado: edited.total,
desgloseSnapshot: { ...snapshot, result: edited },
updatedAt: new Date(),
})
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)));
revalidatePath('/panel');
revalidatePath(`/panel/${leadId}`);
}
export async function enviarPresupuesto(leadId: string) {
const tenantId = await getTenantId();
const [lead] = await db
.select()
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
.limit(1);
if (!lead) throw new Error('Lead no encontrado.');
if (lead.presupuestoEstimado == null) {
throw new Error('El lead no tiene presupuesto que enviar.');
}
await db
.update(leads)
.set({ estado: 'presupuesto_enviado', pipelineStage: 'whatsapp_entregado', updatedAt: new Date() })
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)));
await db.insert(leadEstadoHistory).values({ leadId, estado: 'presupuesto_enviado' });
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'whatsapp_entregado',
metadata: { via: 'whatsapp', simulado: true, total: lead.presupuestoEstimado },
});
revalidatePath('/panel');
revalidatePath(`/panel/${leadId}`);
}
export async function recalcularPresupuesto(leadId: string) {
const tenantId = await getTenantId();
const [lead] = await db

View File

@@ -0,0 +1,66 @@
'use server';
import { eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';
import { tenants } from '@/db/schema';
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
const LOGO_MAX_BYTES = 500_000;
const LOGO_TIPOS = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
function limpiar(raw: FormDataEntryValue | null): string | null {
const s = String(raw ?? '').trim();
return s.length > 0 ? s : null;
}
export async function actualizarEmpresa(formData: FormData) {
const tenantId = await getTenantId();
const nombreEmpresa = limpiar(formData.get('nombreEmpresa'));
if (!nombreEmpresa) {
throw new Error('El nombre de la empresa es obligatorio.');
}
await db
.update(tenants)
.set({
nombreEmpresa,
cif: limpiar(formData.get('cif')),
direccion: limpiar(formData.get('direccion')),
provincia: limpiar(formData.get('provincia')),
telefono: limpiar(formData.get('telefono')),
email: limpiar(formData.get('email')),
web: limpiar(formData.get('web')),
})
.where(eq(tenants.id, tenantId));
revalidatePath('/panel/empresa');
revalidatePath('/panel');
}
export type LogoResult = { ok: boolean; error?: string };
export async function subirLogo(_prev: LogoResult | null, formData: FormData): Promise<LogoResult> {
const tenantId = await getTenantId();
const file = formData.get('logo');
if (!(file instanceof File) || file.size === 0) {
return { ok: false, error: 'Selecciona un archivo de imagen.' };
}
if (!LOGO_TIPOS.includes(file.type)) {
return { ok: false, error: 'Formato no válido. Usa PNG, JPG, WEBP o SVG.' };
}
if (file.size > LOGO_MAX_BYTES) {
return { ok: false, error: 'El logo no puede superar los 500 KB.' };
}
const base64 = Buffer.from(await file.arrayBuffer()).toString('base64');
const dataUri = `data:${file.type};base64,${base64}`;
await db.update(tenants).set({ logoUrl: dataUri }).where(eq(tenants.id, tenantId));
revalidatePath('/panel/empresa');
revalidatePath('/panel');
return { ok: true };
}
export async function quitarLogo() {
const tenantId = await getTenantId();
await db.update(tenants).set({ logoUrl: null }).where(eq(tenants.id, tenantId));
revalidatePath('/panel/empresa');
revalidatePath('/panel');
}

View File

@@ -0,0 +1,93 @@
import { getTenantPerfil } from '@/db/tenant-queries';
import { actualizarEmpresa } from './actions';
import LogoUploader from '@/components/panel/LogoUploader';
export const dynamic = 'force-dynamic';
export default async function EmpresaPage() {
const perfil = await getTenantPerfil();
return (
<div className="space-y-10 max-w-2xl">
<div>
<h1 className="text-2xl font-extrabold tracking-tight text-black">Datos de empresa</h1>
<p className="text-sm text-gray-500 mt-1">
Estos datos y el logo aparecen en la cabecera de los presupuestos en PDF que recibe el
cliente. Manténlos al día.
</p>
</div>
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-4">Logo</h2>
<LogoUploader logoUrl={perfil.logoUrl} />
</section>
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-4">Identidad</h2>
<form action={actualizarEmpresa} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="text-sm md:col-span-2">
<span className="block text-gray-500 mb-1">Nombre de la empresa *</span>
<input
name="nombreEmpresa"
required
defaultValue={perfil.nombreEmpresa}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm">
<span className="block text-gray-500 mb-1">CIF / NIF</span>
<input
name="cif"
defaultValue={perfil.cif ?? ''}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm">
<span className="block text-gray-500 mb-1">Provincia</span>
<input
name="provincia"
defaultValue={perfil.provincia ?? ''}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm md:col-span-2">
<span className="block text-gray-500 mb-1">Dirección</span>
<input
name="direccion"
defaultValue={perfil.direccion ?? ''}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm">
<span className="block text-gray-500 mb-1">Teléfono</span>
<input
name="telefono"
defaultValue={perfil.telefono ?? ''}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm">
<span className="block text-gray-500 mb-1">Email</span>
<input
name="email"
type="email"
defaultValue={perfil.email ?? ''}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm md:col-span-2">
<span className="block text-gray-500 mb-1">Web</span>
<input
name="web"
defaultValue={perfil.web ?? ''}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<button className="md:col-span-2 justify-self-start bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
Guardar datos
</button>
</form>
</section>
</div>
);
}

View File

@@ -32,6 +32,7 @@ export default async function PanelLayout({ children }: { children: React.ReactN
<nav className="flex items-center gap-4 text-xs font-medium">
<Link href="/panel" className="text-gray-500 hover:text-black">Leads</Link>
<Link href="/panel/precios" className="text-gray-500 hover:text-black">Precios</Link>
<Link href="/panel/empresa" className="text-gray-500 hover:text-black">Empresa</Link>
<form action="/logout" method="post">
<button type="submit" className="text-gray-500 hover:text-black">Salir</button>
</form>

View File

@@ -3,8 +3,8 @@
import { and, eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';
import { catalogItems, pricingConfig } from '@/db/schema';
import { getTenantId } from '@/db/pricing-queries';
import { catalogItems, pricingConfig, tenants } from '@/db/schema';
import { getTenantId, type EnvioMode } from '@/db/pricing-queries';
import { parseCatalogCsv } from '@/budget/csv';
// Valida un importe en euros del formulario y lo convierte a céntimos.
@@ -76,6 +76,19 @@ export async function actualizarConfig(formData: FormData) {
revalidatePath('/panel/precios');
}
export async function actualizarEnvio(formData: FormData) {
const tenantId = await getTenantId();
const modo = formData.get('modo');
if (modo !== 'automatico' && modo !== 'revision') {
throw new Error('Modo de envío no válido.');
}
await db
.update(tenants)
.set({ envioPresupuesto: modo as EnvioMode })
.where(eq(tenants.id, tenantId));
revalidatePath('/panel/precios');
}
export type ImportResult = { ok: boolean; inserted: number; errors: { line: number; message: string }[] };
export async function importarCatalogoCsv(_prev: ImportResult | null, formData: FormData): Promise<ImportResult> {

View File

@@ -1,9 +1,10 @@
import { getPricingConfig, getCatalog } from '@/db/pricing-queries';
import { getPricingConfig, getCatalog, getEnvioMode } from '@/db/pricing-queries';
import {
crearMaterial,
actualizarPrecio,
borrarMaterial,
actualizarConfig,
actualizarEnvio,
importarCatalogoCsv,
} from './actions';
@@ -18,7 +19,11 @@ const CATEGORIA_LABEL: Record<(typeof CATEGORIAS)[number], string> = {
};
export default async function PreciosPage() {
const [config, catalog] = await Promise.all([getPricingConfig(), getCatalog()]);
const [config, catalog, envioMode] = await Promise.all([
getPricingConfig(),
getCatalog(),
getEnvioMode(),
]);
return (
<div className="space-y-10">
@@ -30,6 +35,50 @@ export default async function PreciosPage() {
</p>
</div>
{/* Envío de presupuestos */}
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-1">Envío de presupuestos</h2>
<p className="text-sm text-gray-500 mb-4">
Decide si el presupuesto se entrega al cliente automáticamente al final del funnel o si
quieres revisarlo y editar los conceptos antes de enviarlo.
</p>
<form action={actualizarEnvio} className="flex flex-col gap-3">
<label className="flex items-start gap-3 text-sm cursor-pointer">
<input
type="radio"
name="modo"
value="automatico"
defaultChecked={envioMode === 'automatico'}
className="mt-1"
/>
<span>
<span className="block font-medium text-black">Envío automático</span>
<span className="block text-gray-500">
El cliente recibe el presupuesto por WhatsApp en cuanto el funnel lo genera.
</span>
</span>
</label>
<label className="flex items-start gap-3 text-sm cursor-pointer">
<input
type="radio"
name="modo"
value="revision"
defaultChecked={envioMode === 'revision'}
className="mt-1"
/>
<span>
<span className="block font-medium text-black">Revisar antes de enviar</span>
<span className="block text-gray-500">
El funnel se detiene en cada lead para que revises los conceptos y pulses enviar.
</span>
</span>
</label>
<button className="self-start bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
Guardar preferencia
</button>
</form>
</section>
{/* Config general */}
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-4">Configuración general</h2>

View File

@@ -0,0 +1,30 @@
import type { BudgetResult, PartidaResult } from './types';
export const AVISO_EDITADO = 'Presupuesto ajustado manualmente por el reformista.';
// Aplica la edición manual de conceptos del reformista sobre un presupuesto ya calculado.
// Conserva el factor de zona del cálculo original; el reformista ha validado las cifras,
// así que la confianza pasa a alta y el rango colapsa al total.
export function applyConceptoEdits(prev: BudgetResult, partidas: PartidaResult[]): BudgetResult {
const clean: PartidaResult[] = partidas
.map((p) => ({
key: p.key,
label: p.label.trim(),
importe: Math.max(0, Math.round(p.importe)),
}))
.filter((p) => p.label.length > 0);
const subtotal = clean.reduce((s, p) => s + p.importe, 0);
const total = Math.round(subtotal * prev.factorZona);
const avisos = prev.avisos.includes(AVISO_EDITADO) ? prev.avisos : [...prev.avisos, AVISO_EDITADO];
return {
...prev,
partidas: clean,
subtotal,
total,
rango: { min: total, max: total },
confianza: 'alta',
avisos,
};
}

View File

@@ -44,7 +44,9 @@ export interface BudgetInputs {
}
export interface PartidaResult {
key: PartidaKey;
// PartidaKey para las partidas que genera el motor; string libre (p. ej. 'custom-1')
// para las que añade el reformista a mano en la revisión.
key: PartidaKey | string;
label: string;
importe: number; // céntimos (base, antes de factor zona)
}

View File

@@ -0,0 +1,138 @@
'use client';
import { useState, useTransition } from 'react';
import { editarConceptos } from '@/app/panel/actions';
import { formatEuros } from '@/lib/funnel';
import type { PartidaResult } from '@/budget/types';
type Row = { key: string; label: string; euros: string };
function toRows(partidas: PartidaResult[]): Row[] {
return partidas.map((p) => ({
key: p.key,
label: p.label,
euros: (p.importe / 100).toFixed(2),
}));
}
export default function ConceptosEditor({
leadId,
partidas,
factorZona,
bloqueado,
}: {
leadId: string;
partidas: PartidaResult[];
factorZona: number;
bloqueado: boolean;
}) {
const [rows, setRows] = useState<Row[]>(() => toRows(partidas));
const [pending, startTransition] = useTransition();
const [guardado, setGuardado] = useState(false);
const subtotal = rows.reduce((s, r) => {
const euros = Number(r.euros);
return s + (Number.isFinite(euros) && euros > 0 ? Math.round(euros * 100) : 0);
}, 0);
const total = Math.round(subtotal * factorZona);
function updateRow(i: number, patch: Partial<Row>) {
setRows((prev) => prev.map((r, j) => (j === i ? { ...r, ...patch } : r)));
setGuardado(false);
}
function removeRow(i: number) {
setRows((prev) => prev.filter((_, j) => j !== i));
setGuardado(false);
}
function addRow() {
setRows((prev) => [...prev, { key: `custom-${Date.now()}`, label: '', euros: '0.00' }]);
setGuardado(false);
}
function onSubmit(formData: FormData) {
startTransition(async () => {
await editarConceptos(leadId, formData);
setGuardado(true);
});
}
return (
<form action={onSubmit} className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
{rows.map((row, i) => (
<div key={row.key} className="flex items-center gap-2">
<input type="hidden" name="key" value={row.key} />
<input
name="label"
value={row.label}
onChange={(e) => updateRow(i, { label: e.target.value })}
disabled={bloqueado}
placeholder="Concepto"
className="flex-1 border border-gray-300 rounded-lg px-2 py-1 text-sm text-gray-700 disabled:bg-gray-50"
/>
<input
name="importeEuros"
type="number"
min="0"
step="0.01"
value={row.euros}
onChange={(e) => updateRow(i, { euros: e.target.value })}
disabled={bloqueado}
className="w-28 border border-gray-300 rounded-lg px-2 py-1 text-sm text-right text-black disabled:bg-gray-50"
/>
<span className="text-gray-400 text-sm w-4"></span>
<button
type="button"
onClick={() => removeRow(i)}
disabled={bloqueado}
aria-label="Quitar concepto"
className="text-red-500 text-sm w-6 hover:text-red-700 disabled:opacity-30"
>
</button>
</div>
))}
</div>
{!bloqueado && (
<button
type="button"
onClick={addRow}
className="self-start text-sm text-blue-600 hover:underline"
>
+ Añadir concepto
</button>
)}
<div className="flex flex-col gap-1 text-sm border-t border-gray-200 pt-3">
<div className="flex justify-between">
<span className="text-gray-500">Subtotal</span>
<span className="text-black font-medium">{formatEuros(subtotal)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Factor de zona</span>
<span className="text-black font-medium">×{factorZona.toFixed(2)}</span>
</div>
<div className="flex justify-between mt-1 pt-2 border-t border-gray-200">
<span className="text-black font-bold">Total estimado</span>
<span className="text-black font-bold text-lg">{formatEuros(total)}</span>
</div>
</div>
{!bloqueado && (
<div className="flex items-center gap-3">
<button
type="submit"
disabled={pending}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-black text-white text-sm font-semibold w-fit hover:bg-gray-800 disabled:opacity-50"
>
{pending ? 'Guardando…' : 'Guardar conceptos'}
</button>
{guardado && <span className="text-sm text-green-600">Guardado </span>}
</div>
)}
</form>
);
}

View File

@@ -0,0 +1,50 @@
'use client';
import { useActionState } from 'react';
import { subirLogo, quitarLogo, type LogoResult } from '@/app/panel/empresa/actions';
export default function LogoUploader({ logoUrl }: { logoUrl: string | null }) {
const [state, formAction, pending] = useActionState<LogoResult | null, FormData>(subirLogo, null);
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4">
<div className="w-32 h-20 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center overflow-hidden">
{logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" />
) : (
<span className="text-xs text-gray-400">Sin logo</span>
)}
</div>
{logoUrl && (
<form action={quitarLogo}>
<button type="submit" className="text-xs text-red-500 hover:underline">
Quitar logo
</button>
</form>
)}
</div>
<form action={formAction} className="flex flex-wrap items-center gap-2">
<input
type="file"
name="logo"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
className="text-sm file:mr-2 file:rounded-lg file:border-0 file:bg-black file:text-white file:px-3 file:py-1.5 file:text-sm file:font-medium"
/>
<button
type="submit"
disabled={pending}
className="bg-black text-white rounded-lg px-4 py-1.5 text-sm font-medium disabled:opacity-50"
>
{pending ? 'Subiendo…' : 'Subir logo'}
</button>
</form>
{state?.error && <p className="text-sm text-red-600">{state.error}</p>}
{state?.ok && <p className="text-sm text-green-600">Logo actualizado </p>}
<p className="text-xs text-gray-400">PNG, JPG, WEBP o SVG · máx. 500 KB.</p>
</div>
);
}

View File

@@ -1,9 +1,21 @@
import { eq } from 'drizzle-orm';
import { db } from './index';
import { pricingConfig, catalogItems } from './schema';
import { pricingConfig, catalogItems, tenants } from './schema';
import type { PricingConfig, CatalogItem, ManoObraKey } from '@/budget/types';
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
export type EnvioMode = (typeof tenants.envioPresupuesto.enumValues)[number];
export async function getEnvioMode(): Promise<EnvioMode> {
const tenantId = await getTenantId();
const [row] = await db
.select({ modo: tenants.envioPresupuesto })
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1);
return row?.modo ?? 'automatico';
}
const MANO_OBRA_DEFAULT: Record<ManoObraKey, number> = {
demolicion: 0,
fontaneria: 0,

View File

@@ -65,17 +65,28 @@ export const subscriptionStatus = pgEnum('subscription_status', [
'vencido',
]);
// Cómo entrega el reformista el presupuesto al cliente final.
// 'automatico' = el funnel lo envía solo; 'revision' = se para para que el reformista lo revise/edite antes de enviar.
export const envioPresupuestoMode = pgEnum('envio_presupuesto_mode', ['automatico', 'revision']);
// Reformista (cliente del SaaS). MVP = "Reformas Ejemplo" hardcoded.
// Multi-tenant real es F1.5; la tabla ya queda lista para ello.
export const tenants = pgTable('tenants', {
id: uuid('id').primaryKey().defaultRandom(),
slug: text('slug').notNull().unique(),
nombreEmpresa: text('nombre_empresa').notNull(),
logoUrl: text('logo_url'),
logoUrl: text('logo_url'), // data URI base64 del logo (no hay storage externo aún)
provincia: text('provincia'),
whatsappBusiness: text('whatsapp_business'),
// Datos de empresa para la cabecera del presupuesto (RF-D-07).
cif: text('cif'),
direccion: text('direccion'),
telefono: text('telefono'),
email: text('email'),
web: text('web'),
planId: uuid('plan_id').references((): AnyPgColumn => plans.id),
subscriptionStatus: subscriptionStatus('subscription_status').notNull().default('trial'),
envioPresupuesto: envioPresupuestoMode('envio_presupuesto').notNull().default('automatico'),
trialEndsAt: timestamp('trial_ends_at', { withTimezone: true }),
stripeCustomerId: text('stripe_customer_id'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),

View File

@@ -0,0 +1,46 @@
import { eq } from 'drizzle-orm';
import { db } from './index';
import { tenants } from './schema';
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
export type TenantPerfil = {
nombreEmpresa: string;
logoUrl: string | null;
provincia: string | null;
cif: string | null;
direccion: string | null;
telefono: string | null;
email: string | null;
web: string | null;
};
export async function getTenantPerfil(): Promise<TenantPerfil> {
const tenantId = await getTenantId();
const [row] = await db
.select({
nombreEmpresa: tenants.nombreEmpresa,
logoUrl: tenants.logoUrl,
provincia: tenants.provincia,
cif: tenants.cif,
direccion: tenants.direccion,
telefono: tenants.telefono,
email: tenants.email,
web: tenants.web,
})
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1);
return (
row ?? {
nombreEmpresa: 'Reformix',
logoUrl: null,
provincia: null,
cif: null,
direccion: null,
telefono: null,
email: null,
web: null,
}
);
}

View File

@@ -0,0 +1,248 @@
import { Document, Page, Text, View, Image, StyleSheet } from '@react-pdf/renderer';
import type { BudgetResult } from '@/budget/types';
import type { TenantPerfil } from '@/db/tenant-queries';
const COLOR = {
black: '#0a0a0a',
dark: '#111111',
gray600: '#555555',
gray400: '#888888',
gray200: '#e5e5e5',
gray100: '#f5f5f5',
accent: '#0066ff',
accentLight: '#e8f0fe',
};
const styles = StyleSheet.create({
page: {
paddingTop: 40,
paddingBottom: 56,
paddingHorizontal: 44,
fontSize: 10,
fontFamily: 'Helvetica',
color: COLOR.dark,
lineHeight: 1.4,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
paddingBottom: 16,
borderBottomWidth: 2,
borderBottomColor: COLOR.black,
},
empresaNombre: { fontSize: 18, fontFamily: 'Helvetica-Bold', color: COLOR.black },
empresaDato: { fontSize: 8, color: COLOR.gray600, marginTop: 1 },
logo: { maxHeight: 48, maxWidth: 140, objectFit: 'contain' },
docTitle: {
marginTop: 18,
fontSize: 13,
fontFamily: 'Helvetica-Bold',
color: COLOR.black,
letterSpacing: 0.5,
},
metaRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 14,
paddingVertical: 12,
paddingHorizontal: 14,
backgroundColor: COLOR.gray100,
borderRadius: 6,
},
metaLabel: {
fontSize: 7,
color: COLOR.gray400,
textTransform: 'uppercase',
letterSpacing: 0.8,
marginBottom: 2,
},
metaValueBold: { fontSize: 10, fontFamily: 'Helvetica-Bold', color: COLOR.black },
metaValue: { fontSize: 9, color: COLOR.gray600 },
tableHead: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: COLOR.gray200,
paddingBottom: 6,
marginTop: 24,
},
thConcepto: {
flex: 1,
fontSize: 7,
color: COLOR.gray400,
textTransform: 'uppercase',
letterSpacing: 0.8,
fontFamily: 'Helvetica-Bold',
},
thImporte: {
width: 90,
textAlign: 'right',
fontSize: 7,
color: COLOR.gray400,
textTransform: 'uppercase',
letterSpacing: 0.8,
fontFamily: 'Helvetica-Bold',
},
row: {
flexDirection: 'row',
paddingVertical: 6,
borderBottomWidth: 1,
borderBottomColor: COLOR.gray100,
},
tdConcepto: { flex: 1, fontSize: 10, color: COLOR.dark },
tdImporte: { width: 90, textAlign: 'right', fontSize: 10, fontFamily: 'Helvetica-Bold' },
totalsBox: { marginTop: 16, marginLeft: 'auto', width: 220 },
totalsLine: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 2 },
totalsLabel: { fontSize: 9, color: COLOR.gray600 },
totalsValue: { fontSize: 9, fontFamily: 'Helvetica-Bold', color: COLOR.dark },
totalFinal: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 8,
paddingTop: 10,
paddingHorizontal: 12,
paddingBottom: 10,
backgroundColor: COLOR.accentLight,
borderRadius: 6,
},
totalFinalLabel: { fontSize: 10, fontFamily: 'Helvetica-Bold', color: COLOR.black },
totalFinalValue: { fontSize: 16, fontFamily: 'Helvetica-Bold', color: COLOR.accent },
rango: { marginTop: 8, fontSize: 8, color: COLOR.gray400, textAlign: 'right' },
avisos: { marginTop: 20 },
avisoTitle: {
fontSize: 7,
color: COLOR.gray400,
textTransform: 'uppercase',
letterSpacing: 0.8,
marginBottom: 4,
},
avisoItem: { fontSize: 8, color: COLOR.gray600, marginBottom: 2 },
footer: {
position: 'absolute',
bottom: 28,
left: 44,
right: 44,
paddingTop: 10,
borderTopWidth: 1,
borderTopColor: COLOR.gray200,
fontSize: 7,
color: COLOR.gray400,
textAlign: 'center',
},
empty: { marginTop: 40, fontSize: 11, color: COLOR.gray400, textAlign: 'center' },
});
const fmtEuros = (cents: number) =>
new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
}).format(cents / 100);
const fmtFecha = (date: Date) =>
new Intl.DateTimeFormat('es-ES', { day: '2-digit', month: 'long', year: 'numeric' }).format(date);
export type PresupuestoDocProps = {
empresa: TenantPerfil;
cliente: { nombre: string; telefono: string; provincia: string | null };
reforma: { tipoLabel: string; fecha: Date };
desglose: BudgetResult | null;
};
export function PresupuestoDoc({ empresa, cliente, reforma, desglose }: PresupuestoDocProps) {
const contacto = [empresa.telefono, empresa.email, empresa.web].filter(Boolean).join(' · ');
return (
<Document
title={`Presupuesto ${empresa.nombreEmpresa}${cliente.nombre}`}
author={empresa.nombreEmpresa}
>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<View>
<Text style={styles.empresaNombre}>{empresa.nombreEmpresa}</Text>
{empresa.cif ? <Text style={styles.empresaDato}>CIF: {empresa.cif}</Text> : null}
{empresa.direccion ? <Text style={styles.empresaDato}>{empresa.direccion}</Text> : null}
{contacto ? <Text style={styles.empresaDato}>{contacto}</Text> : null}
</View>
{empresa.logoUrl ? (
// eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf Image, no alt prop
<Image src={empresa.logoUrl} style={styles.logo} />
) : null}
</View>
<Text style={styles.docTitle}>PRESUPUESTO ORIENTATIVO DE REFORMA</Text>
<View style={styles.metaRow}>
<View>
<Text style={styles.metaLabel}>Cliente</Text>
<Text style={styles.metaValueBold}>{cliente.nombre}</Text>
<Text style={styles.metaValue}>{cliente.telefono}</Text>
</View>
<View style={{ alignItems: 'flex-end' }}>
<Text style={styles.metaLabel}>Reforma</Text>
<Text style={styles.metaValueBold}>{reforma.tipoLabel}</Text>
<Text style={styles.metaValue}>
{(cliente.provincia ?? '—') + ' · ' + fmtFecha(reforma.fecha)}
</Text>
</View>
</View>
{desglose ? (
<>
<View style={styles.tableHead}>
<Text style={styles.thConcepto}>Concepto</Text>
<Text style={styles.thImporte}>Importe</Text>
</View>
{desglose.partidas.map((p, i) => (
<View style={styles.row} key={`${p.key}-${i}`}>
<Text style={styles.tdConcepto}>{p.label}</Text>
<Text style={styles.tdImporte}>{fmtEuros(p.importe)}</Text>
</View>
))}
<View style={styles.totalsBox}>
<View style={styles.totalsLine}>
<Text style={styles.totalsLabel}>Subtotal</Text>
<Text style={styles.totalsValue}>{fmtEuros(desglose.subtotal)}</Text>
</View>
<View style={styles.totalsLine}>
<Text style={styles.totalsLabel}>Factor de zona</Text>
<Text style={styles.totalsValue}>×{desglose.factorZona.toFixed(2)}</Text>
</View>
<View style={styles.totalFinal}>
<Text style={styles.totalFinalLabel}>Total estimado</Text>
<Text style={styles.totalFinalValue}>{fmtEuros(desglose.total)}</Text>
</View>
{desglose.rango.min !== desglose.rango.max ? (
<Text style={styles.rango}>
Rango: {fmtEuros(desglose.rango.min)} {fmtEuros(desglose.rango.max)}
</Text>
) : null}
</View>
{desglose.avisos.length > 0 ? (
<View style={styles.avisos}>
<Text style={styles.avisoTitle}>Notas</Text>
{desglose.avisos.map((a, i) => (
<Text style={styles.avisoItem} key={i}>
{a}
</Text>
))}
</View>
) : null}
</>
) : (
<Text style={styles.empty}>Este lead aún no tiene presupuesto calculado.</Text>
)}
<Text style={styles.footer} fixed>
Presupuesto orientativo. El precio final puede variar según la visita técnica.
{' · '}
{empresa.nombreEmpresa}
</Text>
</Page>
</Document>
);
}

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { applyConceptoEdits, AVISO_EDITADO } from '@/budget/edit';
import type { BudgetResult } from '@/budget/types';
const base: BudgetResult = {
partidas: [
{ key: 'demolicion', label: 'Demolición', importe: 100000 },
{ key: 'alicatado', label: 'Alicatado y solado', importe: 200000 },
],
subtotal: 300000,
factorZona: 1.2,
total: 360000,
rango: { min: 324000, max: 396000 },
confianza: 'media',
materialesRender: ['azulejo blanco'],
avisos: ['Sin precio para pintura'],
};
describe('applyConceptoEdits', () => {
it('recalcula subtotal y total aplicando el factor de zona', () => {
const result = applyConceptoEdits(base, [
{ key: 'demolicion', label: 'Demolición', importe: 150000 },
{ key: 'alicatado', label: 'Alicatado y solado', importe: 200000 },
]);
expect(result.subtotal).toBe(350000);
expect(result.total).toBe(420000); // 350000 * 1.2
});
it('permite añadir partidas libres y quitar existentes', () => {
const result = applyConceptoEdits(base, [
{ key: 'demolicion', label: 'Demolición', importe: 100000 },
{ key: 'custom-1', label: 'Imprevistos de obra', importe: 50000 },
]);
expect(result.partidas).toHaveLength(2);
expect(result.partidas[1]).toEqual({ key: 'custom-1', label: 'Imprevistos de obra', importe: 50000 });
expect(result.subtotal).toBe(150000);
});
it('descarta partidas sin etiqueta y normaliza importes (>=0, enteros)', () => {
const result = applyConceptoEdits(base, [
{ key: 'a', label: ' ', importe: 999 },
{ key: 'b', label: 'Válida', importe: -500 },
{ key: 'c', label: 'Decimal', importe: 123.7 },
]);
expect(result.partidas).toEqual([
{ key: 'b', label: 'Válida', importe: 0 },
{ key: 'c', label: 'Decimal', importe: 124 },
]);
});
it('marca el presupuesto como ajustado a mano (aviso idempotente, confianza alta, rango = total)', () => {
const once = applyConceptoEdits(base, base.partidas);
expect(once.confianza).toBe('alta');
expect(once.avisos).toContain(AVISO_EDITADO);
expect(once.rango).toEqual({ min: once.total, max: once.total });
const twice = applyConceptoEdits(once, once.partidas);
expect(twice.avisos.filter((a) => a === AVISO_EDITADO)).toHaveLength(1);
});
});

View File

@@ -36,7 +36,7 @@ Reformix es un SaaS B2B para reformistas en España que entrega al cliente final
|---|---|---|
| **F1 — Landings** | 28-may-2026 | Landings B2B + B2C live, tracking activo, ads activos |
| **F2 — MVP** | 11-jun-2026 | Funnel B2C completo end-to-end (form → llamada → render → entrega WhatsApp) + Panel del reformista mínimo |
| F1.5 | post-hackathon | Configurador multi-tenant, validación pre-envío, NL refinement, 3 versiones B/M/P, m² automático |
| F1.5 | post-hackathon | Configurador multi-tenant, ~~validación pre-envío~~ (adelantada a F2, ver nota), NL refinement, 3 versiones B/M/P, m² automático |
| F2 (Producto) | mes 9+ | Marketplace B2C + valorador "Precio Justo" + sello certificado |
---
@@ -263,7 +263,7 @@ Objetivo: dar al reformista visibilidad de leads, capacidad de marcarlos y de ce
## Out of Scope (F1 + F2)
- **Configurador multi-tenant real** del reformista (hardcoded en MVP).
- **Validación opcional del reformista** antes de que el cliente reciba el presupuesto.
- ~~**Validación opcional del reformista** antes de que el cliente reciba el presupuesto.~~ → **Adelantada a F2 (implementada).** El panel permite elegir modo de envío (automático / revisión), editar los conceptos del presupuesto y revisar el PDF antes de enviarlo al cliente.
- **Nurturing y seguimiento del lead** por WhatsApp hasta concertar cita.
- **3 versiones simultáneas Básico/Medio/Premium** con selección granular por elemento.
- **Slider de presupuesto target** con auto-ajuste.