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:
2
mvp/b2c/drizzle/0003_youthful_white_queen.sql
Normal file
2
mvp/b2c/drizzle/0003_youthful_white_queen.sql
Normal 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;
|
||||||
5
mvp/b2c/drizzle/0004_even_stranger.sql
Normal file
5
mvp/b2c/drizzle/0004_even_stranger.sql
Normal 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;
|
||||||
1177
mvp/b2c/drizzle/meta/0003_snapshot.json
Normal file
1177
mvp/b2c/drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1207
mvp/b2c/drizzle/meta/0004_snapshot.json
Normal file
1207
mvp/b2c/drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,20 @@
|
|||||||
"when": 1780162638625,
|
"when": 1780162638625,
|
||||||
"tag": "0002_overjoyed_the_renegades",
|
"tag": "0002_overjoyed_the_renegades",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
// @react-pdf/renderer usa módulos nativos/wasm (yoga, fontkit) que no deben bundlearse.
|
||||||
|
serverExternalPackages: ['@react-pdf/renderer'],
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
// Landing B2B estática (mvp/b2b) servida en /b2b. El fichero vive en public/b2b.html.
|
// Landing B2B estática (mvp/b2b) servida en /b2b. El fichero vive en public/b2b.html.
|
||||||
|
|||||||
569
mvp/b2c/package-lock.json
generated
569
mvp/b2c/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "landing-page",
|
"name": "landing-page",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
@@ -237,6 +238,15 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||||
@@ -2094,6 +2104,30 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -2152,6 +2186,183 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"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": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
|
"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"
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -3821,6 +4038,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.32",
|
"version": "2.10.32",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
|
||||||
@@ -3842,6 +4079,15 @@
|
|||||||
"bcrypt": "bin/bcrypt"
|
"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": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
||||||
@@ -3866,6 +4112,24 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.2",
|
"version": "4.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||||
@@ -4020,6 +4284,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -4040,6 +4313,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -4207,6 +4501,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/doctrine": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||||
@@ -4403,6 +4703,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz",
|
||||||
@@ -5081,6 +5387,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
@@ -5095,7 +5410,6 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
@@ -5152,6 +5466,12 @@
|
|||||||
"reusify": "^1.0.4"
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -5216,6 +5536,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@@ -5551,6 +5888,21 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"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": {
|
"node_modules/html-escaper": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
@@ -5558,6 +5910,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -5595,6 +5953,12 @@
|
|||||||
"node": ">=0.8.19"
|
"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": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||||
@@ -5979,6 +6343,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/is-weakmap": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
||||||
@@ -6096,6 +6466,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/jiti": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
@@ -6105,11 +6484,16 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
@@ -6481,6 +6865,25 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"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": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@@ -6508,7 +6911,6 @@
|
|||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
@@ -6587,6 +6989,12 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -6792,11 +7200,19 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -6994,6 +7410,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -7007,6 +7429,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -7060,6 +7488,14 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"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": "^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": {
|
"node_modules/postgres": {
|
||||||
"version": "3.4.9",
|
"version": "3.4.9",
|
||||||
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
|
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
|
||||||
@@ -7125,7 +7567,6 @@
|
|||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
@@ -7143,6 +7584,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -7189,7 +7639,6 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
@@ -7236,6 +7685,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.7",
|
"version": "2.0.0-next.7",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
"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"
|
"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": {
|
"node_modules/reusify": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||||
@@ -7369,6 +7833,26 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
@@ -7698,6 +8182,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/string.prototype.includes": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
"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"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
||||||
@@ -7902,6 +8401,12 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@@ -8693,6 +9198,32 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/unrs-resolver": {
|
||||||
"version": "1.12.2",
|
"version": "1.12.2",
|
||||||
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz",
|
||||||
@@ -8772,6 +9303,26 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/vitest": {
|
||||||
"version": "4.1.7",
|
"version": "4.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
|
||||||
@@ -9644,6 +10195,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/zod": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Link from 'next/link';
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { getLead } from '@/db/queries';
|
import { getLead } from '@/db/queries';
|
||||||
import EstadoControl from '@/components/panel/EstadoControl';
|
import EstadoControl from '@/components/panel/EstadoControl';
|
||||||
|
import ConceptosEditor from '@/components/panel/ConceptosEditor';
|
||||||
import {
|
import {
|
||||||
PIPELINE_LABEL,
|
PIPELINE_LABEL,
|
||||||
PIPELINE_NEXT,
|
PIPELINE_NEXT,
|
||||||
@@ -10,7 +11,7 @@ import {
|
|||||||
formatEuros,
|
formatEuros,
|
||||||
formatFecha,
|
formatFecha,
|
||||||
} from '@/lib/funnel';
|
} from '@/lib/funnel';
|
||||||
import { recalcularPresupuesto } from '../actions';
|
import { recalcularPresupuesto, enviarPresupuesto } from '../actions';
|
||||||
import type { BudgetResult } from '@/budget/types';
|
import type { BudgetResult } from '@/budget/types';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
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 snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
|
||||||
const desglose = snapshot?.result ?? null;
|
const desglose = snapshot?.result ?? null;
|
||||||
|
const yaEnviado = lead.pipelineStage === 'whatsapp_entregado';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -216,52 +218,42 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
{/* Presupuesto desglosado */}
|
{/* Presupuesto desglosado */}
|
||||||
<Section title="Presupuesto desglosado">
|
<Section title="Presupuesto desglosado">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<form action={recalcularPresupuesto.bind(null, lead.id)}>
|
<form action={recalcularPresupuesto.bind(null, lead.id)}>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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
|
Recalcular desde el catálogo
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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 ? (
|
{desglose ? (
|
||||||
<div className="flex flex-col gap-4 mt-2">
|
<div className="flex flex-col gap-4 mt-2">
|
||||||
{/* Partidas */}
|
{yaEnviado && (
|
||||||
<table className="w-full text-sm">
|
<div className="text-sm font-medium text-green-700 bg-green-50 rounded-lg px-3 py-2">
|
||||||
<thead>
|
Presupuesto enviado al cliente por WhatsApp ✓
|
||||||
<tr className="text-left text-xs text-gray-400 uppercase tracking-wide border-b border-gray-100">
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Subtotal, factor zona, total */}
|
{/* Conceptos editables + subtotal/factor zona/total */}
|
||||||
<div className="flex flex-col gap-1 text-sm border-t border-gray-200 pt-3">
|
<ConceptosEditor
|
||||||
<div className="flex justify-between">
|
leadId={lead.id}
|
||||||
<span className="text-gray-500">Subtotal</span>
|
partidas={desglose.partidas}
|
||||||
<span className="text-black font-medium">{formatEuros(desglose.subtotal)}</span>
|
factorZona={desglose.factorZona}
|
||||||
</div>
|
bloqueado={yaEnviado}
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Rango */}
|
{/* Rango */}
|
||||||
<div className="flex justify-between text-sm">
|
<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">
|
<p className="text-xs text-gray-400">
|
||||||
Presupuesto orientativo. El precio final puede variar según la visita técnica.
|
Presupuesto orientativo. El precio final puede variar según la visita técnica.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-400">Aún no se ha calculado el presupuesto.</p>
|
<p className="text-sm text-gray-400">Aún no se ha calculado el presupuesto.</p>
|
||||||
|
|||||||
46
mvp/b2c/src/app/panel/[id]/presupuesto/route.ts
Normal file
46
mvp/b2c/src/app/panel/[id]/presupuesto/route.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ import { leads, leadEstadoHistory, leadPipelineEventos, precisionHistory } from
|
|||||||
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
||||||
import { getPricingConfig, getCatalog } from '@/db/pricing-queries';
|
import { getPricingConfig, getCatalog } from '@/db/pricing-queries';
|
||||||
import { computeBudget } from '@/budget';
|
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];
|
type Estado = (typeof leads.estado.enumValues)[number];
|
||||||
|
|
||||||
@@ -61,6 +62,73 @@ export async function marcarGanado(leadId: string, precioFinalEuros: number) {
|
|||||||
revalidatePath(`/panel/${leadId}`);
|
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) {
|
export async function recalcularPresupuesto(leadId: string) {
|
||||||
const tenantId = await getTenantId();
|
const tenantId = await getTenantId();
|
||||||
const [lead] = await db
|
const [lead] = await db
|
||||||
|
|||||||
66
mvp/b2c/src/app/panel/empresa/actions.ts
Normal file
66
mvp/b2c/src/app/panel/empresa/actions.ts
Normal 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');
|
||||||
|
}
|
||||||
93
mvp/b2c/src/app/panel/empresa/page.tsx
Normal file
93
mvp/b2c/src/app/panel/empresa/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ export default async function PanelLayout({ children }: { children: React.ReactN
|
|||||||
<nav className="flex items-center gap-4 text-xs font-medium">
|
<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" 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/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">
|
<form action="/logout" method="post">
|
||||||
<button type="submit" className="text-gray-500 hover:text-black">Salir</button>
|
<button type="submit" className="text-gray-500 hover:text-black">Salir</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { catalogItems, pricingConfig } from '@/db/schema';
|
import { catalogItems, pricingConfig, tenants } from '@/db/schema';
|
||||||
import { getTenantId } from '@/db/pricing-queries';
|
import { getTenantId, type EnvioMode } from '@/db/pricing-queries';
|
||||||
import { parseCatalogCsv } from '@/budget/csv';
|
import { parseCatalogCsv } from '@/budget/csv';
|
||||||
|
|
||||||
// Valida un importe en euros del formulario y lo convierte a céntimos.
|
// 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');
|
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 type ImportResult = { ok: boolean; inserted: number; errors: { line: number; message: string }[] };
|
||||||
|
|
||||||
export async function importarCatalogoCsv(_prev: ImportResult | null, formData: FormData): Promise<ImportResult> {
|
export async function importarCatalogoCsv(_prev: ImportResult | null, formData: FormData): Promise<ImportResult> {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { getPricingConfig, getCatalog } from '@/db/pricing-queries';
|
import { getPricingConfig, getCatalog, getEnvioMode } from '@/db/pricing-queries';
|
||||||
import {
|
import {
|
||||||
crearMaterial,
|
crearMaterial,
|
||||||
actualizarPrecio,
|
actualizarPrecio,
|
||||||
borrarMaterial,
|
borrarMaterial,
|
||||||
actualizarConfig,
|
actualizarConfig,
|
||||||
|
actualizarEnvio,
|
||||||
importarCatalogoCsv,
|
importarCatalogoCsv,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
|
|
||||||
@@ -18,7 +19,11 @@ const CATEGORIA_LABEL: Record<(typeof CATEGORIAS)[number], string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function PreciosPage() {
|
export default async function PreciosPage() {
|
||||||
const [config, catalog] = await Promise.all([getPricingConfig(), getCatalog()]);
|
const [config, catalog, envioMode] = await Promise.all([
|
||||||
|
getPricingConfig(),
|
||||||
|
getCatalog(),
|
||||||
|
getEnvioMode(),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
@@ -30,6 +35,50 @@ export default async function PreciosPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* Config general */}
|
||||||
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
<h2 className="font-bold text-black mb-4">Configuración general</h2>
|
<h2 className="font-bold text-black mb-4">Configuración general</h2>
|
||||||
|
|||||||
30
mvp/b2c/src/budget/edit.ts
Normal file
30
mvp/b2c/src/budget/edit.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -44,7 +44,9 @@ export interface BudgetInputs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PartidaResult {
|
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;
|
label: string;
|
||||||
importe: number; // céntimos (base, antes de factor zona)
|
importe: number; // céntimos (base, antes de factor zona)
|
||||||
}
|
}
|
||||||
|
|||||||
138
mvp/b2c/src/components/panel/ConceptosEditor.tsx
Normal file
138
mvp/b2c/src/components/panel/ConceptosEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
mvp/b2c/src/components/panel/LogoUploader.tsx
Normal file
50
mvp/b2c/src/components/panel/LogoUploader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,21 @@
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db } from './index';
|
import { db } from './index';
|
||||||
import { pricingConfig, catalogItems } from './schema';
|
import { pricingConfig, catalogItems, tenants } from './schema';
|
||||||
import type { PricingConfig, CatalogItem, ManoObraKey } from '@/budget/types';
|
import type { PricingConfig, CatalogItem, ManoObraKey } from '@/budget/types';
|
||||||
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
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> = {
|
const MANO_OBRA_DEFAULT: Record<ManoObraKey, number> = {
|
||||||
demolicion: 0,
|
demolicion: 0,
|
||||||
fontaneria: 0,
|
fontaneria: 0,
|
||||||
|
|||||||
@@ -65,17 +65,28 @@ export const subscriptionStatus = pgEnum('subscription_status', [
|
|||||||
'vencido',
|
'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.
|
// Reformista (cliente del SaaS). MVP = "Reformas Ejemplo" hardcoded.
|
||||||
// Multi-tenant real es F1.5; la tabla ya queda lista para ello.
|
// Multi-tenant real es F1.5; la tabla ya queda lista para ello.
|
||||||
export const tenants = pgTable('tenants', {
|
export const tenants = pgTable('tenants', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
slug: text('slug').notNull().unique(),
|
slug: text('slug').notNull().unique(),
|
||||||
nombreEmpresa: text('nombre_empresa').notNull(),
|
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'),
|
provincia: text('provincia'),
|
||||||
whatsappBusiness: text('whatsapp_business'),
|
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),
|
planId: uuid('plan_id').references((): AnyPgColumn => plans.id),
|
||||||
subscriptionStatus: subscriptionStatus('subscription_status').notNull().default('trial'),
|
subscriptionStatus: subscriptionStatus('subscription_status').notNull().default('trial'),
|
||||||
|
envioPresupuesto: envioPresupuestoMode('envio_presupuesto').notNull().default('automatico'),
|
||||||
trialEndsAt: timestamp('trial_ends_at', { withTimezone: true }),
|
trialEndsAt: timestamp('trial_ends_at', { withTimezone: true }),
|
||||||
stripeCustomerId: text('stripe_customer_id'),
|
stripeCustomerId: text('stripe_customer_id'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|||||||
46
mvp/b2c/src/db/tenant-queries.ts
Normal file
46
mvp/b2c/src/db/tenant-queries.ts
Normal 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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
248
mvp/b2c/src/lib/pdf/PresupuestoDoc.tsx
Normal file
248
mvp/b2c/src/lib/pdf/PresupuestoDoc.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
mvp/b2c/tests/budget/edit.test.ts
Normal file
60
mvp/b2c/tests/budget/edit.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
4
specs.md
4
specs.md
@@ -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 |
|
| **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 |
|
| **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 |
|
| 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)
|
## Out of Scope (F1 + F2)
|
||||||
|
|
||||||
- **Configurador multi-tenant real** del reformista (hardcoded en MVP).
|
- **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.
|
- **Nurturing y seguimiento del lead** por WhatsApp hasta concertar cita.
|
||||||
- **3 versiones simultáneas Básico/Medio/Premium** con selección granular por elemento.
|
- **3 versiones simultáneas Básico/Medio/Premium** con selección granular por elemento.
|
||||||
- **Slider de presupuesto target** con auto-ajuste.
|
- **Slider de presupuesto target** con auto-ajuste.
|
||||||
|
|||||||
Reference in New Issue
Block a user