diff --git a/apps/infoalloggi/TODO b/apps/infoalloggi/TODO index c052631..363dd8a 100644 --- a/apps/infoalloggi/TODO +++ b/apps/infoalloggi/TODO @@ -1,5 +1,7 @@ TODOS: - Log email fallite e retry + - piu info negli annunci_servizio sheet + - override magico per saltare inserimento strighe caparra e contratto AFTER MVP: - TODO migrazione app router https://nextjs.org/docs/pages/building-your-application/upgrading/app-router-migration#migrating-from-pages-to-app diff --git a/apps/infoalloggi/check-unused-trpc.ts b/apps/infoalloggi/check-unused-trpc.ts new file mode 100644 index 0000000..38bba1e --- /dev/null +++ b/apps/infoalloggi/check-unused-trpc.ts @@ -0,0 +1,116 @@ +import * as fs from "fs"; +import * as path from "path"; + +// --- CONFIGURATION --- +const ROOT_DIR: string = process.cwd(); +const ROUTERS_DIR: string = path.join(ROOT_DIR, "src/server/api/routers"); +const SCAN_DIRS: string[] = [path.join(ROOT_DIR, "src")]; +const IGNORE_DIRS: string[] = ["node_modules", ".next", "dist"]; + +// Regex to find tRPC procedure definitions +const PROCEDURE_REGEX: RegExp = + /([a-zA-Z0-9_]+)\s*:\s*[a-zA-Z0-9_.]+\.(?:query|mutation|subscription)\s*\(/g; + +interface ProcedureDefinition { + name: string; + file: string; + line: number; +} + +function getAllFiles(dir: string, ext: string[] = [".ts", ".tsx"]): string[] { + let results: string[] = []; + if (!fs.existsSync(dir)) return results; + + const list: string[] = fs.readdirSync(dir); + list.forEach((file: string) => { + const fullPath: string = path.join(dir, file); + const stat = fs.statSync(fullPath); + + if (stat?.isDirectory()) { + if (!IGNORE_DIRS.includes(file)) { + results = results.concat(getAllFiles(fullPath, ext)); + } + } else { + if (ext.some((e) => file.endsWith(e))) { + results.push(fullPath); + } + } + }); + return results; +} + +/** + * Calculates the line number of a specific index in a string + */ +function getLineNumber(content: string, index: number): number { + const linesBefore = content.substring(0, index).split("\n"); + return linesBefore.length; +} + +function run(): void { + console.log("🔍 Scanning for tRPC procedures...\n"); + + const routerFiles: string[] = getAllFiles(ROUTERS_DIR); + const procedures: ProcedureDefinition[] = []; + + // 1. Extract procedure names and their specific line numbers + routerFiles.forEach((filePath: string) => { + const content: string = fs.readFileSync(filePath, "utf8"); + let match: RegExpExecArray | null; + match = PROCEDURE_REGEX.exec(content); + while (match !== null) { + if (match[1]) { + procedures.push({ + name: match[1], + file: filePath, + line: getLineNumber(content, match.index), + }); + } + match = PROCEDURE_REGEX.exec(content); + } + }); + + console.log(`Found ${procedures.length} procedures. Checking for usage...\n`); + + // 2. Get all files to scan + let scanFiles: string[] = []; + SCAN_DIRS.forEach((dir: string) => { + scanFiles = scanFiles.concat(getAllFiles(dir)); + }); + + // 3. Check for usage + const usedNames = new Set(); + + scanFiles.forEach((filePath: string) => { + const content: string = fs.readFileSync(filePath, "utf8"); + + procedures.forEach((proc) => { + if (filePath === proc.file) return; + + // Search for name with word boundaries + const usageRegex = new RegExp(`\\b${proc.name}\\b`); + if (usageRegex.test(content)) { + usedNames.add(proc.name); + } + }); + }); + + // 4. Report results + const unused = procedures.filter((p) => !usedNames.has(p.name)); + + if (unused.length === 0) { + console.log("✅ All good! No unused tRPC procedures found."); + } else { + console.log( + `🚨 Found ${unused.length} potentially unused tRPC procedures:\n`, + ); + unused.forEach((p) => { + const relativePath: string = path.relative(ROOT_DIR, p.file); + // Format: path/to/file.ts:line + // Most IDEs (VS Code, WebStorm) make this clickable in the terminal + console.log(`- ${p.name} -> ${relativePath}:${p.line}`); + }); + } +} + +run(); diff --git a/apps/infoalloggi/package-lock.json b/apps/infoalloggi/package-lock.json index 7317115..529df41 100644 --- a/apps/infoalloggi/package-lock.json +++ b/apps/infoalloggi/package-lock.json @@ -62,10 +62,10 @@ "nodemailer": "^8.0.1", "nodemailer-html-to-text": "^3.2.0", "nuqs": "^2.8.6", + "papaparse": "^5.5.3", "pdf-lib": "^1.17.1", "pg": "^8.19.0", "postcss": "^8.5.6", - "puppeteer-core": "^24.37.5", "radix-ui": "^1.4.3", "react": "^19.2.3", "react-colorful": "^5.6.1", @@ -77,7 +77,7 @@ "react-is": "^19.2.3", "react-leaflet": "^5.0.0", "react-number-format": "^5.4.4", - "react-pdf": "^10.2.0", + "react-pdf": "^10.4.0", "react-phone-number-input": "^3.4.14", "react-reverse-portal": "^2.3.0", "react-select": "^5.10.2", @@ -104,6 +104,7 @@ "@types/node": "^24.2.0", "@types/nodemailer": "^7.0.11", "@types/nodemailer-html-to-text": "^3.1.3", + "@types/papaparse": "^5.5.2", "@types/pg": "^8.16.0", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", @@ -3020,27 +3021,6 @@ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", "license": "MIT" }, - "node_modules/@puppeteer/browsers": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", - "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.4.3", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.5.0", - "semver": "^7.7.4", - "tar-fs": "^3.1.1", - "yargs": "^17.7.2" - }, - "bin": { - "browsers": "lib/cjs/main-cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -6334,12 +6314,6 @@ "url": "https://github.com/sponsors/ueberdosis" } }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", - "license": "MIT" - }, "node_modules/@total-typescript/ts-reset": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/@total-typescript/ts-reset/-/ts-reset-0.6.1.tgz", @@ -6598,6 +6572,16 @@ "integrity": "sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==", "license": "MIT" }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -6678,16 +6662,6 @@ "uuid": "*" } }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@vitejs/plugin-react": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", @@ -6867,6 +6841,7 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -6979,18 +6954,6 @@ "node": ">=12" } }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -7044,20 +7007,6 @@ "proxy-from-env": "^1.1.0" } }, - "node_modules/b4a": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", - "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -7080,98 +7029,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/bare-fs": { - "version": "4.5.5", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz", - "integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.7.0.tgz", - "integrity": "sha512-64Rcwj8qlnTZU8Ps6JJEdSmxBEUGgI7g8l+lMtsJLl4IsfTcHMTfJ188u2iGV6P6YPRZrtv72B2kjn+hp+Yv3g==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz", - "integrity": "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "streamx": "^2.21.0", - "teex": "^1.0.1" - }, - "peerDependencies": { - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/bare-url": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", - "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-path": "^3.0.0" - } - }, "node_modules/base64id": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", @@ -7190,15 +7047,6 @@ "baseline-browser-mapping": "dist/cli.js" } }, - "node_modules/basic-ftp": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", - "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -7268,15 +7116,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -7397,28 +7236,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/chromium-bidi": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", - "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", - "license": "Apache-2.0", - "dependencies": { - "mitt": "^3.0.1", - "zod": "^3.24.1" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, - "node_modules/chromium-bidi/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/citty": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", @@ -7477,37 +7294,6 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -7886,15 +7672,6 @@ "license": "MIT", "peer": true }, - "node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -8082,20 +7859,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -8138,13 +7901,6 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, - "node_modules/devtools-protocol": { - "version": "0.0.1566079", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz", - "integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==", - "license": "BSD-3-Clause", - "peer": true - }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -8306,15 +8062,6 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/engine.io": { "version": "6.6.5", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", @@ -8592,6 +8339,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8609,37 +8357,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/escodegen/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/esm": { "version": "3.2.25", "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", @@ -8650,28 +8367,6 @@ "node": ">=6" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -8682,24 +8377,6 @@ "@types/estree": "^1.0.0" } }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -8782,26 +8459,6 @@ "url": "https://opencollective.com/ramda" } }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8817,12 +8474,6 @@ "node": ">=6.0.0" } }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT" - }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -8894,15 +8545,6 @@ "walk-up-path": "^4.0.0" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -9145,15 +8787,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-east-asian-width": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", @@ -9222,21 +8855,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -9255,20 +8873,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-uri": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", - "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", - "license": "MIT", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/getopts": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz", @@ -9594,6 +9198,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -9607,6 +9212,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -9717,15 +9323,6 @@ "url": "https://opencollective.com/ioredis" } }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -10901,15 +10498,6 @@ "loose-envify": "cli.js" } }, - "node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/lucide-react": { "version": "0.562.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", @@ -11138,12 +10726,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT" - }, "node_modules/motion-dom": { "version": "12.34.3", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz", @@ -11227,15 +10809,6 @@ "node": ">= 0.6" } }, - "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/next": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", @@ -11802,15 +11375,6 @@ ], "license": "MIT" }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -11894,38 +11458,6 @@ "@oxc-resolver/binding-win32-x64-msvc": "11.16.3" } }, - "node_modules/pac-proxy-agent": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", - "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", - "license": "MIT", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.6", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -11938,6 +11470,12 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -12101,12 +11639,6 @@ "url": "https://ko-fi.com/killymxi" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "license": "MIT" - }, "node_modules/pg": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", @@ -12400,15 +11932,6 @@ "node": ">=6" } }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -12637,41 +12160,12 @@ "prosemirror-transform": "^1.1.0" } }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", - "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -12691,45 +12185,6 @@ "node": ">=6" } }, - "node_modules/puppeteer-core": { - "version": "24.37.5", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.37.5.tgz", - "integrity": "sha512-ybL7iE78YPN4T6J+sPLO7r0lSByp/0NN6PvfBEql219cOnttoTFzCWKiBOjstXSqi/OKpwae623DWAsL7cn2MQ==", - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "2.13.0", - "chromium-bidi": "14.0.0", - "debug": "^4.4.3", - "devtools-protocol": "0.0.1566079", - "typed-query-selector": "^2.12.0", - "webdriver-bidi-protocol": "0.4.1", - "ws": "^8.19.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer-core/node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -13249,9 +12704,9 @@ } }, "node_modules/react-pdf": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.3.0.tgz", - "integrity": "sha512-2LQzC9IgNVAX8gM+6F+1t/70a9/5RWThYxc+CWAmT2LW/BRmnj+35x1os5j/nR2oldyf8L+hCAMBmVKU8wrYFA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.4.0.tgz", + "integrity": "sha512-JAuxBX8IXHXuGPVy7d1HbZpAaZF0VIExV3oJByJQM6OWtzn8e2jm6GHRIq7mVY2PKIpYgoLxoglVWm2ITtQ9FQ==", "license": "MIT", "dependencies": { "clsx": "^2.0.0", @@ -13596,15 +13051,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -14182,16 +13628,6 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, "node_modules/smol-toml": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", @@ -14246,34 +13682,6 @@ "node": ">=10.0.0" } }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -14428,17 +13836,6 @@ "node": ">= 0.4" } }, - "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -14762,31 +14159,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tar-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" - } - }, - "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, "node_modules/tarn": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", @@ -14797,25 +14169,6 @@ "node": ">=8.0.0" } }, - "node_modules/teex": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", - "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", - "license": "MIT", - "optional": true, - "dependencies": { - "streamx": "^2.12.5" - } - }, - "node_modules/text-decoder": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", - "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, "node_modules/throttle-debounce": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-3.0.1.tgz", @@ -15130,12 +14483,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typed-query-selector": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", - "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", - "license": "MIT" - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -16061,12 +15408,6 @@ "loose-envify": "^1.0.0" } }, - "node_modules/webdriver-bidi-protocol": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", - "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -16399,12 +15740,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", @@ -16452,15 +15787,6 @@ "node": ">=0.4" } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -16468,43 +15794,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/yoctocolors": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", diff --git a/apps/infoalloggi/package.json b/apps/infoalloggi/package.json index cebc45e..197a760 100644 --- a/apps/infoalloggi/package.json +++ b/apps/infoalloggi/package.json @@ -18,7 +18,8 @@ "types": "tsc --noEmit", "analyze": "cross-env ANALYZE=true next build", "vitest": "vitest run", - "flow": "ts-node flow-gen.ts" + "flow": "ts-node flow-gen.ts", + "unused-trpc": "ts-node check-unused-trpc.ts" }, "version": "0.1.0", "dependencies": { @@ -76,10 +77,10 @@ "nodemailer": "^8.0.1", "nodemailer-html-to-text": "^3.2.0", "nuqs": "^2.8.6", + "papaparse": "^5.5.3", "pdf-lib": "^1.17.1", "pg": "^8.19.0", "postcss": "^8.5.6", - "puppeteer-core": "^24.37.5", "radix-ui": "^1.4.3", "react": "^19.2.3", "react-colorful": "^5.6.1", @@ -91,7 +92,7 @@ "react-is": "^19.2.3", "react-leaflet": "^5.0.0", "react-number-format": "^5.4.4", - "react-pdf": "^10.2.0", + "react-pdf": "^10.4.0", "react-phone-number-input": "^3.4.14", "react-reverse-portal": "^2.3.0", "react-select": "^5.10.2", @@ -118,6 +119,7 @@ "@types/node": "^24.2.0", "@types/nodemailer": "^7.0.11", "@types/nodemailer-html-to-text": "^3.1.3", + "@types/papaparse": "^5.5.2", "@types/pg": "^8.16.0", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", diff --git a/apps/infoalloggi/src/components/acquisto_receipt.tsx b/apps/infoalloggi/src/components/acquisto_receipt.tsx deleted file mode 100644 index e7097cc..0000000 --- a/apps/infoalloggi/src/components/acquisto_receipt.tsx +++ /dev/null @@ -1,226 +0,0 @@ -"use client"; - -import { format } from "date-fns"; -import { it } from "date-fns/locale"; -import Image from "next/image"; -import toast from "react-hot-toast"; -import { Button } from "~/components/ui/button"; -import { Card } from "~/components/ui/card"; -import { PaymentMethodToString, type PaymentType } from "~/i18n/stripe"; -import { formatCurrency } from "~/lib/utils"; -import type { OrdiniOrdineId } from "~/schemas/public/Ordini"; -import type { Prezziario } from "~/schemas/public/Prezziario"; -import { api } from "~/utils/api"; - -export function ReceiptGenerator({ - data, - raw, -}: { - data: PurchaseData; - raw: boolean; -}) { - const { mutate: generatePdf } = - api.pagamenti.getRicevutaCortesiaPdf.useMutation({ - onMutate: () => { - toast.loading("Generazione PDF in corso...", { - id: "generate-pdf", - }); - }, - onSuccess: (res) => { - toast.success("PDF generato con successo!", { - id: "generate-pdf", - }); - - const blob = new Blob([res as unknown as ArrayBuffer], { - type: "application/pdf", - }); - const url = window.URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `ricevuta-cortesia-${data.clienteNome.replace(/\s+/g, "-")}-${format(data.date, "dd-MM-yyyy")}.pdf`; - document.body.appendChild(link); - link.click(); - link.remove(); - window.URL.revokeObjectURL(url); - }, - onError: (error) => { - toast.error(`Errore durante la generazione del PDF: ${error.message}`, { - id: "generate-pdf", - }); - }, - }); - if (raw) { - return ; - } - return ( -
-
-
-

Ricevuta di cortesia

- - -
-
- - - -
-
-
- ); -} - -export type PurchaseData = { - ordineId: OrdiniOrdineId; - date: Date; - - clienteNome: string; - codiceFiscale: string | null; - clienteIndirizzo: string; - paymentMethod: PaymentType; - packName: string; - sconto: number; - - amount_cent: number; - previewSaldo: Prezziario | null; -}; - -interface ReceiptProps { - data: PurchaseData; -} - -function Receipt({ data }: ReceiptProps) { - const companyName = "Arcenia S.r.l."; - - const companyAddress = - "Via Beata Giovanna 1\nBassano del Grappa (VI) 36061\nItalia"; - - const note = - "La presente non costituisce ricevuta o fattura fiscale ai sensi dell'Art.21, DPR 633/72.\nSeguirĂ  fattura elettronica direttamente al tuo indirizzo di posta."; - const footer = - "Arcenia Srl. | Tel. +39 0424529869 | Email: arca@infoalloggi.it"; - return ( -
-
-
-

{companyName}

-

{companyAddress}

-
- -
- {`${companyName} -
-
-
-

- Ricevuta di cortesia -

-
-
-

ID Ordine

-

{data.ordineId}

-
-
-

Data

-

- {format(data.date, "PPP", { - locale: it, - })} -

-

- {format(data.date, "p", { - locale: it, - })} -

-
-
-
-
-
-

Intestatario:

-

{data.clienteNome}

-

{data.codiceFiscale}

-

{data.clienteIndirizzo}

-
-
-
- - - - - - - - - - - - - - - - - {data.previewSaldo && ( - - - - - - - )} - -
OggettoSconto applicatoImporto
{data.packName} - {data.sconto > 0 && `${data.sconto}%`} - - {formatCurrency(data.amount_cent / 100)} -
- {data.previewSaldo.nome_it}{" "} - - pagamento differito, non è dovuto in questo momento - -   - {formatCurrency(data.previewSaldo.prezzo_cent / 100)} -
-
-
-
-
- Totale: - {formatCurrency(data.amount_cent / 100)} -
-
- di cui IVA (22%): - {formatCurrency((data.amount_cent / 100) * 0.22)} -
-
-
-
-
-
-

Metodo di pagamento

-

{PaymentMethodToString(data.paymentMethod)}

-
-
-

Status pagamento

-

Pagato

-
-
-
-
-

Note

-

{note}

-
- -
-

{footer}

-
-
- ); -} diff --git a/apps/infoalloggi/src/components/area-riservata/ordini.tsx b/apps/infoalloggi/src/components/area-riservata/ordini.tsx index bd40a01..85f10f0 100644 --- a/apps/infoalloggi/src/components/area-riservata/ordini.tsx +++ b/apps/infoalloggi/src/components/area-riservata/ordini.tsx @@ -78,7 +78,7 @@ const AdminOrdiniModal = ({

Id Ordine: {data.ordine_id}

-
+
{data.isActive && (
{ + toast.loading("Generazione PDF in corso...", { + id: "generate-pdf", + }); + }, + onSuccess: (data) => { + toast.success("PDF generato con successo!", { + id: "generate-pdf", + }); + if (!data) { + toast.error("Nessun dato ricevuto per la generazione del PDF.", { + id: "generate-pdf", + }); + return; + } + + const binaryString = atob(data.pdf); + const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0)); + const blob = new Blob([bytes], { + type: "application/pdf", + }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = data.title; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + }, + onError: (error) => { + toast.error(`Errore durante la generazione del PDF: ${error.message}`, { + id: "generate-pdf", + }); + }, + }); if (!data) return null; return ( @@ -191,14 +229,12 @@ export const UserOrdiniModal = ({

{data.isActive && (
- generatePdf({ ordineId: data.ordine_id })} + variant="outline" > - - + Ricevuta di cortesia +
)} diff --git a/apps/infoalloggi/src/components/pdf-viewer.tsx b/apps/infoalloggi/src/components/pdf-viewer.tsx index d07b329..1fa98fe 100644 --- a/apps/infoalloggi/src/components/pdf-viewer.tsx +++ b/apps/infoalloggi/src/components/pdf-viewer.tsx @@ -1,30 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import "react-pdf/dist/Page/AnnotationLayer.css"; -import "react-pdf/dist/Page/TextLayer.css"; - -import type { PDFDocumentProxy } from "pdfjs-dist"; +import { Document, Page, pdfjs } from "react-pdf"; import { LoadingPage } from "./loading"; -import "pdfjs-dist/build/pdf.worker.min.mjs"; -import dynamic from "next/dynamic"; - -const Document = dynamic( - () => import("react-pdf").then((mod) => ({ default: mod.Document })), - { ssr: false }, -); - -const Page = dynamic( - () => import("react-pdf").then((mod) => ({ default: mod.Page })), - { ssr: false }, -); - -//pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; - -// pdfjs.GlobalWorkerOptions.workerSrc = new URL( -// "pdfjs-dist/build/pdf.worker.min.mjs", -// import.meta.url, -// ).toString(); +pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; const options = { cMapUrl: "/cmaps/", @@ -37,7 +16,6 @@ export const PDFViewer = ({ url }: { url: string }) => { const containerRef = useRef(null); const [width, setWidth] = useState(0); const file = useMemo(() => ({ url }), [url]); - const onResize = useCallback((entries) => { const [entry] = entries; @@ -55,12 +33,6 @@ export const PDFViewer = ({ url }: { url: string }) => { } }, []); - function onDocumentLoadSuccess({ - numPages: nextNumPages, - }: PDFDocumentProxy): void { - setNumPages(nextNumPages); - } - return (
{ noData={ "Errore nel caricare il documento, riprova piĂą tardi o Scaricalo" } - onLoadSuccess={onDocumentLoadSuccess} + onLoadSuccess={({ numPages }) => setNumPages(numPages)} options={options} > {Array.from(new Array(numPages), (_el, index) => ( @@ -83,6 +55,8 @@ export const PDFViewer = ({ url }: { url: string }) => { index + 1 }`} pageNumber={index + 1} + renderAnnotationLayer={false} + renderTextLayer={false} width={width} /> ))} diff --git a/apps/infoalloggi/src/components/schedaAnnuncioStampabile.tsx b/apps/infoalloggi/src/components/schedaAnnuncioStampabile.tsx deleted file mode 100644 index 87417ab..0000000 --- a/apps/infoalloggi/src/components/schedaAnnuncioStampabile.tsx +++ /dev/null @@ -1,384 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import toast from "react-hot-toast"; -import z from "zod"; -import { Button } from "~/components/ui/button"; -import { Card } from "~/components/ui/card"; -import Input from "~/components/ui/input"; -import { filteredCaratteristiche } from "~/hooks/schedaAnnuncioUtils"; -import { it } from "~/i18n/it"; -import { handleConsegna } from "~/lib/annuncio_details"; -import { replaceWithBr } from "~/lib/newlineToBr"; -import { cn, formatCurrency } from "~/lib/utils"; -import type { Annunci } from "~/schemas/public/Annunci"; -import { api } from "~/utils/api"; -import { IconMatrix } from "./IconComponents"; -import { GoogleMapsIcon, LogoSvg, WhatsAppIcon } from "./svgs"; -import { Label } from "./ui/label"; -import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; - -export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) { - const [inputEmail, setInputEmail] = useState(""); - const [openPopover, setOpenPopover] = useState(false); - - const { mutate } = api.comunicazioni.sendSchedaAnnuncioEmail.useMutation({ - onMutate: () => { - toast.loading("Invio email in corso...", { - id: "send-scheda-annuncio-email", - }); - }, - onSuccess: () => { - toast.success("Email inviata con successo!", { - id: "send-scheda-annuncio-email", - }); - }, - onError: (error) => { - toast.error(`Errore durante l'invio dell'email: ${error.message}`, { - id: "send-scheda-annuncio-email", - }); - }, - }); - - const sendPdf = async () => { - if (!data || !inputEmail || !data.locatore || !data.numero) { - toast.error("Dati annuncio mancanti."); - return; - } - - mutate({ - id: data.id, - to: inputEmail, - codice: data.codice, - numero: data.numero, - nominativo: `${data.locatore}${data.tipo_locatore ? ` (${data.tipo_locatore})` : ""}`, - indirizzo: `${data.indirizzo}, ${data.civico} ${data.comune} ${data.cap} (${data.provincia})`, - }); - setInputEmail(""); - }; - - const { mutate: generatePdf } = api.annunci.getSchedaAnnuncio.useMutation({ - onMutate: () => { - toast.loading("Generazione PDF in corso...", { - id: "generate-pdf", - }); - }, - onSuccess: (res) => { - toast.success("PDF generato con successo!", { - id: "generate-pdf", - }); - - const blob = new Blob([res as unknown as ArrayBuffer], { - type: "application/pdf", - }); - const url = window.URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `scheda-annuncio-${data.codice}.pdf`; - document.body.appendChild(link); - link.click(); - link.remove(); - window.URL.revokeObjectURL(url); - }, - onError: (error) => { - toast.error(`Errore durante la generazione del PDF: ${error.message}`, { - id: "generate-pdf", - }); - }, - }); - - return ( -
-
-
-

Scheda Annuncio Stampabile

-
- - - - - - - - - setInputEmail(e.target.value)} - onKeyDown={(e) => { - if ( - e.key === "Enter" && - z.safeParse(z.email(), inputEmail).success - ) { - sendPdf(); - } - }} - type="email" - /> - - - -
-
- -
-
- ); -} - -export function SchedaAnnuncio({ - data, - raw, -}: { - data: Annunci; - raw?: boolean; -}) { - const companyAddress = - "Via Beata Giovanna, 1 a Bassano del Grappa (VI)\nTel. +39 0424529869\nEmail: arca@infoalloggi.it"; - - const footer = - "Arcenia Srl. | Tel. +39 0424529869 | Email: arca@infoalloggi.it"; - - const [navigationUrl, setNavigationUrl] = useState( - undefined, - ); - - const [printDate, setPrintDate] = useState(""); - const [disponibileDate, setDisponibileDate] = useState(""); - useEffect(() => { - setPrintDate(new Date().toLocaleDateString("it-IT")); - if (data.disponibile_da) { - setDisponibileDate(new Date(data.disponibile_da).toLocaleDateString()); - } else if (data.consegna) { - setDisponibileDate( - handleConsegna({ - aggiornamento: it.card.in_aggiornamento, - consegna: data.consegna, - consegna_da: it.card.consegna_da, - mesi: it.parametri.mesi, - subito: it.card.consegna_subito, - }), - ); - } else { - setDisponibileDate("Non specificata"); - } - if (!data) return; - if (typeof window === "undefined") return; - if ( - navigator.userAgent.toUpperCase().includes("MAC") || - navigator.userAgent.toUpperCase().includes("IPAD") || - navigator.userAgent.toUpperCase().includes("IPHONE") || - navigator.userAgent.toUpperCase().includes("IOS") || - navigator.userAgent.toUpperCase().includes("IPOD") - ) { - setNavigationUrl( - `maps://www.google.com/maps/dir/?api=1&travelmode=driving&layer=traffic&destination=${data.lat},${data.lon}`, - ); - } else { - setNavigationUrl( - `https://www.google.com/maps/dir/?api=1&travelmode=driving&layer=traffic&destination=${data.lat},${data.lon}`, - ); - } - }, [data]); - - return ( - -
-
-
- -
- -
-

Arcenia Srl

-

- {companyAddress} -

-
-
- {/* Property Header */} -
-

- {data.titolo_it || "Immobile in Affitto"} -

-

- Riferimento: {data.codice} -

-
-
-

- {" "} - Contatti - - (mandare un messaggio WhatsApp prima di chiamare) - -

-
-

- {data.locatore}{" "} - {data.tipo_locatore ? `(${data.tipo_locatore})` : ""} -

- {data.numero &&

Tel. {data.numero}

} -
-
- - {/* Property Details Grid */} -
- {/* Location Details */} -
-

Indirizzo

-
-

- {data.indirizzo}, {data.civico} {data.comune} {data.cap} ( - {data.provincia}) -

- {navigationUrl && ( - - Apri in Google Maps - - - )} -
-
- - {/* Property Characteristics */} - {/*
-

- 🏠 Caratteristiche -

-
- {data.mq && ( -

- mq: {data.mq} mq -

- )} - {data.numero_vani && ( -

- Locali:{" "} - {data.numero_vani} -

- )} - {data.numero_camere && ( -

- Camere:{" "} - {data.numero_camere} -

- )} - {data.numero_bagni && ( -

- Bagni:{" "} - {data.numero_bagni} -

- )} -
-
*/} -
- - {/* Pricing Information */} - -
-
- {data.tipo && ( -
-

Tipologia

-

Affitto {data.tipo}

-
- )} - {data.prezzo && ( -
-

Canone Mensile

-

- {formatCurrency(data.prezzo / 100)} -

-
- )} - {(data.disponibile_da || data.consegna) && ( -
-

Disponibile da

-

{disponibileDate}

-
- )} -
-
- - {/* Additional Features */} - -
-

- Dotazioni e Servizi -

-
- {data.caratteristiche && - filteredCaratteristiche(data.caratteristiche).map((item) => { - if (!item.value) return null; - return ( -
- - - - - {item.text}: {item.value} - -
- ); - })} -
-
- - {/* Description */} - {data.desc_it && ( - <> -
-

- Descrizione -

-
-

-
-
- - )} -
-

- Informazione strettamente riservata ed indirizzata all'Utente - Arca-Infoalloggi nel rispetto dell' Art. F. Precisazione dei Tuoi - Obblighi e responsabilitĂ  delle Condizioni Generali di Contratto - (CGC) accettate -

-
-
-

{footer}

-

Documento generato in data: {printDate}

-
-
-
- ); -} diff --git a/apps/infoalloggi/src/components/servizio/annuncio_card.tsx b/apps/infoalloggi/src/components/servizio/annuncio_card.tsx index 1ccfe5e..53a5ab1 100644 --- a/apps/infoalloggi/src/components/servizio/annuncio_card.tsx +++ b/apps/infoalloggi/src/components/servizio/annuncio_card.tsx @@ -68,7 +68,7 @@ export const AnnuncioCard = ({ className }: { className?: string }) => { ); }; -export const AnnuncioCardContent = ({ +const AnnuncioCardContent = ({ interactions, className, data, diff --git a/apps/infoalloggi/src/components/svgs.tsx b/apps/infoalloggi/src/components/svgs.tsx index 11bed15..095cf3c 100644 --- a/apps/infoalloggi/src/components/svgs.tsx +++ b/apps/infoalloggi/src/components/svgs.tsx @@ -696,7 +696,7 @@ export const WhatsAppIcon = (props: SVGProps) => ( /> ); - +/* export const GoogleMapsIcon = (props: SVGProps) => ( ) => ( /> ); - +*/ export const PasswordSVG = (props: SVGProps) => ( { { accessorKey: "ordini", cell: ({ row }) => { + const paidOrders = row.original.ordini_servizio.filter( + (o) => o.isActive, + ).length; + const unpaidOrders = row.original.ordini_servizio.length - paidOrders; return ( - +
+ + {unpaidOrders > 0 && ( + + {unpaidOrders} + + )} + {paidOrders > 0 && ( + + {paidOrders} + + )} +
); }, diff --git a/apps/infoalloggi/src/forms/FormRinnovo.tsx b/apps/infoalloggi/src/forms/FormRinnovo.tsx index a33fdab..3a411e8 100644 --- a/apps/infoalloggi/src/forms/FormRinnovo.tsx +++ b/apps/infoalloggi/src/forms/FormRinnovo.tsx @@ -31,7 +31,7 @@ const Schema = z.object({ gestionale_id: z.string().nullable(), }); -export type FormValues = z.infer; +type FormValues = z.infer; export const FormRinnovo = ({ initialData, diff --git a/apps/infoalloggi/src/hooks/schedaAnnuncioUtils.ts b/apps/infoalloggi/src/hooks/schedaAnnuncioUtils.ts index 43ad0bb..44d5894 100644 --- a/apps/infoalloggi/src/hooks/schedaAnnuncioUtils.ts +++ b/apps/infoalloggi/src/hooks/schedaAnnuncioUtils.ts @@ -3,9 +3,6 @@ import type { IconType } from "~/components/IconComponents"; import type { Caratteristiche } from "~/utils/kanel-types"; -export type CaratteristicheFiltered = ReturnType< - typeof filteredCaratteristiche ->; export const filteredCaratteristiche = (c: Caratteristiche) => { const filtered = [ { diff --git a/apps/infoalloggi/src/i18n/en.ts b/apps/infoalloggi/src/i18n/en.ts index 38e174b..d266924 100644 --- a/apps/infoalloggi/src/i18n/en.ts +++ b/apps/infoalloggi/src/i18n/en.ts @@ -663,6 +663,10 @@ export const en: LangDict = { { href: "/area-riservata/admin/incroci", title: "Incroci" }, { href: "/area-riservata/admin/annunci", title: "Ads" }, { href: "/area-riservata/admin/prezziario", title: "Pricing" }, + { + href: "/area-riservata/admin/stripe-reports", + title: "Account Statements", + }, ], title: "Management", }, diff --git a/apps/infoalloggi/src/i18n/it.ts b/apps/infoalloggi/src/i18n/it.ts index 754c661..dce424c 100644 --- a/apps/infoalloggi/src/i18n/it.ts +++ b/apps/infoalloggi/src/i18n/it.ts @@ -668,6 +668,10 @@ export const it: LangDict = { { href: "/area-riservata/admin/incroci", title: "Incroci" }, { href: "/area-riservata/admin/annunci", title: "Annunci" }, { href: "/area-riservata/admin/prezziario", title: "Prezziario" }, + { + href: "/area-riservata/admin/stripe-reports", + title: "Estratti conto", + }, ], title: "Gestione", }, diff --git a/apps/infoalloggi/src/pages/area-riservata/admin/stripe-reports.tsx b/apps/infoalloggi/src/pages/area-riservata/admin/stripe-reports.tsx new file mode 100644 index 0000000..739b638 --- /dev/null +++ b/apps/infoalloggi/src/pages/area-riservata/admin/stripe-reports.tsx @@ -0,0 +1,320 @@ +import { + add, + differenceInMonths, + endOfMonth, + format, + fromUnixTime, + getUnixTime, + isBefore, +} from "date-fns"; +import { it } from "date-fns/locale"; +import { RefreshCcw } from "lucide-react"; +import type { GetServerSideProps } from "next"; +import Head from "next/head"; +import toast from "react-hot-toast"; +import { LoadingPage } from "~/components/loading"; +import { Status500 } from "~/components/status-page"; +import { Button } from "~/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table"; +import type { NextPageWithLayout } from "~/pages/_app"; + +import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper"; +import { api } from "~/utils/api"; + +const reportTypeTitles: Record = { + "balance_change_from_activity.itemized": "Movimenti", + "payouts.itemized": "Pagamenti", + "balance.summary": "Riepilogo Saldo", +}; + +const reportStatusTitles: Record = { + succeeded: "Completato", + pending: "In lavorazione", + failed: "Fallito", +}; + +const START_DATE = new Date("2026-01-01"); + +/** + * Pagina di gestione reports stripe per admin: /area-riservata/admin/stripe-reports + */ +const StripeReports: NextPageWithLayout = () => { + const { data, isLoading, refetch } = + api.stripe_reports.listReports.useQuery(); + const utils = api.useUtils(); + const { mutate: setupEstrattoConto } = + api.stripe_reports.setupEstrattoConto.useMutation({ + onSuccess: async () => { + toast.success("Report richiesto con successo"); + await utils.stripe_reports.listReports.invalidate(); + }, + onError: (error) => { + toast.error(`Errore nella richiesta del report: ${error.message}`); + }, + }); + const { mutate: generateEstrattoConto } = + api.stripe_reports.generateEstrattoConto.useMutation({ + onMutate: () => { + toast.loading("Generazione estratto conto in corso...", { + id: "generate-pdf", + }); + }, + onSuccess: async (res) => { + if (!res.data) { + toast.error("PDF non disponibile al momento. Riprova piĂą tardi.", { + id: "generate-pdf", + }); + return; + } + toast.success("PDF generato con successo!", { + id: "generate-pdf", + }); + + const binaryString = atob(res.data.pdf); + const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0)); + const blob = new Blob([bytes], { + type: "application/pdf", + }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = res.data.title; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + toast.dismiss("generate-pdf"); + }, + onError: (error) => { + toast.error(`Errore nella generazione del report: ${error.message}`, { + id: "generate-pdf", + }); + }, + }); + const now = new Date(); + const monthsToShow = differenceInMonths(now, START_DATE) + 1; + + if (isLoading) return ; + if (!data) return ; + return ( + <> + + Stripe Reports + + +
+
+
+
+

+ Estratti Conto Stripe +

+ + +
+
+
+

Estratti Conto

+ + + + Intervallo + Stato + + + + {Array.from({ length: monthsToShow }, (_, i) => { + const m = add(START_DATE, { months: i }); + const em = endOfMonth(m); + const hasReports = data.filter((report) => { + if ( + !report.parameters?.interval_start || + !report.parameters?.interval_end + ) { + return false; + } + const um = getUnixTime(m); + const uem = getUnixTime(em); + // controlla se il report è allineato al mese in esame + return ( + um >= report.parameters.interval_start && + um <= report.parameters.interval_end && + uem >= report.parameters.interval_start && + uem <= report.parameters.interval_end + ); + }); + + const hasBudgetReport = hasReports + .sort((a, b) => b.created - a.created) + .filter((report) => + report.report_type.startsWith( + "balance_change_from_activity.itemized", + ), + ); + + const hasValidBudgetReport = hasBudgetReport.find( + (report) => + report.status === "succeeded" && + isBefore( + now, + fromUnixTime(report.result?.expires_at ?? 0), + ), + ); + const hasSummaryReport = hasReports + .sort((a, b) => b.created - a.created) + .filter((report) => + report.report_type.startsWith("balance.summary"), + ); + const hasValidSummaryReport = hasSummaryReport.find( + (report) => + report.status === "succeeded" && + isBefore( + now, + fromUnixTime(report.result?.expires_at ?? 0), + ), + ); + + const hasReport = + hasBudgetReport.length > 0 && hasSummaryReport.length > 0; + + const hasReportValid = + hasValidBudgetReport && hasValidSummaryReport; + + return ( + + + {format(m, "MMMM yyyy", { locale: it })} + + + {!hasReportValid ? ( + + ) : ( +
+ + +
+ )} +
+
+ ); + })} +
+
+
+
+
+

Report Generati da Stripe

+ + + + Creato il + Tipo + Intervallo + Stato + Scadenza + + + + {data.map((report) => { + const createdAt = fromUnixTime(report.created); + const expiresAt = report.result?.expires_at + ? fromUnixTime(report.result.expires_at) + : null; + const intervalStart = report.parameters.interval_start + ? fromUnixTime(report.parameters.interval_start) + : null; + const intervalEnd = report.parameters.interval_end + ? fromUnixTime(report.parameters.interval_end) + : null; + + const reportType = report.report_type.replace(/\.\d/gm, ""); + + return ( + + {format(createdAt, "dd/MM/yyyy")} + + {reportTypeTitles[reportType] || "Report Sconosciuto"} + + + {intervalStart && intervalEnd + ? `${format(intervalStart, "dd/MM/yyyy")} - ${format(intervalEnd, "dd/MM/yyyy")}` + : "N/A"} + + + {reportStatusTitles[report.status] || + "Stato Sconosciuto"} + + + {expiresAt ? format(expiresAt, "dd/MM/yyyy") : "N/A"} + + + ); + })} + +
+
+
+
+ + ); +}; + +export const getServerSideProps = (async (context) => { + const access_token = context.req.cookies.access_token; + + const helpers = await TrpcAuthedFetchingIstance({ access_token }); + if (helpers) { + await helpers.trpc.stripe_reports.listReports.prefetch(); + return { + props: { + trpcState: helpers.trpc.dehydrate(), + }, + }; + } + return { + props: {}, + }; +}) satisfies GetServerSideProps; +export default StripeReports; diff --git a/apps/infoalloggi/src/pages/area-riservata/scheda-annuncio-stampa/[id].tsx b/apps/infoalloggi/src/pages/area-riservata/scheda-annuncio-stampa/[id].tsx index bc61b97..ad7ae5f 100644 --- a/apps/infoalloggi/src/pages/area-riservata/scheda-annuncio-stampa/[id].tsx +++ b/apps/infoalloggi/src/pages/area-riservata/scheda-annuncio-stampa/[id].tsx @@ -1,48 +1,229 @@ +import { Dot } from "lucide-react"; import type { GetServerSideProps } from "next"; import Head from "next/head"; +import toast from "react-hot-toast"; import { AreaRiservataLayout } from "~/components/Layout"; -import { - SchedaAnnuncio, - SchedaAnnuncioStampabile, -} from "~/components/schedaAnnuncioStampabile"; -import { redirectTo500 } from "~/lib/utils"; +import { LoadingPage } from "~/components/loading"; +import { Status500 } from "~/components/status-page"; +import { LogoSvg, WhatsAppIcon } from "~/components/svgs"; +import { Button } from "~/components/ui/button"; +import { Card } from "~/components/ui/card"; +import { replaceWithBr } from "~/lib/newlineToBr"; +import { formatCurrency, redirectTo500 } from "~/lib/utils"; import type { NextPageWithLayout } from "~/pages/_app"; -import type { AnnunciWithMedia } from "~/server/controllers/annunci.controller"; +import type { AnnunciId } from "~/schemas/public/Annunci"; import { generateSSGHelper } from "~/server/utils/ssgHelper"; import { zAnnuncioId } from "~/server/utils/zod_types"; +import { api } from "~/utils/api"; type PageProps = { - raw: boolean; - data: StringifiedAnnunciWithMedia; + id: AnnunciId; }; /** * Pagina della scheda annuncio stampabile: /area-riservata/scheda-annuncio-stampa/[id]?raw=true */ const SchedaAnnuncioPage: NextPageWithLayout = ({ - raw, - data, + id, }: PageProps) => { - const annuncioData = StringifiedToAnnuncio(data); - if (raw) { - return ; + const { data, isLoading } = api.annunci.getSchedaAnnuncioData.useQuery({ + id, + }); + + const { mutate: generatePdf } = api.annunci.getSchedaAnnuncioPdf.useMutation({ + onMutate: () => { + toast.loading("Generazione PDF in corso...", { + id: "generate-pdf", + }); + }, + onSuccess: (data) => { + if (!data) { + toast.error("PDF non disponibile al momento. Riprova piĂą tardi.", { + id: "generate-pdf", + }); + return; + } + toast.success("PDF generato con successo!", { + id: "generate-pdf", + }); + + const binaryString = atob(data.pdf); + const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0)); + const blob = new Blob([bytes], { + type: "application/pdf", + }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = data.title; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + toast.dismiss("generate-pdf"); + }, + onError: (error) => { + toast.error(`Errore durante la generazione del PDF: ${error.message}`, { + id: "generate-pdf", + }); + }, + }); + + const companyAddress = + "Via Beata Giovanna, 1 a Bassano del Grappa (VI)\nTel. +39 0424529869\nEmail: arca@infoalloggi.it"; + + if (isLoading) { + return ; } + if (!data) { + return ; + } + return ( <> - Scheda Annuncio {annuncioData.codice} + Scheda Annuncio {data.codice} -
- +
+
+
+

Scheda Annuncio Stampabile

+
+ +
+
+ +
+
+
+ +
+ +
+

+ Arcenia Srl +

+

+ {companyAddress} +

+
+
+
+

+ {data.titolo_it || "Immobile in Affitto"} +

+

+ Riferimento: {data.codice} +

+
+
+

+ {" "} + Contatti + + (mandare un messaggio WhatsApp prima di chiamare) + +

+
+

{data.nominativo}

+ {data.numero && ( +

Tel. {data.numero}

+ )} +
+
+ +
+
+

+ Indirizzo +

+
+

{data.indirizzo}

+
+
+
+ +
+
+ {data.tipo && ( +
+

Tipologia

+

Affitto {data.tipo}

+
+ )} + {data.prezzo && ( +
+

Canone Mensile

+

+ {formatCurrency(data.prezzo / 100)} +

+
+ )} + +
+

Disponibile da

+

{data.disponibile_da}

+
+
+
+ +
+

+ Dotazioni e Servizi +

+
+ {data.caratteristiche.map((item) => { + if (!item[1]) return null; + return ( +
+ + {item[0]}: + {item[1]} +
+ ); + })} +
+
+ + {/* Description */} + {data.desc_it && ( + <> +
+

+ Descrizione +

+
+

+
+
+ + )} +
+

+ Informazione strettamente riservata ed indirizzata all'Utente + Arca-Infoalloggi nel rispetto dell' Art. F. Precisazione dei + Tuoi Obblighi e responsabilitĂ  delle Condizioni Generali di + Contratto (CGC) accettate +

+
+
+
+
); }; SchedaAnnuncioPage.getLayout = function getLayout(page) { - if (page.props.raw) { - return <>{page}; - } return {page}; }; @@ -50,7 +231,6 @@ export default SchedaAnnuncioPage; export const getServerSideProps = (async (context) => { const id = context.params?.id; - const raw = context.query?.raw; if (typeof id !== "string") { console.error("Error: id is not a string"); @@ -65,56 +245,14 @@ export const getServerSideProps = (async (context) => { const helper = generateSSGHelper(); - const data = await helper.annunci.getAnnuncioById_rawImgUrls.fetch({ + await helper.annunci.getSchedaAnnuncioData.prefetch({ id: parsed.data, }); - if (!data) { - return { - notFound: true, - }; - } return { props: { - raw: raw === "true", + id: parsed.data, trpcState: helper.dehydrate(), - data: AnnuncioToStringified(data), }, }; }) satisfies GetServerSideProps; - -type StringifiedAnnunciWithMedia = Omit< - AnnunciWithMedia, - "creato_il" | "disponibile_da" | "media_updated_at" | "modificato_il" -> & { - creato_il: string | undefined; - disponibile_da: string | undefined; - media_updated_at: string | undefined; - modificato_il: string | undefined; -}; - -function AnnuncioToStringified( - data: AnnunciWithMedia, -): StringifiedAnnunciWithMedia { - return { - ...data, - creato_il: data?.creato_il?.toISOString(), - disponibile_da: data?.disponibile_da?.toISOString(), - media_updated_at: data?.media_updated_at?.toISOString(), - modificato_il: data?.modificato_il?.toISOString(), - }; -} - -function StringifiedToAnnuncio( - data: StringifiedAnnunciWithMedia, -): AnnunciWithMedia { - return { - ...data, - creato_il: data?.creato_il ? new Date(data.creato_il) : null, - disponibile_da: data?.disponibile_da ? new Date(data.disponibile_da) : null, - media_updated_at: data?.media_updated_at - ? new Date(data.media_updated_at) - : null, - modificato_il: data?.modificato_il ? new Date(data.modificato_il) : null, - }; -} diff --git a/apps/infoalloggi/src/pages/servizio/pagamento-checkout/[ordineId].tsx b/apps/infoalloggi/src/pages/servizio/pagamento-checkout/[ordineId].tsx index 0a01a63..eb1566d 100644 --- a/apps/infoalloggi/src/pages/servizio/pagamento-checkout/[ordineId].tsx +++ b/apps/infoalloggi/src/pages/servizio/pagamento-checkout/[ordineId].tsx @@ -70,6 +70,7 @@ export const getServerSideProps = (async (context) => { const customer = await stripe.customers.create({ email: paymentData.email, name: paymentData.username, + description: paymentData.username, metadata: { userId: paymentData.userid, }, diff --git a/apps/infoalloggi/src/pages/servizio/ricevuta-cortesia/[ordineId].tsx b/apps/infoalloggi/src/pages/servizio/ricevuta-cortesia/[ordineId].tsx index 7584291..01e8cc1 100644 --- a/apps/infoalloggi/src/pages/servizio/ricevuta-cortesia/[ordineId].tsx +++ b/apps/infoalloggi/src/pages/servizio/ricevuta-cortesia/[ordineId].tsx @@ -1,8 +1,10 @@ import type { GetServerSideProps } from "next"; -import { ReceiptGenerator } from "~/components/acquisto_receipt"; +import dynamic from "next/dynamic"; +import { useEffect, useState } from "react"; import { AreaRiservataLayout } from "~/components/Layout"; import { LoadingPage } from "~/components/loading"; import { Status500 } from "~/components/status-page"; +import { Button } from "~/components/ui/button"; import { redirectTo500 } from "~/lib/utils"; import type { NextPageWithLayout } from "~/pages/_app"; import type { OrdiniOrdineId } from "~/schemas/public/Ordini"; @@ -10,8 +12,14 @@ import { generateSSGHelper } from "~/server/utils/ssgHelper"; import { zOrdineId } from "~/server/utils/zod_types"; import { api } from "~/utils/api"; +const PDFViewer = dynamic( + () => import("~/components/pdf-viewer").then((mod) => mod.PDFViewer), + { + ssr: false, + loading: () => , + }, +); type PagamentoRecapProps = { - raw: boolean; ordineId: OrdiniOrdineId; }; @@ -19,22 +27,89 @@ type PagamentoRecapProps = { * Pagina di ricevuta di cortesia dopo un acquisto: /servizio/ricevuta-cortesia/[ordineId]?raw=true|false */ const PaymentRecapPage: NextPageWithLayout = ({ - raw, ordineId, }: PagamentoRecapProps) => { - const { data, isLoading } = api.servizio.getOrdineRicevutaData.useQuery({ - ordineId, - }); + const { data, isLoading } = + api.pagamenti.getRicevutaCortesiaPdfQuery.useQuery({ + ordineId, + }); + const [pdfUrl, setPdfUrl] = useState(null); + const handleDownload = (pdfData: string, title: string) => { + const binaryString = atob(pdfData); + const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0)); + const blob = new Blob([bytes], { + type: "application/pdf", + }); + const url = window.URL.createObjectURL(blob); + + const link = document.createElement("a"); + link.href = url; + link.download = title; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + }; + + const handlePrint = (pdfUrl: string) => { + // 1. Create a hidden iframe + const iframe = document.createElement("iframe"); + iframe.style.display = "none"; + iframe.src = pdfUrl; + + // 2. Wait for the PDF to load inside the iframe + iframe.onload = () => { + iframe.contentWindow?.focus(); + iframe.contentWindow?.print(); + + // 3. Clean up (optional: wait a bit to ensure print dialog opened) + setTimeout(() => { + document.body.removeChild(iframe); + }, 1000); + }; + + document.body.appendChild(iframe); + }; + + useEffect(() => { + if (data) { + const binaryString = atob(data.pdf); + const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0)); + const blob = new Blob([bytes], { + type: "application/pdf", + }); + const url = window.URL.createObjectURL(blob); + setPdfUrl(url); + } + }, [data]); if (isLoading) { return ; } - if (!data) { + if (!data || !pdfUrl) { return ; } return ( -
- +
+
+
+

Ricevuta di cortesia

+
+ + +
+
+ + +
); }; @@ -50,7 +125,7 @@ export default PaymentRecapPage; export const getServerSideProps = (async (context) => { const id = context.params?.ordineId; - const raw = context.query?.raw; + if (typeof id !== "string") { console.error("Error: ordineId is not a string"); return redirectTo500; @@ -59,18 +134,17 @@ export const getServerSideProps = (async (context) => { const parsed = zOrdineId.safeParse(id); if (!parsed.success) { console.error("Error parsing ordineId", parsed.error); - return redirectTo500 + return redirectTo500; } const helper = generateSSGHelper(); if (helper) { - await helper.servizio.getOrdineRicevutaData.prefetch({ + await helper.pagamenti.getRicevutaCortesiaPdfQuery.prefetch({ ordineId: parsed.data, }); return { props: { - raw: raw === "true", ordineId: parsed.data, trpcState: helper.dehydrate(), }, diff --git a/apps/infoalloggi/src/server/FattureInCloud.ts b/apps/infoalloggi/src/server/FattureInCloud.ts index 1da8373..9c7fabb 100644 --- a/apps/infoalloggi/src/server/FattureInCloud.ts +++ b/apps/infoalloggi/src/server/FattureInCloud.ts @@ -1,7 +1,6 @@ import { ClientsApi, Configuration, - InfoApi, } from "@fattureincloud/fattureincloud-ts-sdk"; import { env } from "~/env"; @@ -12,7 +11,6 @@ const apiConfig = new Configuration({ export const companyId = Number.parseInt(env.FIC_COMPANY_ID); export const clientsApi = new ClientsApi(apiConfig); -export const infosApi = new InfoApi(apiConfig); /* const FIC_COUNTRIES = [ "Italia", @@ -266,4 +264,4 @@ const FIC_COUNTRIES = [ "Zambia", "Zimbabwe", "Irlanda del Nord", -];*/ \ No newline at end of file +];*/ diff --git a/apps/infoalloggi/src/server/api/root.ts b/apps/infoalloggi/src/server/api/root.ts index 1b32003..013c347 100644 --- a/apps/infoalloggi/src/server/api/root.ts +++ b/apps/infoalloggi/src/server/api/root.ts @@ -14,6 +14,7 @@ import { servizioRouter } from "~/server/api/routers/servizio"; import { statsRouter } from "~/server/api/routers/stats"; import { storageRouter } from "~/server/api/routers/storage"; import { stripeRouter } from "~/server/api/routers/stripe"; +import { stripeReportsRouter } from "~/server/api/routers/stripe_reports"; import { testRouter } from "~/server/api/routers/test"; import { usersRouter } from "~/server/api/routers/users"; import { createTRPCRouter } from "~/server/api/trpc"; @@ -46,6 +47,7 @@ export const appRouter = createTRPCRouter({ stats: statsRouter, storage: storageRouter, stripe: stripeRouter, + stripe_reports: stripeReportsRouter, test: testRouter, users: usersRouter, //sync: syncRouter, diff --git a/apps/infoalloggi/src/server/api/routers/annunci.ts b/apps/infoalloggi/src/server/api/routers/annunci.ts index 83eff1e..7b875ea 100644 --- a/apps/infoalloggi/src/server/api/routers/annunci.ts +++ b/apps/infoalloggi/src/server/api/routers/annunci.ts @@ -1,4 +1,3 @@ -import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; import type { AnnunciUpdate } from "~/schemas/public/Annunci"; import { @@ -15,6 +14,7 @@ import { getAnnunciListHandler, getAnnuncioData, getAnnunciRicerca, + getAnnunciSchedaData, getCodici_AnnunciHandler, getOptions_AnnunciHandler, getProprietarioDataHandler, @@ -23,12 +23,12 @@ import { type VideoToRemove, } from "~/server/controllers/annunci.controller"; import { db } from "~/server/db"; -import { NewMail } from "~/server/services/mailer"; import { - genPdfSchedaAnnuncio, invalidateCache, SCHEDA_ANNUNCIO_CACHE_PREFIX, -} from "~/server/services/puppeteer.service"; +} from "~/server/services/cache.service"; +import { NewMail } from "~/server/services/mailer"; +import { TypstGenerate } from "~/server/services/typst.service"; import { zAnnuncioId, zServizioId } from "~/server/utils/zod_types"; export const annunciRouter = createTRPCRouter({ @@ -83,6 +83,32 @@ export const annunciRouter = createTRPCRouter({ .query(async ({ input }) => { return await getAnnunciById_rawImgUrls({ ...input }); }), + getSchedaAnnuncioData: adminProcedure + .input( + z.object({ + id: zAnnuncioId, + }), + ) + .query(async ({ input }) => { + return await getAnnunciSchedaData(input.id); + }), + getSchedaAnnuncioPdf: adminProcedure + .input( + z.object({ + id: zAnnuncioId, + }), + ) + .mutation(async ({ input }) => { + const data = await getAnnunciSchedaData(input.id); + const pdfBuffer = await TypstGenerate({ + templateId: "annuncio.typ", + data, + }); + return { + pdf: pdfBuffer.toString("base64"), + title: `scheda-annuncio-${data.codice}.pdf`, + }; + }), getCodici: publicProcedure.query(async () => { return await getCodici_AnnunciHandler(); }), @@ -162,20 +188,4 @@ export const annunciRouter = createTRPCRouter({ ...input, }); }), - getSchedaAnnuncio: adminProcedure - .input( - z.object({ - id: zAnnuncioId, - }), - ) - .mutation(async ({ input }) => { - try { - return await genPdfSchedaAnnuncio(input.id); - } catch (e) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Errore nella generazione della scheda: ${(e as Error).message}`, - }); - } - }), }); diff --git a/apps/infoalloggi/src/server/api/routers/comunicazioni.ts b/apps/infoalloggi/src/server/api/routers/comunicazioni.ts index cc047eb..9f7ac7a 100644 --- a/apps/infoalloggi/src/server/api/routers/comunicazioni.ts +++ b/apps/infoalloggi/src/server/api/routers/comunicazioni.ts @@ -23,15 +23,10 @@ import { deleteEmail, getEmails, } from "~/server/services/email.service"; -import { type MailsTemplates, NewMail } from "~/server/services/mailer"; +import type { MailsTemplates } from "~/server/services/mail-templates"; +import { NewMail } from "~/server/services/mailer"; import { CONDIZIONI_DEFAULT } from "~/server/services/prezziario.service"; -import { genPdfSchedaAnnuncioBase64 } from "~/server/services/puppeteer.service"; -import { - zAnnuncioId, - zEventId, - zOrdineId, - zUserId, -} from "~/server/utils/zod_types"; +import { zEventId, zOrdineId, zUserId } from "~/server/utils/zod_types"; export const comunicazioniRouter = createTRPCRouter({ getEmails: protectedProcedure @@ -88,65 +83,6 @@ export const comunicazioniRouter = createTRPCRouter({ }); return { success: true }; }), - sendSchedaAnnuncioEmail: adminProcedure - .input( - z.object({ - id: zAnnuncioId, - codice: z.string(), - numero: z.string(), - nominativo: z.string(), - indirizzo: z.string(), - to: z.email(), - }), - ) - .mutation(async ({ input }) => { - try { - const scheda = await genPdfSchedaAnnuncioBase64(input.id); - - await NewMail({ - template: { - mailType: "emailSchedaContatto", - props: { - nominativo: input.nominativo, - telefono: input.numero, - indirizzo: input.indirizzo, - }, - }, - mail: { - subject: `Scheda Annuncio ${input.codice} - Infoalloggi.it`, - to: input.to, - attachments: [ - { - filename: `scheda annuncio ${input.codice}.pdf`, - content: scheda, - encoding: "base64", - contentType: "application/pdf", - }, - ], - }, - }); - - await NewMail({ - template: { - mailType: "generic", - props: { - title: `Ricevuta invio Scheda ${input.codice} a ${input.to}`, - testo: `La scheda dell'annuncio ${input.codice} è stata inviata a ${input.to} con successo.`, - }, - }, - mail: { - subject: `Ricevuta invio Scheda ${input.codice} a ${input.to}`, - to: "web@infoalloggi.it", - }, - }); - } catch (e) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Errore nell'invio dell'email: ${(e as Error).message}`, - cause: e instanceof Error ? e : undefined, - }); - } - }), sendUtenteInteressatoAdAnnuncioEmail: publicProcedure .input( diff --git a/apps/infoalloggi/src/server/api/routers/pagamenti.ts b/apps/infoalloggi/src/server/api/routers/pagamenti.ts index 022a90e..87d5d29 100644 --- a/apps/infoalloggi/src/server/api/routers/pagamenti.ts +++ b/apps/infoalloggi/src/server/api/routers/pagamenti.ts @@ -4,13 +4,14 @@ import OrderTypeEnum from "~/schemas/public/OrderTypeEnum"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { getPagamentoDataForCheckout, + getRicevutaData, type PreparedPaymentData, preparePayment, previewSaldo, } from "~/server/controllers/pagamenti.controller"; import { db } from "~/server/db"; -import { genPdfRicevutaCortesia } from "~/server/services/puppeteer.service"; +import { TypstGenerate } from "~/server/services/typst.service"; import { zOrdineId, zRinnovoId, zServizioId } from "~/server/utils/zod_types"; // Create a new ratelimiter, that allows 10 requests per 10 seconds /* @@ -113,7 +114,29 @@ export const pagamentiRouter = createTRPCRouter({ .input(z.object({ ordineId: zOrdineId })) .mutation(async ({ input }) => { try { - return await genPdfRicevutaCortesia(input.ordineId); + const ricData = await getRicevutaData(input.ordineId); + const pdf = await TypstGenerate({ + templateId: "receipt.typ", + data: ricData.data, + }); + return { title: ricData.title, pdf: pdf.toString("base64") }; + } catch (e) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Errore nella generazione della ricevuta di cortesia: ${(e as Error).message}`, + }); + } + }), + getRicevutaCortesiaPdfQuery: protectedProcedure + .input(z.object({ ordineId: zOrdineId })) + .query(async ({ input }) => { + try { + const ricData = await getRicevutaData(input.ordineId); + const pdf = await TypstGenerate({ + templateId: "receipt.typ", + data: ricData.data, + }); + return { title: ricData.title, pdf: pdf.toString("base64") }; } catch (e) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", diff --git a/apps/infoalloggi/src/server/api/routers/revalidation.ts b/apps/infoalloggi/src/server/api/routers/revalidation.ts index 27cb209..7b3537f 100644 --- a/apps/infoalloggi/src/server/api/routers/revalidation.ts +++ b/apps/infoalloggi/src/server/api/routers/revalidation.ts @@ -6,7 +6,7 @@ import { getCodici_AnnunciHandler } from "~/server/controllers/annunci.controlle import { getCacheStats, invalidateAllCaches, -} from "~/server/services/puppeteer.service"; +} from "~/server/services/cache.service"; import { revalidate, revalidateMultiple, diff --git a/apps/infoalloggi/src/server/api/routers/servizio.ts b/apps/infoalloggi/src/server/api/routers/servizio.ts index ff389eb..72e446a 100644 --- a/apps/infoalloggi/src/server/api/routers/servizio.ts +++ b/apps/infoalloggi/src/server/api/routers/servizio.ts @@ -12,7 +12,6 @@ import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica"; import { adminProcedure, createTRPCRouter, - overridableProcedure, protectedProcedure, } from "~/server/api/trpc"; import { getPacksPerServizio } from "~/server/controllers/pagamenti.controller"; @@ -25,7 +24,6 @@ import { getAnnunciAvailableToAdd, getCompatibileAnnunci, getDataPerAcquisto, - getOrdineRicevutaData, getRichieste, getServiziByUserId, getServizioDataById, @@ -166,15 +164,6 @@ export const servizioRouter = createTRPCRouter({ .query(async ({ input }) => { return await getOrdineById(input.ordineId); }), - getOrdineRicevutaData: overridableProcedure - .input( - z.object({ - ordineId: zOrdineId, - }), - ) - .query(async ({ input }) => { - return await getOrdineRicevutaData({ ordineId: input.ordineId }); - }), getOrdini: protectedProcedure .input( diff --git a/apps/infoalloggi/src/server/api/routers/stripe_reports.ts b/apps/infoalloggi/src/server/api/routers/stripe_reports.ts new file mode 100644 index 0000000..33441e6 --- /dev/null +++ b/apps/infoalloggi/src/server/api/routers/stripe_reports.ts @@ -0,0 +1,226 @@ +import { TRPCError } from "@trpc/server"; +import { endOfMonth, format, getUnixTime } from "date-fns"; +import { it } from "date-fns/locale"; +import Papa from "papaparse"; +import { z } from "zod/v4"; +import stripe from "~/lib/stripe"; +import { adminProcedure, createTRPCRouter } from "~/server/api/trpc"; +import { TypstGenerate } from "~/server/services/typst.service"; +export const stripeReportsRouter = createTRPCRouter({ + listReports: adminProcedure.query(async () => { + try { + const reports = await stripe.reporting.reportRuns.list({ limit: 20 }); + return reports.data; + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Error listing reports: ${(error as Error).message}`, + cause: error, + }); + } + }), + + setupEstrattoConto: adminProcedure + .input( + z.object({ + start: z.date(), + }), + ) + .mutation(async ({ input }) => { + const start = getUnixTime(input.start); + const end = getUnixTime(endOfMonth(input.start)); + + try { + await stripe.reporting.reportRuns.create({ + report_type: "balance_change_from_activity.itemized.1", + parameters: { + interval_start: start, + interval_end: end, + columns: [ + "balance_transaction_id", + "created_utc", + "currency", + "customer_email", + "customer_name", + "description", + "fee", + "gross", + "net", + "payment_method_type", + ], + }, + }); + await stripe.reporting.reportRuns.create({ + report_type: "balance.summary.1", + parameters: { + interval_start: start, + interval_end: end, + }, + }); + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Error generating estratto conto: ${(error as Error).message}`, + cause: error, + }); + } + }), + generateEstrattoConto: adminProcedure + .input( + z.object({ + movimentiUrl: z.string(), + saldoUrl: z.string(), + }), + ) + .mutation(async ({ input }) => { + try { + const movimentiCSV = await fetch(input.movimentiUrl, { + headers: { + Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`, + }, + }).then((res) => res.text()); + const saldoCSV = await fetch(input.saldoUrl, { + headers: { + Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`, + }, + }).then((res) => res.text()); + + const { data: movimentiData, errors: movimentiErrors } = Papa.parse( + movimentiCSV, + { + header: true, // Uses first row as keys + skipEmptyLines: true, + dynamicTyping: true, // Handles numbers/booleans + }, + ); + if (movimentiErrors.length > 0) { + return { + success: false, + message: movimentiErrors.map((e) => e.message).join(", "), + }; + } + + const { data: saldoData, errors: saldoErrors } = Papa.parse(saldoCSV, { + header: true, // Uses first row as keys + skipEmptyLines: true, + dynamicTyping: true, // Handles numbers/booleans + }); + if (saldoErrors.length > 0) { + return { + success: false, + message: saldoErrors.map((e) => e.message).join(", "), + }; + } + + const movimenti = z.array(movimentiSchema).safeParse(movimentiData); + const saldi = z.array(saldoSchema).safeParse(saldoData); + + if (!movimenti.success) { + console.error("Movimenti validation errors:", movimenti.error); + return { + success: false, + message: `Movimenti CSV validation error: ${movimenti.error.message}`, + }; + } + if (!saldi.success) { + console.error("Saldo validation errors:", saldi.error); + return { + success: false, + message: `Saldo CSV validation error: ${saldi.error.message}`, + }; + } + const startingBalance = saldi.data.find( + (s) => s.category === "starting_balance", + ); + const endingBalance = saldi.data.find( + (s) => s.category === "ending_balance", + ); + if (!startingBalance || !endingBalance) { + return { + success: false, + message: "Missing starting or ending balance in saldo report", + }; + } + + const data: EstrattoContoData = { + interval: `${format(new Date(startingBalance.description.replace(/[^\d-]/gm, "")), "dd/MM/yyyy", { locale: it }) || "N/A"} - ${format(new Date(endingBalance.description.replace(/[^\d-]/gm, "")), "dd/MM/yyyy", { locale: it }) || "N/A"}`, + emissione: format(new Date(), "dd/MM/yyyy", { locale: it }), + starting_balance: startingBalance?.net_amount * 100 || 0, + ending_balance: endingBalance?.net_amount * 100 || 0, + activity_gross: + (saldi.data.find((s) => s.category === "activity_gross") + ?.net_amount || 0) * 100, + activity_fee: + (saldi.data.find((s) => s.category === "activity_fee") + ?.net_amount || 0) * -100, + payouts: + saldi.data.find((s) => s.category === "payouts")?.net_amount || 0, + movimenti: movimenti.data.map((m) => ({ + date: format(new Date(m.created_utc), "dd/MM/yyyy", { + locale: it, + }), + description: m.description || "", + gross: m.gross * 100, + net: m.net * 100, + fees: m.fee * 100, + name: m.customer_name || "", + email: m.customer_email || "", + })), + }; + const pdf = await TypstGenerate({ templateId: "estratto.typ", data }); + + return { + success: true, + data: { + pdf: pdf.toString("base64"), + title: `Estratto Conto ${data.interval}.pdf`, + }, + }; + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Error generating estratto conto: ${(error as Error).message}`, + cause: error, + }); + } + }), +}); +//stripe reporting report_runs create --report-type "balance.summary.1" --parameters.interval-start 1772323200 --parameters.interval-end 1775001599 + +const movimentiSchema = z.object({ + balance_transaction_id: z.string(), + created_utc: z.string(), + currency: z.string(), + customer_email: z.email().nullable(), + customer_name: z.string().nullable(), + description: z.string().nullable(), + fee: z.number(), + gross: z.number(), + net: z.number(), + payment_method_type: z.string().nullable(), +}); +const saldoSchema = z.object({ + category: z.string(), + currency: z.string(), + description: z.string(), + net_amount: z.number(), +}); + +export type EstrattoContoData = { + interval: string; + emissione: string; + starting_balance: number; + ending_balance: number; + activity_gross: number; // Entrate lorde + activity_fee: number; // Commissioni + payouts: number; // Prelievi verso conto bancario + movimenti: { + date: string; + description: string; + gross: number; + net: number; + fees: number; + name: string; + email: string; + }[]; +}; diff --git a/apps/infoalloggi/src/server/api/routers/test.ts b/apps/infoalloggi/src/server/api/routers/test.ts index 5077ad6..c6f17fd 100644 --- a/apps/infoalloggi/src/server/api/routers/test.ts +++ b/apps/infoalloggi/src/server/api/routers/test.ts @@ -3,7 +3,6 @@ import { protectedApiProcedure, publicProcedure, } from "~/server/api/trpc"; -import { TypstRunner, TypstRunner2 } from "~/server/services/typst.service"; export const testRouter = createTRPCRouter({ testApi: protectedApiProcedure.query(async () => { @@ -15,9 +14,9 @@ export const testRouter = createTRPCRouter({ console.error("test console.error"); throw new Error("This is a test error"); }), - testTypst: publicProcedure.query(async () => { - return await TypstRunner2(); - }), + // testTypst: publicProcedure.query(async () => { + // return await genTesti(); + // }), }); /* PROTECTED API CALL EXAMPLE diff --git a/apps/infoalloggi/src/server/controllers/annunci.controller.ts b/apps/infoalloggi/src/server/controllers/annunci.controller.ts index fb36c4b..f75ffae 100644 --- a/apps/infoalloggi/src/server/controllers/annunci.controller.ts +++ b/apps/infoalloggi/src/server/controllers/annunci.controller.ts @@ -1,5 +1,6 @@ import { TRPCError } from "@trpc/server"; import { env } from "~/env"; +import { filteredCaratteristiche } from "~/hooks/schedaAnnuncioUtils"; import type { Result } from "~/lib/result"; import { getStorageUrl } from "~/lib/storage_utils"; import type { @@ -11,6 +12,7 @@ import type { ImagesRefs } from "~/schemas/public/ImagesRefs"; import type { ServizioServizioId } from "~/schemas/public/Servizio"; import type { VideosRefs } from "~/schemas/public/VideosRefs"; import { db } from "~/server/db"; +import type { AnnuncioTemplateData } from "~/server/services/typst.service"; import { withImages, withVideos } from "~/utils/kysely-helper"; import { revalidate } from "../utils/revalidationHelper"; @@ -555,3 +557,75 @@ export const removeAnnuncioMedia = async ({ }); } }; + +export const getAnnunciSchedaData = async ( + id: AnnunciId, +): Promise => { + try { + const data = await db + .selectFrom("annunci") + .select([ + "codice", + "locatore", + "numero", + "indirizzo", + "civico", + "comune", + "provincia", + "cap", + "lat", + "lon", + "tipo", + "prezzo", + "titolo_it", + "desc_it", + "disponibile_da", + "tipo_locatore", + "caratteristiche", + ]) + .where("id", "=", id) + .executeTakeFirstOrThrow( + () => new Error(`Annuncio non trovato - id: ${id}`), + ); + + if ( + !data.locatore || + !data.numero || + !data.indirizzo || + !data.lat || + !data.lon || + !data.tipo || + !data.prezzo || + !data.titolo_it || + !data.desc_it || + !data.disponibile_da + ) { + throw new Error( + `Dati incompleti per generazione scheda annuncio - id: ${id}`, + ); + } + return { + codice: data.codice, + numero: data.numero, + nominativo: `${data.locatore}${data.tipo_locatore ? ` (${data.tipo_locatore})` : ""}`, + indirizzo: `${data.indirizzo}, ${data.civico} ${data.comune} ${data.cap} (${data.provincia})`, + lat: data.lat, + lon: data.lon, + tipo: data.tipo, + prezzo: data.prezzo, + titolo_it: data.titolo_it, + desc_it: data.desc_it, + disponibile_da: data.disponibile_da.toLocaleDateString("it-IT"), + caratteristiche: data.caratteristiche + ? filteredCaratteristiche(data.caratteristiche) + .filter((c) => c.value !== null) + .map((c) => [c.text, c.value || ""]) + : [], + }; + } catch (e) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Errore recupero dati scheda annuncio: ${(e as Error).message}`, + }); + } +}; diff --git a/apps/infoalloggi/src/server/controllers/event_queue.controller.ts b/apps/infoalloggi/src/server/controllers/event_queue.controller.ts index 3d2024c..5645aac 100644 --- a/apps/infoalloggi/src/server/controllers/event_queue.controller.ts +++ b/apps/infoalloggi/src/server/controllers/event_queue.controller.ts @@ -12,6 +12,8 @@ import type { import type { OrdiniOrdineId } from "~/schemas/public/Ordini"; import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe"; import type { UsersId } from "~/schemas/public/Users"; +import { getAnnunciSchedaData } from "~/server/controllers/annunci.controller"; +import { getRicevutaData } from "~/server/controllers/pagamenti.controller"; import { type MinimalSMSData, SendMessage, @@ -19,10 +21,14 @@ import { import { db } from "~/server/db"; import { NewMail, type NewMailProps } from "~/server/services/mailer"; import { - genPdfCondizioniBase64, - genPdfRicevutaCortesiaBase64, - genPdfSchedaAnnuncioBase64, -} from "../services/puppeteer.service"; + convertHtmlToTypst, + TypstGenerate, +} from "~/server/services/typst.service"; +import { getKeydbClient } from "~/utils/keydb"; +import { + CONDIZIONI_CACHE_PREFIX, + SCHEDA_ANNUNCIO_CACHE_PREFIX, +} from "../services/cache.service"; type GeneratedAttachmentTypes = | { type: "scheda_annuncio"; annuncioId: AnnunciId } @@ -41,12 +47,68 @@ export type Email_EventData = { const generatePdfContent = async (params: GeneratedAttachmentTypes) => { switch (params.type) { - case "ricevuta_cortesia": - return await genPdfRicevutaCortesiaBase64(params.ordineId); - case "condizioni": - return await genPdfCondizioniBase64(params.stringId); - case "scheda_annuncio": - return await genPdfSchedaAnnuncioBase64(params.annuncioId); + case "ricevuta_cortesia": { + const ricData = await getRicevutaData(params.ordineId); + const pdf = await TypstGenerate({ + templateId: "receipt.typ", + data: ricData.data, + }); + return pdf.toString("base64"); + } + + case "condizioni": { + const keybd = getKeydbClient(); + const cacheKey = `${CONDIZIONI_CACHE_PREFIX}${params.stringId}`; + if (keybd) { + const cached = await keybd.get(cacheKey); + if (cached) { + return cached; + } + } + const data = await db + .selectFrom("testi_e_stringhe") + .selectAll() + .where("stinga_id", "=", params.stringId) + .executeTakeFirst(); + if (!data || !data.stringa_value) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Stringa non trovata", + }); + } + + const stringa = convertHtmlToTypst(data.stringa_value); + const pdf = await TypstGenerate({ + templateId: "testi.typ", + data: { stringa }, + }); + const pdfBase64 = pdf.toString("base64"); + if (keybd) { + await keybd.set(cacheKey, pdfBase64, "EX", 3600 * 24 * 5); + } + + return pdfBase64; + } + case "scheda_annuncio": { + const keybd = getKeydbClient(); + const cacheKey = `${SCHEDA_ANNUNCIO_CACHE_PREFIX}${params.annuncioId}`; + if (keybd) { + const cached = await keybd.get(cacheKey); + if (cached) { + return cached; + } + } + const data = await getAnnunciSchedaData(params.annuncioId); + const pdf = await TypstGenerate({ + templateId: "annuncio.typ", + data, + }); + const pdfBase64 = pdf.toString("base64"); + if (keybd) { + await keybd.set(cacheKey, pdfBase64, "EX", 3600 * 5); + } + return pdfBase64; + } default: throw new Error(`Unknown PDF type: `); } diff --git a/apps/infoalloggi/src/server/controllers/fic.controller.ts b/apps/infoalloggi/src/server/controllers/fic.controller.ts index ed73b6a..bde2eb6 100644 --- a/apps/infoalloggi/src/server/controllers/fic.controller.ts +++ b/apps/infoalloggi/src/server/controllers/fic.controller.ts @@ -44,11 +44,6 @@ export const getClientFromCF = async (cf: string) => { return clients.data; }; -export const addClient = async (client: Client) => { - const newClient = await clientsApi.createClient(companyId, { data: client }); - return newClient.data; -}; - export const exportUserToFIC = async (userId: UsersId) => { try { const user = await db diff --git a/apps/infoalloggi/src/server/controllers/pagamenti.controller.ts b/apps/infoalloggi/src/server/controllers/pagamenti.controller.ts index ecda41d..2806019 100644 --- a/apps/infoalloggi/src/server/controllers/pagamenti.controller.ts +++ b/apps/infoalloggi/src/server/controllers/pagamenti.controller.ts @@ -1,5 +1,7 @@ import { TRPCError } from "@trpc/server"; -import { add, intervalToDuration } from "date-fns"; +import { add, format, intervalToDuration } from "date-fns"; +import { PaymentMethodToString, type PaymentType } from "~/i18n/stripe"; +import { parseDBComune, parseDBNazione } from "~/lib/catasto"; import OrderTypeEnum from "~/schemas/public/OrderTypeEnum"; import type { OrdiniOrdineId } from "~/schemas/public/Ordini"; import type { @@ -9,6 +11,8 @@ import type { import type { RinnoviId } from "~/schemas/public/Rinnovi"; import type { ServizioServizioId } from "~/schemas/public/Servizio"; import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum"; +import { getComuni, getNazioni } from "~/server/controllers/catasto.controller"; +import type { RicevutaProps } from "~/server/services/typst.service"; import { db } from "../db"; import { createOrdine } from "../services/ordini.service"; import { @@ -615,3 +619,124 @@ export const previewSaldo = async ( return saldo; }; + +export const getRicevutaData = async ( + ordineId: OrdiniOrdineId, +): Promise<{ title: string; data: RicevutaProps }> => { + try { + const ordine = await db + .selectFrom("ordini") + .select([ + "ordini.ordine_id", + "ordini.userid", + "ordini.amount_cent", + "ordini.sconto", + "ordini.created_at", + "ordini.paymentmethod", + "ordini.type", + "ordini.servizio_id", + ]) + .innerJoin("prezziario", "prezziario.idprezziario", "ordini.packid") + .select("prezziario.nome_it") + .where("ordini.ordine_id", "=", ordineId) + .executeTakeFirstOrThrow( + () => new Error(`Ordine not found - ordineId: ${ordineId}`), + ); + + const anagrafica = await db + .selectFrom("users") + .select("users.username") + .innerJoin("users_anagrafica", "users_anagrafica.userid", "users.id") + .select([ + "users_anagrafica.via_residenza", + "users_anagrafica.civico_residenza", + "users_anagrafica.comune_residenza", + "users_anagrafica.provincia_residenza", + "users_anagrafica.cap_residenza", + "users_anagrafica.nazione_residenza", + "users_anagrafica.codice_fiscale", + ]) + .where("users.id", "=", ordine.userid) + .executeTakeFirstOrThrow( + () => new Error(`Anagrafica not found - userid: ${ordine.userid}`), + ); + const comuni = await getComuni(); + const nazioni = await getNazioni(); + + const parsedComune = parseDBComune(anagrafica.comune_residenza, comuni); + const parsedNazione = parseDBNazione(anagrafica.nazione_residenza, nazioni); + + let previewSaldo: Prezziario | null = null; + + if (ordine.type === OrderTypeEnum.Acconto && ordine.servizio_id) { + const servizio = await getServizioById(ordine.servizio_id); + if (servizio) { + const saldo = await SaldoSolver({ + tipologia: servizio.tipologia, + permanenza: servizio.permanenza, + budget: servizio.budget, + }); + if (saldo.success) { + previewSaldo = saldo.pack; + } else { + console.error( + `Errore nel calcolo del saldo per il servizio ${ordine.servizio_id}: ${saldo.message}, procedura getRicevutaData`, + ); + } + } else { + console.error( + `Servizio non trovato per ordine ${ordineId} con servizio_id ${ordine.servizio_id}, procedura getRicevutaData`, + ); + } + } + if (!anagrafica.codice_fiscale) { + throw new Error(`Codice fiscale mancante per utente ${ordine.userid}`); + } + + console.log(previewSaldo); + + const data = { + ordineId: ordine.ordine_id, + datetime: format(ordine.created_at, "HH:mm"), + date: format(ordine.created_at, "dd/MM/yyyy"), + nominativo: anagrafica.username, + codicefiscale: anagrafica.codice_fiscale, + indirizzo: `${anagrafica.via_residenza} ${anagrafica.civico_residenza}\n${parsedComune?.nome} (${anagrafica.provincia_residenza}) ${anagrafica.cap_residenza}`, + nazione: parsedNazione?.nome || "N/A", + method: PaymentMethodToString( + (ordine.paymentmethod || "manual") as PaymentType, + ), + items: [ + { + title: ordine.nome_it, + amount: ordine.amount_cent, + discount: ordine.sconto, + strike: false, + desc: null, + }, + ...(previewSaldo + ? [ + { + title: previewSaldo.nome_it, + amount: previewSaldo.prezzo_cent, + discount: null, + strike: true, + desc: "Pagamento differito, non è dovuto in questo momento", + }, + ] + : []), + ], + total: ordine.amount_cent - ordine.sconto, + vat: (ordine.amount_cent - ordine.sconto) * 0.22, + }; + return { + title: `ricevuta-cortesia-${anagrafica.username.replace(/\s+/g, "-")}-${format(ordine.created_at, "dd-MM-yyyy")}.pdf`, + data, + }; + } catch (e) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Error fetching ordine ricevuta data: ${(e as Error).message}`, + }); + } +}; diff --git a/apps/infoalloggi/src/server/controllers/servizio.controller.ts b/apps/infoalloggi/src/server/controllers/servizio.controller.ts index 2e39a20..c28844a 100644 --- a/apps/infoalloggi/src/server/controllers/servizio.controller.ts +++ b/apps/infoalloggi/src/server/controllers/servizio.controller.ts @@ -2,10 +2,7 @@ import { TRPCError } from "@trpc/server"; import { add, isBefore } from "date-fns"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres"; import { z } from "zod/v4"; -import type { PurchaseData } from "~/components/acquisto_receipt"; import { env } from "~/env"; -import type { PaymentType } from "~/i18n/stripe"; -import { parseDBComune, parseDBNazione } from "~/lib/catasto"; import { withParsedDates } from "~/lib/dateParserJsonKysely"; import type { Annunci, AnnunciId } from "~/schemas/public/Annunci"; import OrderTypeEnum from "~/schemas/public/OrderTypeEnum"; @@ -42,10 +39,8 @@ import { } from "~/utils/kysely-helper"; import { GetUserInterests } from "../services/interests.service"; import { createOrdine } from "../services/ordini.service"; -import { genPdfCondizioniBase64 } from "../services/puppeteer.service"; import { getServizioById } from "../services/servizio.service"; import type { AnnuncioRicerca } from "./annunci.controller"; -import { getComuni, getNazioni } from "./catasto.controller"; import { SaldoSolver } from "./pagamenti.controller"; export const getServiziByUserId = async (userId: UsersId) => { @@ -243,30 +238,38 @@ export const attivaServizio_SaltaPagamentoAcconto = async ( ); if (user.comms_enabled) { - await NewMail({ - template: { - mailType: "servizioAttivato", - }, - mail: { - subject: "Pagamento Confermato - Acconto Infoalloggi.it", - to: ordine.email, - attachments: ordine.testo_condizioni - ? [ - { - filename: `condizioni_contrattuali_${ordine.created_at.toISOString()}.pdf`, + await addEventToQueue({ + data: { + tipo: "email", + data: { + template: { + mailType: "servizioAttivato", + }, + mail: { + subject: "Pagamento Confermato - Acconto Infoalloggi.it", + to: ordine.email, + attachments: ordine.testo_condizioni + ? [ + { + filename: `condizioni_contrattuali_${ordine.created_at.toISOString()}.pdf`, + generate: { + type: "condizioni", + stringId: + ordine.testo_condizioni as TestiEStringheStingaId, + }, - content: await genPdfCondizioniBase64( - ordine.testo_condizioni as TestiEStringheStingaId, - ), - - encoding: "base64", - contentType: "application/pdf", - }, - ] - : [], + encoding: "base64", + contentType: "application/pdf", + }, + ] + : [], + }, + userId: ordine.userId, + }, }, - userId: ordine.userId, + lock_expires: add(new Date(), { seconds: 30 }), }); + await addEventToQueue({ data: { tipo: "email", @@ -1398,101 +1401,6 @@ export const getCompatibileAnnunci = async ({ } }; -export const getOrdineRicevutaData = async ({ - ordineId, -}: { - ordineId: OrdiniOrdineId; -}): Promise => { - try { - const data = await db - .selectFrom("ordini") - .select([ - "ordini.ordine_id", - "ordini.userid", - "ordini.amount_cent", - "ordini.sconto", - "ordini.created_at", - "ordini.paymentmethod", - "ordini.type", - "ordini.servizio_id", - ]) - .innerJoin("prezziario", "prezziario.idprezziario", "ordini.packid") - .select("prezziario.nome_it") - .where("ordini.ordine_id", "=", ordineId) - //.where("ordini.paymentstatus", "=", PaymentStatusEnum.success) - - .executeTakeFirstOrThrow( - () => new Error(`Ordine not found - ordineId: ${ordineId}`), - ); - - const anagrafica = await db - .selectFrom("users") - .select("users.username") - .innerJoin("users_anagrafica", "users_anagrafica.userid", "users.id") - .select([ - "users_anagrafica.via_residenza", - "users_anagrafica.civico_residenza", - "users_anagrafica.comune_residenza", - "users_anagrafica.provincia_residenza", - "users_anagrafica.cap_residenza", - "users_anagrafica.nazione_residenza", - "users_anagrafica.codice_fiscale", - ]) - .where("users.id", "=", data.userid) - .executeTakeFirstOrThrow( - () => new Error(`Anagrafica not found - userid: ${data.userid}`), - ); - const comuni = await getComuni(); - const nazioni = await getNazioni(); - - const parsedComune = parseDBComune(anagrafica.comune_residenza, comuni); - const parsedNazione = parseDBNazione(anagrafica.nazione_residenza, nazioni); - - let previewSaldo: Prezziario | null = null; - - if (data.type === OrderTypeEnum.Acconto && data.servizio_id) { - const servizio = await getServizioById(data.servizio_id); - if (servizio) { - const saldo = await SaldoSolver({ - tipologia: servizio.tipologia, - permanenza: servizio.permanenza, - budget: servizio.budget, - }); - if (saldo.success) { - previewSaldo = saldo.pack; - } else { - console.error( - `Errore nel calcolo del saldo per il servizio ${data.servizio_id}: ${saldo.message}, procedura getOrdineRicevutaData`, - ); - } - } else { - console.error( - `Servizio non trovato per ordine ${ordineId} con servizio_id ${data.servizio_id}, procedura getOrdineRicevutaData`, - ); - } - } - - return { - clienteIndirizzo: `${anagrafica.via_residenza} ${anagrafica.civico_residenza}\n${parsedComune?.nome} (${anagrafica.provincia_residenza}) ${anagrafica.cap_residenza}\n${parsedNazione?.nome}`, - clienteNome: anagrafica.username, - codiceFiscale: anagrafica.codice_fiscale, - date: data.created_at, - - packName: data.nome_it, - sconto: data.sconto, - amount_cent: data.amount_cent, - - ordineId: data.ordine_id, - paymentMethod: (data.paymentmethod || "manual") as PaymentType, - previewSaldo, - }; - } catch (e) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Error fetching ordine ricevuta data: ${(e as Error).message}`, - }); - } -}; export type RichiesteData = Servizio & Pick & { annunci_servizio: ServizioAnnuncioData[]; diff --git a/apps/infoalloggi/src/server/services/cache.service.ts b/apps/infoalloggi/src/server/services/cache.service.ts new file mode 100644 index 0000000..8a95328 --- /dev/null +++ b/apps/infoalloggi/src/server/services/cache.service.ts @@ -0,0 +1,64 @@ +import type Redis from "ioredis"; +import { getKeydbClient } from "~/utils/keydb"; + +export const SCHEDA_ANNUNCIO_CACHE_PREFIX = "scheda-annuncio-"; +export const CONDIZIONI_CACHE_PREFIX = "condizioni-"; +export const invalidateCache = async (cacheKey: string) => { + const keybd = getKeydbClient(); + if (keybd) { + await keybd.del(cacheKey); + } +}; + +async function deleteByPrefix(redis: Redis, prefix: string) { + // Create a stream to find keys matching the pattern + const stream = redis.scanStream({ + match: `${prefix}*`, + count: 100, // Process 100 keys per iteration + }); + + stream.on("data", async (keys: string[]) => { + if (keys.length > 0) { + // Create a pipeline to delete keys in a single network round-trip + const pipeline = redis.pipeline(); + + // Use UNLINK instead of DEL for better performance on large keys + keys.forEach((key) => pipeline.unlink(key)); + + await pipeline.exec(); + console.log(`Deleted ${keys.length} keys`); + } + }); + + stream.on("error", (err) => { + console.error( + `Error scanning/unlinking keys with prefix ${prefix}: ${err.message}`, + ); + }); +} + +export const invalidateAllCaches = async () => { + const keybd = getKeydbClient(); + if (keybd) { + await deleteByPrefix(keybd, SCHEDA_ANNUNCIO_CACHE_PREFIX); + await deleteByPrefix(keybd, CONDIZIONI_CACHE_PREFIX); + } +}; + +export const getCacheStats = async () => { + const keybd = getKeydbClient(); + if (keybd) { + const schedaKeys = await keybd.keys(`${SCHEDA_ANNUNCIO_CACHE_PREFIX}*`); + const condizioniKeys = await keybd.keys(`${CONDIZIONI_CACHE_PREFIX}*`); + return { + success: true, + schedaCount: schedaKeys.length, + condizioniCount: condizioniKeys.length, + }; + } + return { + success: false, + schedaCount: 0, + condizioniCount: 0, + }; +}; diff --git a/apps/infoalloggi/src/server/services/email.service.ts b/apps/infoalloggi/src/server/services/email.service.ts index be39269..5e302f3 100644 --- a/apps/infoalloggi/src/server/services/email.service.ts +++ b/apps/infoalloggi/src/server/services/email.service.ts @@ -2,7 +2,8 @@ import { TRPCError } from "@trpc/server"; import type { EmailsIdEmail } from "~/schemas/public/Emails"; import type { UsersId } from "~/schemas/public/Users"; import { db } from "~/server/db"; -import { genMail, type MailsTemplates } from "~/server/services/mailer"; +import type { MailsTemplates } from "~/server/services/mail-templates"; +import { genMail } from "~/server/services/mailer"; export const storeEmail = async ({ userId, diff --git a/apps/infoalloggi/src/server/services/mail-templates.ts b/apps/infoalloggi/src/server/services/mail-templates.ts index ebe5de2..83cc1bc 100644 --- a/apps/infoalloggi/src/server/services/mail-templates.ts +++ b/apps/infoalloggi/src/server/services/mail-templates.ts @@ -1,6 +1,4 @@ -import { - AvvisoInterruzioneMailTag, -} from "emails/avviso-interruzione"; +import { AvvisoInterruzioneMailTag } from "emails/avviso-interruzione"; import { ContattoAdminEmailPropsSchema, ContattoAdminMailTag, @@ -13,36 +11,23 @@ import { ContattoInteressatoMailTag, ContattoInteressatoPropsSchema, } from "emails/email-interessato"; -import { - GenericMailTag, - GenericPropsSchema, -} from "emails/gereric-email"; +import { GenericMailTag, GenericPropsSchema } from "emails/gereric-email"; import { InterruzioneMailTag } from "emails/interruzione"; import { InvitoMailTag, InvitoPropsSchema } from "emails/invito"; -import { - PagamentoConfermaMailTag, -} from "emails/pagamento-conferma"; -import { - PagamentoErroreMailTag, -} from "emails/pagamento-errore"; -import { - PagamentoRicezioneMailTag, -} from "emails/pagamento-ricezione"; +import { PagamentoConfermaMailTag } from "emails/pagamento-conferma"; +import { PagamentoErroreMailTag } from "emails/pagamento-errore"; +import { PagamentoRicezioneMailTag } from "emails/pagamento-ricezione"; import { PwResetLinkMailTag, PwResetLinkPropsSchema, } from "emails/pw-reset-link"; import { RecessoMailTag } from "emails/recesso"; -import { - RegistrazioneAvvenutaMailTag, -} from "emails/registrazione-avvenuta"; +import { RegistrazioneAvvenutaMailTag } from "emails/registrazione-avvenuta"; import { SchedaContattoMailTag, SchedaContattoPropsSchema, } from "emails/scheda-annuncio"; -import { - ServizioAttivatoMailTag, -} from "emails/servizio-attivato"; +import { ServizioAttivatoMailTag } from "emails/servizio-attivato"; import { VerificaEmailMailTag, VerificaEmailPropsSchema, @@ -93,9 +78,9 @@ export const TemplateKeys = Object.keys(Templates) as Array< keyof typeof Templates >; -export const TemplateSchemas = Object.entries(Templates) - .map(([_, value]) => value.props) - .filter((schema) => schema !== null); +// const TemplateSchemas = Object.entries(Templates) +// .map(([_, value]) => value.props) +// .filter((schema) => schema !== null); export type TemplateType = keyof typeof Templates; diff --git a/apps/infoalloggi/src/server/services/mailer.ts b/apps/infoalloggi/src/server/services/mailer.ts index 0e9080f..eb1b8b2 100644 --- a/apps/infoalloggi/src/server/services/mailer.ts +++ b/apps/infoalloggi/src/server/services/mailer.ts @@ -26,14 +26,6 @@ import { GetFlagValueHandler } from "~/server/controllers/flags.controller"; import { db } from "~/server/db"; import { storeEmail } from "~/server/services/email.service"; -export { - type MailsTemplates, - TemplateKeys, - TemplateSchemas, - Templates, - type TemplateType, -} from "~/server/services/mail-templates"; - import { type MailsTemplates, Templates, diff --git a/apps/infoalloggi/src/server/services/puppeteer.service.ts b/apps/infoalloggi/src/server/services/puppeteer.service.ts deleted file mode 100644 index 6667dc3..0000000 --- a/apps/infoalloggi/src/server/services/puppeteer.service.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { TRPCError } from "@trpc/server"; -import type Redis from "ioredis"; -import puppeteer, { type PDFOptions } from "puppeteer-core"; -import { env } from "~/env"; -import type { AnnunciId } from "~/schemas/public/Annunci"; -import type { OrdiniOrdineId } from "~/schemas/public/Ordini"; -import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe"; -import { getKeydbClient } from "~/utils/keydb"; -import { TOKEN_CONFIG } from "../auth/configs"; - -export const SCHEDA_ANNUNCIO_CACHE_PREFIX = "scheda-annuncio-"; -const CONDIZIONI_CACHE_PREFIX = "condizioni-"; -export const invalidateCache = async (cacheKey: string) => { - const keybd = getKeydbClient(); - if (keybd) { - await keybd.del(cacheKey); - } -}; - -async function deleteByPrefix(redis: Redis, prefix: string) { - // Create a stream to find keys matching the pattern - const stream = redis.scanStream({ - match: `${prefix}*`, - count: 100, // Process 100 keys per iteration - }); - - stream.on("data", async (keys: string[]) => { - if (keys.length > 0) { - // Create a pipeline to delete keys in a single network round-trip - const pipeline = redis.pipeline(); - - // Use UNLINK instead of DEL for better performance on large keys - keys.forEach((key) => pipeline.unlink(key)); - - await pipeline.exec(); - console.log(`Deleted ${keys.length} keys`); - } - }); - - stream.on("error", (err) => { - console.error( - `Error scanning/unlinking keys with prefix ${prefix}: ${err.message}`, - ); - }); -} - -export const invalidateAllCaches = async () => { - const keybd = getKeydbClient(); - if (keybd) { - await deleteByPrefix(keybd, SCHEDA_ANNUNCIO_CACHE_PREFIX); - await deleteByPrefix(keybd, CONDIZIONI_CACHE_PREFIX); - } -}; - -export const getCacheStats = async () => { - const keybd = getKeydbClient(); - if (keybd) { - const schedaKeys = await keybd.keys(`${SCHEDA_ANNUNCIO_CACHE_PREFIX}*`); - const condizioniKeys = await keybd.keys(`${CONDIZIONI_CACHE_PREFIX}*`); - return { - success: true, - schedaCount: schedaKeys.length, - condizioniCount: condizioniKeys.length, - }; - } - return { - success: false, - schedaCount: 0, - condizioniCount: 0, - }; -}; -export const genPdfSchedaAnnuncioBase64 = async (annuncioId: AnnunciId) => { - const pdf = await genPdfSchedaAnnuncio(annuncioId); - const pdfBase64 = Buffer.from(pdf).toString("base64"); - const base64Content = pdfBase64.includes("base64,") - ? pdfBase64.split("base64,")[1] - : pdfBase64; - if (!base64Content) { - throw new Error("Failed to convert PDF to base64"); - } - return base64Content; -}; - -export const genPdfSchedaAnnuncio = async (annuncioId: AnnunciId) => { - const keybd = getKeydbClient(); - const url = `/area-riservata/scheda-annuncio-stampa/${annuncioId}?raw=true`; - const options: PDFOptions = { printBackground: true }; - if (keybd) { - const cacheKey = `${SCHEDA_ANNUNCIO_CACHE_PREFIX}${annuncioId}`; - const cached = await keybd.get(cacheKey); - if (cached) { - return new Uint8Array(Buffer.from(cached, "base64")); - } - const scheda = await puppeteerGenPdf({ - url, - options, - }); - await keybd.set( - cacheKey, - Buffer.from(scheda).toString("base64"), - "EX", - 3600 * 5, - ); - return scheda; - } - return await puppeteerGenPdf({ url, options }); -}; - -export const genPdfCondizioniBase64 = async ( - condizioniId: TestiEStringheStingaId, -) => { - const pdf = await genPdfCondizioni(condizioniId); - const pdfBase64 = Buffer.from(pdf).toString("base64"); - const base64Content = pdfBase64.includes("base64,") - ? pdfBase64.split("base64,")[1] - : pdfBase64; - if (!base64Content) { - throw new Error("Failed to convert PDF to base64"); - } - return base64Content; -}; - -const genPdfCondizioni = async (condizioniId: TestiEStringheStingaId) => { - const keybd = getKeydbClient(); - const url = `/servizio/condizioni/${condizioniId}?raw=true`; - if (keybd) { - const cacheKey = `${CONDIZIONI_CACHE_PREFIX}${condizioniId}`; - const cached = await keybd.get(cacheKey); - if (cached) { - return new Uint8Array(Buffer.from(cached, "base64")); - } - const scheda = await puppeteerGenPdf({ url }); - await keybd.set( - cacheKey, - Buffer.from(scheda).toString("base64"), - "EX", - 3600 * 24 * 5, - ); - return scheda; - } - return await puppeteerGenPdf({ url }); -}; - -export const genPdfRicevutaCortesia = async (ordineId: OrdiniOrdineId) => { - const url = `/servizio/ricevuta-cortesia/${ordineId}?raw=true`; - return await puppeteerGenPdf({ url }); -}; - -export const genPdfRicevutaCortesiaBase64 = async ( - ordineId: OrdiniOrdineId, -) => { - const pdf = await genPdfRicevutaCortesia(ordineId); - const pdfBase64 = Buffer.from(pdf).toString("base64"); - const base64Content = pdfBase64.includes("base64,") - ? pdfBase64.split("base64,")[1] - : pdfBase64; - if (!base64Content) { - throw new Error("Failed to convert PDF to base64"); - } - return base64Content; -}; - -const puppeteerGenPdf = async ({ - url, - options, -}: { - url: string; - options?: PDFOptions; -}) => { - let browser = null; - - try { - browser = await puppeteer.connect({ - browserWSEndpoint: `${env.BROWSER_WS_URL}`, - }); - - const page = await browser.newPage(); - - const c = await browser.cookies(); - if ( - c.length === 0 || - !c.some( - (cookie) => cookie.name === TOKEN_CONFIG.OVERRIDE_TOKEN_COOKIE_NAME, - ) - ) { - const productionDomain = new URL(env.INTERNAL_BASE_URL).hostname; - const newCookies = [ - { - name: TOKEN_CONFIG.OVERRIDE_TOKEN_COOKIE_NAME, - value: env.EXP_API_PASS, - domain: - env.NODE_ENV === "production" - ? productionDomain - : "host.docker.internal", - path: "/", - }, - ]; - await browser.setCookie(...newCookies); - } - - const baseUrl = - env.NODE_ENV === "production" - ? env.INTERNAL_BASE_URL - : `http://host.docker.internal:3000`; - - const targetUrl = `${baseUrl}${url}`; - try { - await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 60000 }); - } catch (e) { - if ((e as Error).message.includes("detached")) { - await page.goto(targetUrl, { waitUntil: "load", timeout: 60000 }); - } else throw e; - } - - const pdf = await page.pdf({ - format: "A4", - ...options, - }); - return pdf; - } catch (error) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Errore nella generazione del PDF, URL: ${url}, ${(error as Error).message}`, - }); - } finally { - // 'disconnect' tells browserless you're done with this tab - if (browser) await browser.disconnect(); - } -}; diff --git a/apps/infoalloggi/src/server/services/typst.service.ts b/apps/infoalloggi/src/server/services/typst.service.ts index afa5529..6c5e1d3 100644 --- a/apps/infoalloggi/src/server/services/typst.service.ts +++ b/apps/infoalloggi/src/server/services/typst.service.ts @@ -3,9 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { TRPCError } from "@trpc/server"; -import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe"; -import { db } from "~/server/db"; -import { getStringa } from "~/server/services/testi_stringhe.service"; +import type { EstrattoContoData } from "~/server/api/routers/stripe_reports"; const execPromise = promisify(exec); const projectRoot = path.resolve("."); @@ -13,140 +11,11 @@ const fontsPath = path.join(projectRoot, "typst", "fonts"); const tmpDir = path.join(projectRoot, "tmp"); -type RicevutaProps = { - ordineId: string; - date: string; - datetime: string; - nominativo: string; - codicefiscale: string; - indirizzo: string; - nazione: string; - items: { - title: string; - desc: string | null; - discount: number | null; - amount: number; - strike: boolean; - }[]; - total: number; - vat: number; - method: string; -}; - -export const TypstRunner = async () => { - const timestamp = Date.now(); - await fs.mkdir(tmpDir, { recursive: true }); - const jsonFileName = `data-${timestamp}.json`; - const jsonPath = path.join(tmpDir, jsonFileName); - const templatePath = path.join(projectRoot, "typst", "receipt.typ"); - const pdfPath = path.join(tmpDir, `receipt-${timestamp}.pdf`); - - const props: RicevutaProps = { - ordineId: "dniuawndiandioawmodmawodmawdaw", - date: "10 gennaio 2026", - datetime: "14:55", - nominativo: "Di Grigoli Angelo Damiano", - codicefiscale: "DGRNLD97C13B602Z", - indirizzo: "Via Aldo Fantina 15, PIEVE DEL GRAPPA (TV) 31017,", - nazione: "Italia", - items: [ - { - title: "Acconto Cerco Transitorio", - desc: null, - discount: null, - amount: 3500, - strike: false, - }, - { - title: "Saldo Cerco Affitto Transitorio (6 mesi)", - desc: "Pagamento differito, non è dovuto in questo momento", - discount: null, - amount: 37500, - strike: true, - }, - ], - total: 3500, - vat: 3500 * 0.22, - method: "Carta di credito", - }; - - await fs.writeFile(jsonPath, JSON.stringify(props)); - - try { - const command = `typst compile --input data_path=/tmp/${jsonFileName} --root "${projectRoot}" --font-path "${fontsPath}" "${templatePath}" "${pdfPath}"`; - - await execPromise(command); - - const pdfBuffer = await fs.readFile(pdfPath); - return new Response(pdfBuffer, { - headers: { "Content-Type": "application/pdf" }, - }); - } catch (err) { - console.error("typst compile failed:", err); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to generate PDF with Typst", - cause: err instanceof Error ? err : undefined, - }); - } finally { - // Cleanup — runs even on failure - await Promise.all([ - fs.unlink(jsonPath).catch(() => {}), - //fs.unlink(pdfFile).catch(() => {}), - ]); - } -}; - -export const TypstRunner2 = async () => { - const s = await getStringa({ - db: db, - stringaId: "CONDIZIONI_CERCHI" as TestiEStringheStingaId, - }); - if (!s || !s.stringa_value) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Stringa non trovata", - }); - } - const timestamp = Date.now(); - await fs.mkdir(tmpDir, { recursive: true }); - const jsonFileName = `tmp-${timestamp}.json`; - const jsonPath = path.join(tmpDir, jsonFileName); - const templatePath = path.join(projectRoot, "typst", "testi.typ"); - const pdfFile = path.join(tmpDir, `doc-${timestamp}.pdf`); - - const p = convertHtmlToTypst(s.stringa_value); - - await fs.writeFile(jsonPath, JSON.stringify({ stringa: p })); - - try { - const command = `typst compile --input data_path=/tmp/${jsonFileName} --root "${projectRoot}" --font-path "${fontsPath}" "${templatePath}" "${pdfFile}"`; - await execPromise(command); - const pdfBuffer = await fs.readFile(pdfFile); - return new Response(pdfBuffer, { - headers: { "Content-Type": "application/pdf" }, - }); - } catch (err) { - console.error("typst compile failed:", err); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to generate PDF with Typst", - cause: err instanceof Error ? err : undefined, - }); - } finally { - // Cleanup — runs even on failure - await Promise.all([ - fs.unlink(jsonPath).catch(() => {}), - //fs.unlink(pdfFile).catch(() => {}), - ]); - } -}; - /** * Converts HTML to Typst markup using Pandoc. * works on Windows & Linux. */ -function convertHtmlToTypst(html: string): string { +export function convertHtmlToTypst(html: string): string { try { // We pass the HTML via 'input' which writes to the process stdin const output = execSync( @@ -165,3 +34,95 @@ function convertHtmlToTypst(html: string): string { }); } } + +type EstrattoContoTemplate = { + templateId: "estratto.typ"; + data: EstrattoContoData; +}; +export type RicevutaProps = { + ordineId: string; + date: string; + datetime: string; + nominativo: string; + codicefiscale: string; + indirizzo: string; + nazione: string; + items: { + title: string; + desc: string | null; + discount: number | null; + amount: number; + strike: boolean; + }[]; + total: number; + vat: number; + method: string; +}; +type RicevutaTemplate = { + templateId: "receipt.typ"; + data: RicevutaProps; +}; + +type TestiTemplate = { + templateId: "testi.typ"; + data: { + /** + * const striga = convertHtmlToTypst(stringa_value); + */ + stringa: string; + }; +}; +export type AnnuncioTemplateData = { + codice: string; + nominativo: string; + numero: string; + indirizzo: string; + lat: string; + lon: string; + tipo: string; + prezzo: number; + titolo_it: string; + desc_it: string; + disponibile_da: string; + caratteristiche: [string, string][]; +}; +type AnnuncioTemplate = { + templateId: "annuncio.typ"; + data: AnnuncioTemplateData; +}; +type TypstInput = + | EstrattoContoTemplate + | RicevutaTemplate + | TestiTemplate + | AnnuncioTemplate; + +export const TypstGenerate = async ({ templateId, data }: TypstInput) => { + const timestamp = Date.now(); + await fs.mkdir(tmpDir, { recursive: true }); + const tmpDataFile = `data-${timestamp}.json`; + const tmpDataPath = path.join(tmpDir, tmpDataFile); + const templatePath = path.join(projectRoot, "typst", templateId); + const resultPath = path.join(tmpDir, `doc-${timestamp}.pdf`); + + await fs.writeFile(tmpDataPath, JSON.stringify(data)); + + try { + const command = `typst compile --input data_path=/tmp/${tmpDataFile} --root "${projectRoot}" --font-path "${fontsPath}" "${templatePath}" "${resultPath}"`; + + await execPromise(command); + + return await fs.readFile(resultPath); + } catch (err) { + console.error(`typst compile failed, template: ${templateId}:`, err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to generate PDF with Typst, template: ${templateId}`, + cause: err instanceof Error ? err : undefined, + }); + } finally { + await Promise.all([ + fs.unlink(tmpDataPath).catch(() => {}), + fs.unlink(resultPath).catch(() => {}), + ]); + } +}; diff --git a/apps/infoalloggi/tsconfig.json b/apps/infoalloggi/tsconfig.json index 03ed924..1bd0da6 100644 --- a/apps/infoalloggi/tsconfig.json +++ b/apps/infoalloggi/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "allowJs": true, /* Path Aliases */ + "baseUrl": ".", "checkJs": true, /* Base Options: */ @@ -51,7 +52,8 @@ "emails", ".next/types/**/*.ts", "tests", - "flow-gen.ts" + "flow-gen.ts", + "check-unused-trpc.ts" ], "ts-node": { // these options are overrides used only by ts-node diff --git a/apps/infoalloggi/typst/annuncio.typ b/apps/infoalloggi/typst/annuncio.typ index 2fe3cf9..7407cc7 100644 --- a/apps/infoalloggi/typst/annuncio.typ +++ b/apps/infoalloggi/typst/annuncio.typ @@ -1,15 +1,35 @@ #import "@preview/iconic-salmon-fa:1.1.0": fa-location-pin, fa-whatsapp +#import "utils.typ": format-eur +#import "@preview/oxifmt:1.0.0": strfmt +#let data_file = sys.inputs.at("data_path", default: "none") + +#let data = if data_file != "none" { json(data_file) } else { + ( + codice: "3786B", + nominativo: "INFOALLOGGI (delegato)", + numero: "+393453944827", + indirizzo: "VIA MONTE ASOLONE, 21 int 1 Bassano del Grappa 36061 (VI)", + lat: "45.7705", + lon: "11.74959", + tipo: "Transitorio", + prezzo: 54000, + titolo_it: "BASSANO S. VITO MONOLOCALE A USO TRANSITORIO - 01/05/26", + desc_it: "CODICE: 3786B - LIBERO DA MAGGIO - AFFITTO TRANSITORIO -\n\nConfortevole monolocale situato nella tranquilla zona di San Vito, a Bassano del Grappa. Libero da giugno, questo appartamento è la soluzione ideale per chi cerca un affitto transitorio. Completamente ammobiliato e arredato con gusto, il monolocale si trova al piano terra e offre un soggiorno luminoso con un angolo cottura recente. La camera, dotata di un comodo letto alla francese, è separata da una parete per garantire privacy. Classe energetica F.\n\nIl bagno è completo di doccia e lavatrice nuova, perfetto per le esigenze quotidiane. Inoltre, è disponibile un posto auto scoperto riservato, rendendo la vita ancora più comoda. Le utenze sono attive, e il contratto di affitto prevede una permanenza da 6 a 12 mesi, con un canone mensile di 540,00 € e spese condominiali di 80,00 €/mese.\n\nQuesto monolocale è ideale per single e si trova in un contesto tranquillo, lontano dal caos cittadino. Non è consentito fumare all'interno dei locali e si preferiscono inquilini senza animali. Non perdere l'occasione di vivere in un ambiente accogliente e funzionale! Contattaci per maggiori informazioni e per organizzare una visita.\n\nRICHIESTE DEL PROPRIETARIO:\n\nTipologia contratto di locazione: TRANSITORIO (No stabile)\nRegime fiscale: CEDOLARE SECCA (Senza imposte di registro e bolli)\nData disponibilità: 1 MAGGIO\nPermanenza: DA 6 a 12 MESI\nPreferenza: SINGLE\nContratto di lavoro: A TEMPO DETERMINATO (o altra motivazione prevista per legge)\nCanone mensile: 540,00 €\nSpese condominiali e accessorie: 80,00 €/MESE\nDeposito cauzionale: IN BASE ALLA PERMANENZA\nConsumi utenze: Gas/Riscaldamento - Luce - Acqua (Rimborso a CONSUMO)\nFumatori: NON E' CONSENTITO FUMARE ALL'INTERNO DEI LOCALI\nAnimali: NON E' CONSENTITO\n\nCOSA FARE PER VISITARE L'IMMOBILE\n \nInfoalloggi.it è una piattaforma immobiliare che ti consente di avere un contatto diretto con i proprietari previa attivazione del servizio. La piattaforma e i nostri referenti sono a tua disposizione per assisterti nella tua ricerca. \nLa geo-localizzazione indica la zona dell'immobile, l'indirizzo esatto lo ricevi con l'attivazione del servizio.\n", + disponibile_da: "01/05/2026", + caratteristiche: ( + ("Tipo Impianto", "Radiatori"), + ("Riscaldamento", "Autonomo"), + ("Arredi", "Arredato"), + ("Piani Edificio", "Uno"), + ("Internet", "No"), + ), + ) +} + + #set page( paper: "a4", margin: (bottom: 1.5cm, rest: 1cm), - footer: [ - #line(length: 100%, stroke: 0.5pt + luma(200)) - #set text(size: 8pt, fill: luma(100)) - #grid( - columns: (1fr, auto), - [Arcenia Srl. Tel. +39 0424529869 | Email: arca\@infoalloggi.it], [Documento generato in data: 31/3/2026], - ) - ], ) #set text(font: "Inter 18pt", size: 10pt, lang: "it", fill: rgb("#1f1f1f")) @@ -28,112 +48,90 @@ Email: arca\@infoalloggi.it ], ) -#v(1em) -#line(length: 100%, stroke: 0.5pt + luma(200)) -#v(1em) - -// --- TITLE & REFERENCE --- +#v(0.5em) #align(center)[#text( - size: 16pt, - weight: "bold", + size: 14pt, + weight: "semibold", fill: rgb("#63493f"), -)[BASSANO S. VITO MONOLOCALE A USO TRANSITORIO - 01/06/26]] +)[#data.titolo_it]] #v(0.5em) #align(center)[#text( - size: 14pt, -)[Riferimento: *3786B*]] + size: 13pt, +)[Riferimento: *#data.codice*]] -#v(1.5em) +#v(.5em) -#box(stroke: (paint: rgb("#25d366"), thickness: 1.5pt), width: 100%, inset: 16pt, radius: 10pt)[ +#box(stroke: (paint: rgb("#25d366"), thickness: 1.5pt), width: 100%, inset: 16pt, radius: 10pt, fill: rgb("#fcfcfc"))[ #grid( columns: 1fr, gutter: 14pt, - [#fa-whatsapp(size: 20pt, fill: rgb("#25d366"))#h(1em)#text( - size: 16pt, - weight: "bold", + [#fa-whatsapp(size: 16pt, fill: rgb("#25d366"))#h(1em)#text( + size: 14pt, + weight: "semibold", fill: rgb("#63493f"), )[Contatti]#h(.5em)#text(size: 12pt, fill: rgb("#63493f"))[(mandare un messaggio WhatsApp prima di chiamare)]], - [INFOALLOGGI (delegato)], - [*Tel. +393453944827*] + [#data.nominativo], + [*Tel. #data.numero*] ) ] -#v(1.5em) -#grid( - columns: 1fr, - gutter: 10pt, [ - #text(size: 10pt, weight: "bold")[Indirizzo]], [ - #line(length: 100%, stroke: 0.5pt + luma(200))], [ - #text(weight: "bold", size: 12pt)[VIA MONTE ASOLONE, 21 int 1 Bassano del Grappa 36061 (VI)]#h(3em)#link( - "https://maps.google.com", - )[#fa-location-pin(size: 20pt, fill: red)#text(fill: blue)[Apri in Google Maps]] - ] -) +#v(.5em) +#text(weight: "semibold")[Indirizzo] +#line(length: 100%, stroke: 0.5pt + luma(200)) +#text(weight: "semibold")[#data.indirizzo]#h(3em)#link( + strfmt( + "https://www.google.com/maps/dir/?api=1&travelmode=driving&layer=traffic&destination={},{}", + data.lat, + data.lon, + ), +)[#fa-location-pin(size: 18pt, fill: red)#text(fill: blue)[Apri in Google Maps]] -#v(0.5em) +#v(1em) -#v(0.5em) - - -#v(1.5em) // --- KEY DETAILS TABLE --- #table( columns: (1fr, 1fr, 1fr), - stroke: 0.5pt + luma(200), - inset: 10pt, - [*Tipologia* \ Affitto Transitorio], [*Canone Mensile* \ 540,00 €], [*Disponibile da* \ 31/05/2026], + stroke: none, + //inset: 10pt, + [#box( + inset: 0.8em, + fill: luma(245), + height: auto, + width: 100%, + )[Tipologia \ #text(weight: "semibold", size: 12pt)[Affitto #data.tipo]] ], + [#box( + inset: 0.8em, + fill: luma(245), + height: auto, + width: 100%, + )[Canone Mensile \ #text(weight: "semibold", size: 12pt)[#format-eur(data.prezzo)]]], + [#box( + inset: 0.8em, + fill: luma(245), + height: auto, + width: 100%, + )[Disponibile da \ #text(weight: "semibold", size: 12pt)[#data.disponibile_da]]], ) -#v(1.5em) +#v(.5em) // --- FEATURES --- -#text(size: 12pt, weight: "bold")[Dotazioni e Servizi] +#text(size: 12pt, weight: "semibold")[Dotazioni e Servizi] #v(0.5em) #grid( columns: (1fr, 1fr, 1fr), - gutter: 10pt, - [- *Tipo Impianto:* Radiatori], [- *Riscaldamento:* Autonomo], [- *Arredi:* Arredato], - [- *Piani Edificio:* Uno], [- *Internet:* No], + gutter: 14pt, + + ..data.caratteristiche.map(pair => [- *#pair.at(0)*: #pair.at(1)]) ) #v(1.5em) // --- DESCRIPTION --- -#text(size: 12pt, weight: "bold")[Descrizione] -#v(0.5em) -CODICE: 3786B LIBERO DA GIUGNO AFFITTO TRANSITORIO - Confortevole monolocale situato nella tranquilla zona di San Vito, a Bassano del Grappa. Libero da giugno, questo appartamento è la soluzione ideale per chi cerca un affitto transitorio. - -Completamente ammobiliato e arredato con gusto, il monolocale si trova al piano terra e offre un soggiorno luminoso con un angolo cottura recente. La camera, dotata di un comodo letto alla francese, è separata da una parete per garantire privacy. Classe energetica F. - -Il bagno è completo di doccia e lavatrice nuova, perfetto per le esigenze quotidiane. Inoltre, è disponibile un posto auto scoperto riservato, rendendo la vita ancora più comoda. Le utenze sono attive, e il contratto di affitto prevede una permanenza da 6 a 12 mesi, con un canone mensile di 540,00 € e spese condominiali di 80,00 €/mese. - -Questo monolocale è ideale per single e si trova in un contesto tranquillo, lontano dal caos cittadino. Non è consentito fumare all'interno dei locali e si preferiscono inquilini senza animali. Non perdere l'occasione di vivere in un ambiente accogliente e funzionale! Contattaci per maggiori informazioni e per organizzare una visita. +#text(size: 12pt, weight: "semibold")[Descrizione] +#line(length: 100%, stroke: 0.5pt + luma(200)) +#text(size: 9pt)[#data.desc_it] #v(1.5em) -// --- OWNER REQUESTS --- -#rect(fill: rgb("#f5f5f5"), width: 100%, inset: 15pt, stroke: none)[ - #text(size: 12pt, weight: "bold")[RICHIESTE DEL PROPRIETARIO:] - #v(0.5em) - #list( - [*Tipologia contratto di locazione:* TRANSITORIO (No stabile)], - [*Regime fiscale:* CEDOLARE SECCA (Senza imposte di registro e bolli)], - [*Data disponibilità:* 1 GIUGNO], - [*Permanenza:* DA 6 a 12 MESI], - [*Preferenza:* SINGLE], - [*Contratto di lavoro:* A TEMPO DETERMINATO (o altra motivazione prevista per legge)], - [*Canone mensile:* 540,00 €], - [*Spese condominiali e accessorie:* 80,00 €/MESE], - [*Deposito cauzionale:* IN BASE ALLA PERMANENZA], - [*Consumi utenze:* Gas/Riscaldamento - Luce Acqua (Rimborso a CONSUMO)], - [*Fumatori:* NON E' CONSENTITO FUMARE ALL'INTERNO DEI LOCALI], - [*Animali:* NON E' CONSENTITO], - ) +#text(size: 8pt, fill: rgb("#636363"))[ + Informazione strettamente riservata ed indirizzata all'Utente Arca-Infoalloggi nel rispetto dell' Art. F. Precisazione dei Tuoi Obblighi e responsabilità delle Condizioni Generali di Contratto (CGC) accettate ] -#v(1.5em) - -// --- HOW TO VISIT --- -#text(size: 12pt, weight: "bold")[COSA FARE PER VISITARE L'IMMOBILE] -#v(0.5em) -Infoalloggi.it è una piattaforma immobiliare che ti consente di avere un contatto diretto con i proprietari previa attivazione del servizio. La piattaforma e i nostri referenti sono a tua disposizione per assisterti nella tua ricerca. - -La geo-localizzazione indica la zona dell'immobile, l'indirizzo esatto lo ricevi con l'attivazione del servizio. Informazione strettamente riservata ed indirizzata all'Utente Arca-Infoalloggi nel rispetto dell' Art. F. Precisazione dei Tuoi Obblighi e responsabilità delle Condizioni Generali di Contratto (CGC) accettate. diff --git a/apps/infoalloggi/typst/estratto.typ b/apps/infoalloggi/typst/estratto.typ new file mode 100644 index 0000000..bd6313b --- /dev/null +++ b/apps/infoalloggi/typst/estratto.typ @@ -0,0 +1,122 @@ +#import "utils.typ": format-eur + +#let data_file = sys.inputs.at("data_path", default: "none") +#let data = if data_file != "none" { json(data_file) } else { + ( + interval: "01/03/2026 - 31/03/2026", + emissione: "03/04/2026", + starting_balance: 0, + ending_balance: 0, + activity_gross: 184500, // Entrate lorde + activity_fee: -6187, // Commissioni + payouts: -178313, // Prelievi verso conto bancario + movimenti: ( + ( + "date": "17/03/2026", + "description": "Acconto Cerco Transitorio", + "gross": 3500, + "net": 3361, + "fees": -139, + "name": "Rossi Mario", + "email": "killmyfootwithsniper@gmail.com", + ), + ( + "date": "17/03/2026", + "description": "Rinnovo permanenza 6 mesi", + "gross": 36000, + "net": 34805, + "fees": -1195.0000004, + "name": "Rossi Mario", + "email": "killmyfootwithsniper@gmail.com", + ), + ), + ) +} + + + + +#set page(paper: "a4", margin: (x: .5cm, y: 1.5cm)) +#set text(font: "Inter 18pt", size: 10pt) + +#let stripe-blue = rgb("#635BFF") +#let text-dark = rgb("#30313D") +#let text-muted = rgb("#687385") +#let border-color = rgb("#E3E8EE") +#let bg-light = rgb("#F7FAFC") + +#grid( + columns: (1fr, 1fr), + align(left)[ + // Simulazione del logo Stripe + #text(fill: stripe-blue, size: 28pt, weight: "bold")[Stripe] + ], + align(right)[ + #text(fill: text-muted, size: 14pt, weight: "bold")[ESTRATTO CONTO] \ + #v(4pt) + #text(fill: text-dark, size: 10pt)[*Periodo:* #data.interval] \ + #text(fill: text-dark, size: 10pt)[*Generato il:* #data.emissione] + ], +) + +#v(.5cm) + +#text(fill: text-muted, size: 9pt, weight: "bold")[INTESTARIO DEL CONTO:] \ +#v(2pt) +#text(weight: "bold", size: 11pt, fill: text-dark)[Arcenia S.r.l.] \ +Via Beata Giovanna, 1 \ +36061 Bassano del Grappa (VI) \ +Italia \ +P.IVA: 00924740244 + +#v(.5cm) + +#text(size: 14pt, weight: "bold", fill: stripe-blue)[Riepilogo del Conto] +#v(0.3cm) + +#table( + columns: (1fr, 1fr, 1fr, 1fr, 1fr), + stroke: border-color, + fill: (col, row) => if row == 0 { bg-light } else { none }, + align: center + horizon, + + [*Saldo Iniziale*], [*Totale Entrate*], [*Commissioni*], [*Prelievi*], [*Saldo Finale*], + [#format-eur(data.starting_balance)], + [#format-eur(data.activity_gross)], + [#format-eur(data.activity_fee)], + [#format-eur(data.payouts)], + [#format-eur(data.ending_balance)], +) + +#v(.5cm) + +#text(size: 14pt, weight: "bold", fill: stripe-blue)[Dettaglio Movimenti] +#v(0.3cm) + +#table( + columns: (auto, 1fr, 1fr, auto, auto, auto), + stroke: (x, y) => if y == 0 { + (bottom: 1pt + text-muted) + } else { + (bottom: 0.5pt + border-color) + }, + fill: (col, row) => if row == 0 { bg-light } else { none }, + align: (left, left, left, left, left, left), + table.header([*Data*], [*Descrizione*], [*Cliente*], [*Lordo*], [*Comm.*], [*Netto*]), + ..data + .movimenti + .map(item => { + ( + item.date, + item.description, + item.name + "\n" + text(size: 8pt)[#item.email], + format-eur(item.gross), + format-eur(item.fees), + format-eur(item.net), + ) + }) + .flatten(), +) + + + diff --git a/apps/infoalloggi/typst/receipt.typ b/apps/infoalloggi/typst/receipt.typ index 3956554..eef51f5 100644 --- a/apps/infoalloggi/typst/receipt.typ +++ b/apps/infoalloggi/typst/receipt.typ @@ -1,4 +1,4 @@ -#import "utils.typ": format-price +#import "utils.typ": format-eur #let data_file = sys.inputs.at("data_path", default: "none") @@ -77,7 +77,7 @@ ], align(right)[ #text(fill: luma(120), size: 9pt)[Data] \ - #data.date \ + #text(size: 12pt)[#data.date] \ #data.datetime ], ) @@ -111,11 +111,15 @@ .map(item => { ( item.title + if item.desc != none { "\n" + text(size: 9pt, fill: luma(100))[#item.desc] } else { "" }, - if item.at("discount", default: none) != none { [#item.discount %] } else [], + if item.at("discount", default: none) != none { + if item.discount > 0 { + [#item.discount %] + } + } else [], if item.at("strike", default: false) { - strike(stroke: 1pt + luma(100))[#text(fill: luma(100))[#format-price(item.amount)]] + strike(stroke: 1pt + luma(100))[#text(fill: luma(100))[#format-eur(item.amount)]] } else { - [#format-price(item.amount)] + [#format-eur(item.amount)] }, table.hline(stroke: 0.5pt + luma(200)), ) @@ -130,8 +134,8 @@ columns: (1fr, auto), align: (right, right), gutter: 1.5em, - [*Totale:*], [*#format-price(data.total)*], - [#text(fill: luma(100))[di cui IVA (22%):]], [#text(fill: luma(100))[#format-price(data.vat)]], + [*Totale:*], [*#format-eur(data.total)*], + [#text(fill: luma(100))[di cui IVA (22%):]], [#text(fill: luma(100))[#format-eur(data.vat)]], ) ] diff --git a/apps/infoalloggi/typst/utils.typ b/apps/infoalloggi/typst/utils.typ index 1d7cc86..52556b7 100644 --- a/apps/infoalloggi/typst/utils.typ +++ b/apps/infoalloggi/typst/utils.typ @@ -1,15 +1,38 @@ #import "@preview/oxifmt:1.0.0": strfmt -#let format-price(cents) = { - // Handle case where cents might be null or not a number - if cents == none { return "0,00 €" } - let euros = int(cents / 100) - let decimal = calc.rem(cents, 100) - // Create the decimal string and pad with a leading zero if needed (e.g., 5 cents -> "05") - let decimal-str = if decimal < 10 { "0" + str(decimal) } else { str(decimal) } +#let format-eur(cents) = { + // 1. Gestione del segno + let is-negative = cents < 0 + let abs-cents = calc.abs(cents) - return str(euros) + "," + decimal-str + " €" + // 2. Calcolo parte intera e frazionaria + // Usiamo calc.round per sicurezza se l'input non fosse un intero puro + let total-abs = calc.round(abs-cents) + let whole = calc.trunc(total-abs / 100) + let frac = calc.rem(total-abs, 100) + + // 3. Formattazione Migliaia (punti) + let chars = str(whole).clusters() + let formatted-whole = "" + let count = 0 + for c in chars.rev() { + if count > 0 and calc.rem(count, 3) == 0 { + formatted-whole = "." + formatted-whole + } + formatted-whole = c + formatted-whole + count += 1 + } + + // 4. Formattazione Centesimi (sempre 2 cifre) + // Usiamo lo zero-padding per casi come 5 centesimi -> "05" + let frac-str = if frac < 10 { "0" + str(frac) } else { str(frac) } + + // 5. Output finale + let sign = if is-negative { "-" } else { "" } + + // Ritorna il testo formattato + [#sign #formatted-whole,#frac-str €] }