feat: implement Stripe reports management for admin panel
- Add new page for managing Stripe reports with functionality to generate and view reports. - Create API router for handling Stripe report operations including listing, setting up, and generating reports. - Introduce caching service for managing cache invalidation related to reports. - Update Typst templates for formatting financial reports in EUR. - Enhance utility functions for price formatting and error handling. - Implement a script to check for unused tRPC procedures in the codebase.
This commit is contained in:
parent
964abfbf46
commit
f52dd96c11
44 changed files with 1850 additions and 2217 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
TODOS:
|
TODOS:
|
||||||
- Log email fallite e retry
|
- Log email fallite e retry
|
||||||
|
- piu info negli annunci_servizio sheet
|
||||||
|
- override magico per saltare inserimento strighe caparra e contratto
|
||||||
|
|
||||||
AFTER MVP:
|
AFTER MVP:
|
||||||
- TODO migrazione app router https://nextjs.org/docs/pages/building-your-application/upgrading/app-router-migration#migrating-from-pages-to-app
|
- TODO migrazione app router https://nextjs.org/docs/pages/building-your-application/upgrading/app-router-migration#migrating-from-pages-to-app
|
||||||
|
|
|
||||||
116
apps/infoalloggi/check-unused-trpc.ts
Normal file
116
apps/infoalloggi/check-unused-trpc.ts
Normal file
|
|
@ -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<string>();
|
||||||
|
|
||||||
|
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();
|
||||||
763
apps/infoalloggi/package-lock.json
generated
763
apps/infoalloggi/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -18,7 +18,8 @@
|
||||||
"types": "tsc --noEmit",
|
"types": "tsc --noEmit",
|
||||||
"analyze": "cross-env ANALYZE=true next build",
|
"analyze": "cross-env ANALYZE=true next build",
|
||||||
"vitest": "vitest run",
|
"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",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -76,10 +77,10 @@
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^8.0.1",
|
||||||
"nodemailer-html-to-text": "^3.2.0",
|
"nodemailer-html-to-text": "^3.2.0",
|
||||||
"nuqs": "^2.8.6",
|
"nuqs": "^2.8.6",
|
||||||
|
"papaparse": "^5.5.3",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pg": "^8.19.0",
|
"pg": "^8.19.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"puppeteer-core": "^24.37.5",
|
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.6.1",
|
||||||
|
|
@ -91,7 +92,7 @@
|
||||||
"react-is": "^19.2.3",
|
"react-is": "^19.2.3",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-number-format": "^5.4.4",
|
"react-number-format": "^5.4.4",
|
||||||
"react-pdf": "^10.2.0",
|
"react-pdf": "^10.4.0",
|
||||||
"react-phone-number-input": "^3.4.14",
|
"react-phone-number-input": "^3.4.14",
|
||||||
"react-reverse-portal": "^2.3.0",
|
"react-reverse-portal": "^2.3.0",
|
||||||
"react-select": "^5.10.2",
|
"react-select": "^5.10.2",
|
||||||
|
|
@ -118,6 +119,7 @@
|
||||||
"@types/node": "^24.2.0",
|
"@types/node": "^24.2.0",
|
||||||
"@types/nodemailer": "^7.0.11",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/nodemailer-html-to-text": "^3.1.3",
|
"@types/nodemailer-html-to-text": "^3.1.3",
|
||||||
|
"@types/papaparse": "^5.5.2",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19.2.9",
|
"@types/react": "^19.2.9",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|
|
||||||
|
|
@ -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 <Receipt data={data} />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-4xl">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="font-bold text-2xl">Ricevuta di cortesia</h1>
|
|
||||||
|
|
||||||
<Button onClick={() => generatePdf({ ordineId: data.ordineId })}>
|
|
||||||
Stampa
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Card className="p-4">
|
|
||||||
<Receipt data={data} />
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="font-bold text-2xl">{companyName}</h1>
|
|
||||||
<p className="whitespace-pre-line text-sm">{companyAddress}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative size-24">
|
|
||||||
<Image
|
|
||||||
alt={`${companyName} logo`}
|
|
||||||
className="object-contain"
|
|
||||||
fill
|
|
||||||
src={"/favicon/favicon.png"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="border-b pb-4">
|
|
||||||
<h2 className="text-center font-semibold text-xl">
|
|
||||||
Ricevuta di cortesia
|
|
||||||
</h2>
|
|
||||||
<div className="mt-2 flex justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground text-sm">ID Ordine</p>
|
|
||||||
<p className="text-sm">{data.ordineId}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-muted-foreground text-sm">Data</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{format(data.date, "PPP", {
|
|
||||||
locale: it,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm">
|
|
||||||
{format(data.date, "p", {
|
|
||||||
locale: it,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="mb-1 font-semibold">Intestatario:</h3>
|
|
||||||
<p className="font-medium">{data.clienteNome}</p>
|
|
||||||
<p className="text-sm">{data.codiceFiscale}</p>
|
|
||||||
<p className="whitespace-pre-line text-sm">{data.clienteIndirizzo}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<table className="w-full table-auto">
|
|
||||||
<thead className="flex w-full">
|
|
||||||
<tr className="grid w-full grow grid-cols-[1fr_auto_auto] gap-2 border-b">
|
|
||||||
<th className="py-2 text-left">Oggetto</th>
|
|
||||||
|
|
||||||
<th className="py-2 text-right">Sconto applicato</th>
|
|
||||||
<th className="py-2 text-right">Importo</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr className="grid w-full grow grid-cols-[1fr_auto_auto] gap-2 border-b">
|
|
||||||
<td className="py-2">{data.packName}</td>
|
|
||||||
|
|
||||||
<td className="py-2 text-right">
|
|
||||||
{data.sconto > 0 && `${data.sconto}%`}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 text-right">
|
|
||||||
{formatCurrency(data.amount_cent / 100)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{data.previewSaldo && (
|
|
||||||
<tr className="grid w-full grow grid-cols-[1fr_auto_auto] gap-2 border-b">
|
|
||||||
<td className="py-2">
|
|
||||||
{data.previewSaldo.nome_it}{" "}
|
|
||||||
<span className="text-sm">
|
|
||||||
pagamento differito, non è dovuto in questo momento
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="py-2 text-right"> </td>
|
|
||||||
<td className="py-2 text-right text-muted-foreground line-through">
|
|
||||||
{formatCurrency(data.previewSaldo.prezzo_cent / 100)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<div className="w-64">
|
|
||||||
<div className="mt-1 flex justify-between pt-2 font-bold">
|
|
||||||
<span>Totale:</span>
|
|
||||||
<span>{formatCurrency(data.amount_cent / 100)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between py-1">
|
|
||||||
<span>di cui IVA (22%):</span>
|
|
||||||
<span>{formatCurrency((data.amount_cent / 100) * 0.22)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="mb-1 font-semibold">Metodo di pagamento</h3>
|
|
||||||
<p>{PaymentMethodToString(data.paymentMethod)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<h3 className="mb-1 font-semibold">Status pagamento</h3>
|
|
||||||
<p className="font-medium text-green-600">Pagato</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<h3 className="mb-1 font-semibold">Note</h3>
|
|
||||||
<p className="whitespace-pre-line text-sm">{note}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-6 text-center text-muted-foreground text-sm">
|
|
||||||
<p>{footer}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -78,7 +78,7 @@ const AdminOrdiniModal = ({
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody className="max-h-[80vh] space-y-2 overflow-auto pb-5">
|
<CredenzaBody className="max-h-[80vh] space-y-2 overflow-auto pb-5">
|
||||||
<p>Id Ordine: {data.ordine_id}</p>
|
<p>Id Ordine: {data.ordine_id}</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{data.isActive && (
|
{data.isActive && (
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -148,6 +148,44 @@ export const UserOrdiniModal = ({
|
||||||
const { data } = api.servizio.getOrdineById.useQuery({
|
const { data } = api.servizio.getOrdineById.useQuery({
|
||||||
ordineId,
|
ordineId,
|
||||||
});
|
});
|
||||||
|
const { mutate: generatePdf } =
|
||||||
|
api.pagamenti.getRicevutaCortesiaPdf.useMutation({
|
||||||
|
onMutate: () => {
|
||||||
|
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;
|
if (!data) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -191,14 +229,12 @@ export const UserOrdiniModal = ({
|
||||||
</p>
|
</p>
|
||||||
{data.isActive && (
|
{data.isActive && (
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Button
|
||||||
href={`/servizio/ricevuta-cortesia/${data.ordine_id}`}
|
onClick={() => generatePdf({ ordineId: data.ordine_id })}
|
||||||
target="_blank"
|
variant="outline"
|
||||||
>
|
>
|
||||||
<Button variant="outline">
|
Ricevuta di cortesia <ReceiptText />
|
||||||
Ricevuta di cortesia <ReceiptText />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,9 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import "react-pdf/dist/Page/AnnotationLayer.css";
|
import { Document, Page, pdfjs } from "react-pdf";
|
||||||
import "react-pdf/dist/Page/TextLayer.css";
|
|
||||||
|
|
||||||
import type { PDFDocumentProxy } from "pdfjs-dist";
|
|
||||||
import { LoadingPage } from "./loading";
|
import { LoadingPage } from "./loading";
|
||||||
|
|
||||||
import "pdfjs-dist/build/pdf.worker.min.mjs";
|
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/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();
|
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
cMapUrl: "/cmaps/",
|
cMapUrl: "/cmaps/",
|
||||||
|
|
@ -37,7 +16,6 @@ export const PDFViewer = ({ url }: { url: string }) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [width, setWidth] = useState(0);
|
const [width, setWidth] = useState(0);
|
||||||
const file = useMemo(() => ({ url }), [url]);
|
const file = useMemo(() => ({ url }), [url]);
|
||||||
|
|
||||||
const onResize = useCallback<ResizeObserverCallback>((entries) => {
|
const onResize = useCallback<ResizeObserverCallback>((entries) => {
|
||||||
const [entry] = entries;
|
const [entry] = entries;
|
||||||
|
|
||||||
|
|
@ -55,12 +33,6 @@ export const PDFViewer = ({ url }: { url: string }) => {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function onDocumentLoadSuccess({
|
|
||||||
numPages: nextNumPages,
|
|
||||||
}: PDFDocumentProxy): void {
|
|
||||||
setNumPages(nextNumPages);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="mx-1 flex w-full justify-center overflow-clip rounded-md shadow-lg outline"
|
className="mx-1 flex w-full justify-center overflow-clip rounded-md shadow-lg outline"
|
||||||
|
|
@ -73,7 +45,7 @@ export const PDFViewer = ({ url }: { url: string }) => {
|
||||||
noData={
|
noData={
|
||||||
"Errore nel caricare il documento, riprova più tardi o Scaricalo"
|
"Errore nel caricare il documento, riprova più tardi o Scaricalo"
|
||||||
}
|
}
|
||||||
onLoadSuccess={onDocumentLoadSuccess}
|
onLoadSuccess={({ numPages }) => setNumPages(numPages)}
|
||||||
options={options}
|
options={options}
|
||||||
>
|
>
|
||||||
{Array.from(new Array(numPages), (_el, index) => (
|
{Array.from(new Array(numPages), (_el, index) => (
|
||||||
|
|
@ -83,6 +55,8 @@ export const PDFViewer = ({ url }: { url: string }) => {
|
||||||
index + 1
|
index + 1
|
||||||
}`}
|
}`}
|
||||||
pageNumber={index + 1}
|
pageNumber={index + 1}
|
||||||
|
renderAnnotationLayer={false}
|
||||||
|
renderTextLayer={false}
|
||||||
width={width}
|
width={width}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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<string>("");
|
|
||||||
const [openPopover, setOpenPopover] = useState<boolean>(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 (
|
|
||||||
<div className="mx-auto max-w-4xl">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
||||||
<h1 className="font-bold text-2xl">Scheda Annuncio Stampabile</h1>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={() => generatePdf({ id: data.id })}>
|
|
||||||
Scarica Scheda
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Popover onOpenChange={setOpenPopover} open={openPopover}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button className="print:hidden" variant="outline">
|
|
||||||
Invia tramite email
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="flex w-80 flex-col gap-4 print:hidden">
|
|
||||||
<Label htmlFor="indirizzo">Indirizzo Email</Label>
|
|
||||||
<Input
|
|
||||||
id="indirizzo"
|
|
||||||
onChange={(e) => setInputEmail(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (
|
|
||||||
e.key === "Enter" &&
|
|
||||||
z.safeParse(z.email(), inputEmail).success
|
|
||||||
) {
|
|
||||||
sendPdf();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
type="email"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
disabled={
|
|
||||||
z.safeParse(z.email(), inputEmail).success === false
|
|
||||||
}
|
|
||||||
onClick={sendPdf}
|
|
||||||
>
|
|
||||||
Invia
|
|
||||||
</Button>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SchedaAnnuncio data={data} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string | undefined>(
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [printDate, setPrintDate] = useState<string>("");
|
|
||||||
const [disponibileDate, setDisponibileDate] = useState<string>("");
|
|
||||||
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 (
|
|
||||||
<Card
|
|
||||||
className={cn(
|
|
||||||
"p-4 print:border-none print:shadow-none",
|
|
||||||
raw && "border-none shadow-none",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<LogoSvg className="h-10 w-auto" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<p className="text-right font-semibold text-lg">Arcenia Srl</p>
|
|
||||||
<p className="whitespace-pre-line text-right text-sm">
|
|
||||||
{companyAddress}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Property Header */}
|
|
||||||
<div className="space-y-2 text-center">
|
|
||||||
<h2 className="font-bold text-primary text-xl">
|
|
||||||
{data.titolo_it || "Immobile in Affitto"}
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg text-muted-foreground">
|
|
||||||
Riferimento: <strong>{data.codice}</strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-primary/5 p-4 outline-2 outline-green-500">
|
|
||||||
<h3 className="mb-2 inline-flex items-center gap-2 font-semibold text-lg text-primary">
|
|
||||||
<WhatsAppIcon className="size-6 fill-green-500 stroke-green-500" />{" "}
|
|
||||||
Contatti
|
|
||||||
<span className="text-base">
|
|
||||||
(mandare un messaggio WhatsApp prima di chiamare)
|
|
||||||
</span>
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p>
|
|
||||||
{data.locatore}{" "}
|
|
||||||
{data.tipo_locatore ? `(${data.tipo_locatore})` : ""}
|
|
||||||
</p>
|
|
||||||
{data.numero && <p className="font-semibold">Tel. {data.numero}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Property Details Grid */}
|
|
||||||
<div className="grid grid-cols-1 gap-6 pb-1">
|
|
||||||
{/* Location Details */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="border-b pb-1 font-semibold text-lg">Indirizzo</h3>
|
|
||||||
<div className="flex items-center gap-8">
|
|
||||||
<p className="font-semibold">
|
|
||||||
{data.indirizzo}, {data.civico} {data.comune} {data.cap} (
|
|
||||||
{data.provincia})
|
|
||||||
</p>
|
|
||||||
{navigationUrl && (
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 text-blue-600 underline underline-offset-2"
|
|
||||||
href={navigationUrl}
|
|
||||||
id="navigationLink"
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<span>Apri in Google Maps</span>
|
|
||||||
<GoogleMapsIcon className="size-6" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Property Characteristics */}
|
|
||||||
{/* <div className="space-y-3">
|
|
||||||
<h3 className="border-b pb-1 font-semibold text-lg">
|
|
||||||
🏠 Caratteristiche
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
{data.mq && (
|
|
||||||
<p>
|
|
||||||
<span className="font-medium">mq:</span> {data.mq} mq
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{data.numero_vani && (
|
|
||||||
<p>
|
|
||||||
<span className="font-medium">Locali:</span>{" "}
|
|
||||||
{data.numero_vani}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{data.numero_camere && (
|
|
||||||
<p>
|
|
||||||
<span className="font-medium">Camere:</span>{" "}
|
|
||||||
{data.numero_camere}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{data.numero_bagni && (
|
|
||||||
<p>
|
|
||||||
<span className="font-medium">Bagni:</span>{" "}
|
|
||||||
{data.numero_bagni}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div> */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pricing Information */}
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
|
||||||
{data.tipo && (
|
|
||||||
<div className="rounded bg-gray-50 p-3">
|
|
||||||
<p className="font-medium text-primary">Tipologia</p>
|
|
||||||
<p className="font-bold text-xl">Affitto {data.tipo}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{data.prezzo && (
|
|
||||||
<div className="rounded bg-gray-50 p-3">
|
|
||||||
<p className="font-medium text-primary">Canone Mensile</p>
|
|
||||||
<p className="font-bold text-xl">
|
|
||||||
{formatCurrency(data.prezzo / 100)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(data.disponibile_da || data.consegna) && (
|
|
||||||
<div className="rounded bg-gray-50 p-3">
|
|
||||||
<p className="font-medium text-primary">Disponibile da</p>
|
|
||||||
<p className="font-bold text-xl">{disponibileDate}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Additional Features */}
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="border-b pb-1 font-semibold text-lg">
|
|
||||||
Dotazioni e Servizi
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-4 gap-2 text-sm">
|
|
||||||
{data.caratteristiche &&
|
|
||||||
filteredCaratteristiche(data.caratteristiche).map((item) => {
|
|
||||||
if (!item.value) return null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-start gap-2"
|
|
||||||
key={item.text}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<IconMatrix type={item.icon} />
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{item.text}: {item.value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
{data.desc_it && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="border-b pb-1 font-semibold text-lg">
|
|
||||||
Descrizione
|
|
||||||
</h3>
|
|
||||||
<div className="text-justify text-sm leading-relaxed">
|
|
||||||
<p
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: replaceWithBr(data.desc_it),
|
|
||||||
}}
|
|
||||||
></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="mx-auto max-w-2xl pt-2 text-center text-muted-foreground text-xs">
|
|
||||||
<p>
|
|
||||||
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
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="pt-2 text-center text-muted-foreground text-xs">
|
|
||||||
<p>{footer}</p>
|
|
||||||
<p className="mt-1">Documento generato in data: {printDate}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -68,7 +68,7 @@ export const AnnuncioCard = ({ className }: { className?: string }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AnnuncioCardContent = ({
|
const AnnuncioCardContent = ({
|
||||||
interactions,
|
interactions,
|
||||||
className,
|
className,
|
||||||
data,
|
data,
|
||||||
|
|
|
||||||
|
|
@ -696,7 +696,7 @@ export const WhatsAppIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
/*
|
||||||
export const GoogleMapsIcon = (props: SVGProps<SVGSVGElement>) => (
|
export const GoogleMapsIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||||
<svg
|
<svg
|
||||||
height="1em"
|
height="1em"
|
||||||
|
|
@ -728,7 +728,7 @@ export const GoogleMapsIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
*/
|
||||||
export const PasswordSVG = (props: SVGProps<SVGSVGElement>) => (
|
export const PasswordSVG = (props: SVGProps<SVGSVGElement>) => (
|
||||||
<svg
|
<svg
|
||||||
height={800}
|
height={800}
|
||||||
|
|
|
||||||
|
|
@ -541,11 +541,27 @@ export const RichiesteTable = ({ data }: RichiesteTableProps) => {
|
||||||
{
|
{
|
||||||
accessorKey: "ordini",
|
accessorKey: "ordini",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
|
const paidOrders = row.original.ordini_servizio.filter(
|
||||||
|
(o) => o.isActive,
|
||||||
|
).length;
|
||||||
|
const unpaidOrders = row.original.ordini_servizio.length - paidOrders;
|
||||||
return (
|
return (
|
||||||
<OrdersModal isAdmin={true} ordini={row.original.ordini_servizio}>
|
<OrdersModal isAdmin={true} ordini={row.original.ordini_servizio}>
|
||||||
<Button className="flex-1" size="sm" variant="outline">
|
<div className="relative inline-flex">
|
||||||
<Logs />
|
<Button className="flex-1" size="sm" variant="outline">
|
||||||
</Button>
|
<Logs />
|
||||||
|
</Button>
|
||||||
|
{unpaidOrders > 0 && (
|
||||||
|
<span className="absolute -top-1.5 -right-1.5 flex size-3.5 items-center justify-center rounded-full bg-red-600 text-white text-xs">
|
||||||
|
{unpaidOrders}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{paidOrders > 0 && (
|
||||||
|
<span className="absolute -top-1.5 -left-1.5 flex size-3.5 items-center justify-center rounded-full bg-green-600 text-white text-xs">
|
||||||
|
{paidOrders}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</OrdersModal>
|
</OrdersModal>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ const Schema = z.object({
|
||||||
gestionale_id: z.string().nullable(),
|
gestionale_id: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type FormValues = z.infer<typeof Schema>;
|
type FormValues = z.infer<typeof Schema>;
|
||||||
|
|
||||||
export const FormRinnovo = ({
|
export const FormRinnovo = ({
|
||||||
initialData,
|
initialData,
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,6 @@
|
||||||
import type { IconType } from "~/components/IconComponents";
|
import type { IconType } from "~/components/IconComponents";
|
||||||
import type { Caratteristiche } from "~/utils/kanel-types";
|
import type { Caratteristiche } from "~/utils/kanel-types";
|
||||||
|
|
||||||
export type CaratteristicheFiltered = ReturnType<
|
|
||||||
typeof filteredCaratteristiche
|
|
||||||
>;
|
|
||||||
export const filteredCaratteristiche = (c: Caratteristiche) => {
|
export const filteredCaratteristiche = (c: Caratteristiche) => {
|
||||||
const filtered = [
|
const filtered = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -663,6 +663,10 @@ export const en: LangDict = {
|
||||||
{ href: "/area-riservata/admin/incroci", title: "Incroci" },
|
{ href: "/area-riservata/admin/incroci", title: "Incroci" },
|
||||||
{ href: "/area-riservata/admin/annunci", title: "Ads" },
|
{ href: "/area-riservata/admin/annunci", title: "Ads" },
|
||||||
{ href: "/area-riservata/admin/prezziario", title: "Pricing" },
|
{ href: "/area-riservata/admin/prezziario", title: "Pricing" },
|
||||||
|
{
|
||||||
|
href: "/area-riservata/admin/stripe-reports",
|
||||||
|
title: "Account Statements",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
title: "Management",
|
title: "Management",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -668,6 +668,10 @@ export const it: LangDict = {
|
||||||
{ href: "/area-riservata/admin/incroci", title: "Incroci" },
|
{ href: "/area-riservata/admin/incroci", title: "Incroci" },
|
||||||
{ href: "/area-riservata/admin/annunci", title: "Annunci" },
|
{ href: "/area-riservata/admin/annunci", title: "Annunci" },
|
||||||
{ href: "/area-riservata/admin/prezziario", title: "Prezziario" },
|
{ href: "/area-riservata/admin/prezziario", title: "Prezziario" },
|
||||||
|
{
|
||||||
|
href: "/area-riservata/admin/stripe-reports",
|
||||||
|
title: "Estratti conto",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
title: "Gestione",
|
title: "Gestione",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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<string, string> = {
|
||||||
|
"balance_change_from_activity.itemized": "Movimenti",
|
||||||
|
"payouts.itemized": "Pagamenti",
|
||||||
|
"balance.summary": "Riepilogo Saldo",
|
||||||
|
};
|
||||||
|
|
||||||
|
const reportStatusTitles: Record<string, string> = {
|
||||||
|
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 <LoadingPage />;
|
||||||
|
if (!data) return <Status500 />;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Stripe Reports</title>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<div className="mx-1 flex-1 overflow-auto">
|
||||||
|
<div className="mx-auto space-y-8 pt-4">
|
||||||
|
<div className="mx-3 flex-wrap items-center justify-between sm:flex">
|
||||||
|
<div className="flex max-w-lg items-center gap-3">
|
||||||
|
<h3 className="font-bold text-accent-foreground text-xl sm:text-2xl">
|
||||||
|
Estratti Conto Stripe
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={async () => await refetch()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RefreshCcw className="size-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mx-4 flex flex-col">
|
||||||
|
<h3 className="font-semibold text-lg">Estratti Conto</h3>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="grid grid-cols-2">
|
||||||
|
<TableHead>Intervallo</TableHead>
|
||||||
|
<TableHead>Stato</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{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 (
|
||||||
|
<TableRow className="grid grid-cols-2" key={m.getTime()}>
|
||||||
|
<TableCell>
|
||||||
|
{format(m, "MMMM yyyy", { locale: it })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{!hasReportValid ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => setupEstrattoConto({ start: m })}
|
||||||
|
size="sm"
|
||||||
|
variant={hasReport ? "destructive" : "default"}
|
||||||
|
>
|
||||||
|
{hasReport ? "Scaduto - Rigenera" : "Genera Report"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
hasValidBudgetReport.result?.url &&
|
||||||
|
hasValidSummaryReport.result?.url
|
||||||
|
) {
|
||||||
|
generateEstrattoConto({
|
||||||
|
movimentiUrl:
|
||||||
|
hasValidBudgetReport.result.url,
|
||||||
|
saldoUrl: hasValidSummaryReport.result.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
variant="success"
|
||||||
|
>
|
||||||
|
Disponibile - Apri Estratto Conto
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setupEstrattoConto({ start: m })}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Rigenera Stripe Report
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<div className="mx-4 flex flex-col">
|
||||||
|
<h3 className="font-semibold text-lg">Report Generati da Stripe</h3>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Creato il</TableHead>
|
||||||
|
<TableHead>Tipo</TableHead>
|
||||||
|
<TableHead>Intervallo</TableHead>
|
||||||
|
<TableHead>Stato</TableHead>
|
||||||
|
<TableHead>Scadenza</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{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 (
|
||||||
|
<TableRow key={report.id}>
|
||||||
|
<TableCell>{format(createdAt, "dd/MM/yyyy")}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{reportTypeTitles[reportType] || "Report Sconosciuto"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{intervalStart && intervalEnd
|
||||||
|
? `${format(intervalStart, "dd/MM/yyyy")} - ${format(intervalEnd, "dd/MM/yyyy")}`
|
||||||
|
: "N/A"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{reportStatusTitles[report.status] ||
|
||||||
|
"Stato Sconosciuto"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{expiresAt ? format(expiresAt, "dd/MM/yyyy") : "N/A"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
@ -1,48 +1,229 @@
|
||||||
|
import { Dot } from "lucide-react";
|
||||||
import type { GetServerSideProps } from "next";
|
import type { GetServerSideProps } from "next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
import { AreaRiservataLayout } from "~/components/Layout";
|
import { AreaRiservataLayout } from "~/components/Layout";
|
||||||
import {
|
import { LoadingPage } from "~/components/loading";
|
||||||
SchedaAnnuncio,
|
import { Status500 } from "~/components/status-page";
|
||||||
SchedaAnnuncioStampabile,
|
import { LogoSvg, WhatsAppIcon } from "~/components/svgs";
|
||||||
} from "~/components/schedaAnnuncioStampabile";
|
import { Button } from "~/components/ui/button";
|
||||||
import { redirectTo500 } from "~/lib/utils";
|
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 { 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 { generateSSGHelper } from "~/server/utils/ssgHelper";
|
||||||
import { zAnnuncioId } from "~/server/utils/zod_types";
|
import { zAnnuncioId } from "~/server/utils/zod_types";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
raw: boolean;
|
id: AnnunciId;
|
||||||
data: StringifiedAnnunciWithMedia;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pagina della scheda annuncio stampabile: /area-riservata/scheda-annuncio-stampa/[id]?raw=true
|
* Pagina della scheda annuncio stampabile: /area-riservata/scheda-annuncio-stampa/[id]?raw=true
|
||||||
*/
|
*/
|
||||||
const SchedaAnnuncioPage: NextPageWithLayout<PageProps> = ({
|
const SchedaAnnuncioPage: NextPageWithLayout<PageProps> = ({
|
||||||
raw,
|
id,
|
||||||
data,
|
|
||||||
}: PageProps) => {
|
}: PageProps) => {
|
||||||
const annuncioData = StringifiedToAnnuncio(data);
|
const { data, isLoading } = api.annunci.getSchedaAnnuncioData.useQuery({
|
||||||
if (raw) {
|
id,
|
||||||
return <SchedaAnnuncio data={annuncioData} raw />;
|
});
|
||||||
|
|
||||||
|
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 <LoadingPage />;
|
||||||
}
|
}
|
||||||
|
if (!data) {
|
||||||
|
return <Status500 />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Scheda Annuncio {annuncioData.codice}</title>
|
<title>Scheda Annuncio {data.codice}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<div className="mx-auto w-full p-4">
|
<div className="mx-auto w-full max-w-4xl p-4">
|
||||||
<SchedaAnnuncioStampabile data={annuncioData} />
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<h1 className="font-bold text-2xl">Scheda Annuncio Stampabile</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={() => generatePdf({ id })}>
|
||||||
|
Scarica Scheda
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<LogoSvg className="h-10 w-auto" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<p className="text-right font-semibold text-lg">
|
||||||
|
Arcenia Srl
|
||||||
|
</p>
|
||||||
|
<p className="whitespace-pre-line text-right text-sm">
|
||||||
|
{companyAddress}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<h2 className="font-bold text-primary text-xl">
|
||||||
|
{data.titolo_it || "Immobile in Affitto"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-muted-foreground">
|
||||||
|
Riferimento: <strong>{data.codice}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-primary/5 p-4 outline-2 outline-green-500">
|
||||||
|
<h3 className="mb-2 inline-flex items-center gap-2 font-semibold text-lg text-primary">
|
||||||
|
<WhatsAppIcon className="size-6 fill-green-500 stroke-green-500" />{" "}
|
||||||
|
Contatti
|
||||||
|
<span className="text-base">
|
||||||
|
(mandare un messaggio WhatsApp prima di chiamare)
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p>{data.nominativo}</p>
|
||||||
|
{data.numero && (
|
||||||
|
<p className="font-semibold">Tel. {data.numero}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 pb-1">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="border-b pb-1 font-semibold text-lg">
|
||||||
|
Indirizzo
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<p className="font-semibold">{data.indirizzo}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||||
|
{data.tipo && (
|
||||||
|
<div className="rounded bg-gray-100 p-3">
|
||||||
|
<p className="font-medium text-primary">Tipologia</p>
|
||||||
|
<p className="font-bold text-xl">Affitto {data.tipo}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.prezzo && (
|
||||||
|
<div className="rounded bg-gray-100 p-3">
|
||||||
|
<p className="font-medium text-primary">Canone Mensile</p>
|
||||||
|
<p className="font-bold text-xl">
|
||||||
|
{formatCurrency(data.prezzo / 100)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded bg-gray-100 p-3">
|
||||||
|
<p className="font-medium text-primary">Disponibile da</p>
|
||||||
|
<p className="font-bold text-xl">{data.disponibile_da}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="border-b pb-1 font-semibold text-lg">
|
||||||
|
Dotazioni e Servizi
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||||
|
{data.caratteristiche.map((item) => {
|
||||||
|
if (!item[1]) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-start gap-1"
|
||||||
|
key={item[0]}
|
||||||
|
>
|
||||||
|
<Dot className="size-5" />
|
||||||
|
<span className="font-semibold">{item[0]}:</span>
|
||||||
|
<span>{item[1]}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{data.desc_it && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="border-b pb-1 font-semibold text-lg">
|
||||||
|
Descrizione
|
||||||
|
</h3>
|
||||||
|
<div className="text-justify text-sm leading-relaxed">
|
||||||
|
<p
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: replaceWithBr(data.desc_it),
|
||||||
|
}}
|
||||||
|
></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="mx-auto max-w-2xl pt-2 text-center text-muted-foreground text-xs">
|
||||||
|
<p>
|
||||||
|
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
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
SchedaAnnuncioPage.getLayout = function getLayout(page) {
|
SchedaAnnuncioPage.getLayout = function getLayout(page) {
|
||||||
if (page.props.raw) {
|
|
||||||
return <>{page}</>;
|
|
||||||
}
|
|
||||||
return <AreaRiservataLayout noSidebar>{page}</AreaRiservataLayout>;
|
return <AreaRiservataLayout noSidebar>{page}</AreaRiservataLayout>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -50,7 +231,6 @@ export default SchedaAnnuncioPage;
|
||||||
|
|
||||||
export const getServerSideProps = (async (context) => {
|
export const getServerSideProps = (async (context) => {
|
||||||
const id = context.params?.id;
|
const id = context.params?.id;
|
||||||
const raw = context.query?.raw;
|
|
||||||
|
|
||||||
if (typeof id !== "string") {
|
if (typeof id !== "string") {
|
||||||
console.error("Error: id is not a string");
|
console.error("Error: id is not a string");
|
||||||
|
|
@ -65,56 +245,14 @@ export const getServerSideProps = (async (context) => {
|
||||||
|
|
||||||
const helper = generateSSGHelper();
|
const helper = generateSSGHelper();
|
||||||
|
|
||||||
const data = await helper.annunci.getAnnuncioById_rawImgUrls.fetch({
|
await helper.annunci.getSchedaAnnuncioData.prefetch({
|
||||||
id: parsed.data,
|
id: parsed.data,
|
||||||
});
|
});
|
||||||
if (!data) {
|
|
||||||
return {
|
|
||||||
notFound: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
raw: raw === "true",
|
id: parsed.data,
|
||||||
trpcState: helper.dehydrate(),
|
trpcState: helper.dehydrate(),
|
||||||
data: AnnuncioToStringified(data),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}) satisfies GetServerSideProps<PageProps>;
|
}) satisfies GetServerSideProps<PageProps>;
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ export const getServerSideProps = (async (context) => {
|
||||||
const customer = await stripe.customers.create({
|
const customer = await stripe.customers.create({
|
||||||
email: paymentData.email,
|
email: paymentData.email,
|
||||||
name: paymentData.username,
|
name: paymentData.username,
|
||||||
|
description: paymentData.username,
|
||||||
metadata: {
|
metadata: {
|
||||||
userId: paymentData.userid,
|
userId: paymentData.userid,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import type { GetServerSideProps } from "next";
|
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 { AreaRiservataLayout } from "~/components/Layout";
|
||||||
import { LoadingPage } from "~/components/loading";
|
import { LoadingPage } from "~/components/loading";
|
||||||
import { Status500 } from "~/components/status-page";
|
import { Status500 } from "~/components/status-page";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
import { redirectTo500 } from "~/lib/utils";
|
import { redirectTo500 } from "~/lib/utils";
|
||||||
import type { NextPageWithLayout } from "~/pages/_app";
|
import type { NextPageWithLayout } from "~/pages/_app";
|
||||||
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
|
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 { zOrdineId } from "~/server/utils/zod_types";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
|
const PDFViewer = dynamic(
|
||||||
|
() => import("~/components/pdf-viewer").then((mod) => mod.PDFViewer),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <LoadingPage />,
|
||||||
|
},
|
||||||
|
);
|
||||||
type PagamentoRecapProps = {
|
type PagamentoRecapProps = {
|
||||||
raw: boolean;
|
|
||||||
ordineId: OrdiniOrdineId;
|
ordineId: OrdiniOrdineId;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -19,22 +27,89 @@ type PagamentoRecapProps = {
|
||||||
* Pagina di ricevuta di cortesia dopo un acquisto: /servizio/ricevuta-cortesia/[ordineId]?raw=true|false
|
* Pagina di ricevuta di cortesia dopo un acquisto: /servizio/ricevuta-cortesia/[ordineId]?raw=true|false
|
||||||
*/
|
*/
|
||||||
const PaymentRecapPage: NextPageWithLayout<PagamentoRecapProps> = ({
|
const PaymentRecapPage: NextPageWithLayout<PagamentoRecapProps> = ({
|
||||||
raw,
|
|
||||||
ordineId,
|
ordineId,
|
||||||
}: PagamentoRecapProps) => {
|
}: PagamentoRecapProps) => {
|
||||||
const { data, isLoading } = api.servizio.getOrdineRicevutaData.useQuery({
|
const { data, isLoading } =
|
||||||
ordineId,
|
api.pagamenti.getRicevutaCortesiaPdfQuery.useQuery({
|
||||||
});
|
ordineId,
|
||||||
|
});
|
||||||
|
const [pdfUrl, setPdfUrl] = useState<string | null>(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) {
|
if (isLoading) {
|
||||||
return <LoadingPage />;
|
return <LoadingPage />;
|
||||||
}
|
}
|
||||||
if (!data) {
|
if (!data || !pdfUrl) {
|
||||||
return <Status500 />;
|
return <Status500 />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full p-4">
|
<div className="mx-auto w-full max-w-4xl p-4">
|
||||||
<ReceiptGenerator data={data} raw={raw} />
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="font-bold text-2xl">Ricevuta di cortesia</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
className="hidden sm:flex"
|
||||||
|
onClick={() => handlePrint(pdfUrl)}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Stampa
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => handleDownload(data.pdf, data.title)}>
|
||||||
|
Scarica
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PDFViewer url={pdfUrl} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -50,7 +125,7 @@ export default PaymentRecapPage;
|
||||||
|
|
||||||
export const getServerSideProps = (async (context) => {
|
export const getServerSideProps = (async (context) => {
|
||||||
const id = context.params?.ordineId;
|
const id = context.params?.ordineId;
|
||||||
const raw = context.query?.raw;
|
|
||||||
if (typeof id !== "string") {
|
if (typeof id !== "string") {
|
||||||
console.error("Error: ordineId is not a string");
|
console.error("Error: ordineId is not a string");
|
||||||
return redirectTo500;
|
return redirectTo500;
|
||||||
|
|
@ -59,18 +134,17 @@ export const getServerSideProps = (async (context) => {
|
||||||
const parsed = zOrdineId.safeParse(id);
|
const parsed = zOrdineId.safeParse(id);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
console.error("Error parsing ordineId", parsed.error);
|
console.error("Error parsing ordineId", parsed.error);
|
||||||
return redirectTo500
|
return redirectTo500;
|
||||||
}
|
}
|
||||||
|
|
||||||
const helper = generateSSGHelper();
|
const helper = generateSSGHelper();
|
||||||
if (helper) {
|
if (helper) {
|
||||||
await helper.servizio.getOrdineRicevutaData.prefetch({
|
await helper.pagamenti.getRicevutaCortesiaPdfQuery.prefetch({
|
||||||
ordineId: parsed.data,
|
ordineId: parsed.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
raw: raw === "true",
|
|
||||||
ordineId: parsed.data,
|
ordineId: parsed.data,
|
||||||
trpcState: helper.dehydrate(),
|
trpcState: helper.dehydrate(),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import {
|
import {
|
||||||
ClientsApi,
|
ClientsApi,
|
||||||
Configuration,
|
Configuration,
|
||||||
InfoApi,
|
|
||||||
} from "@fattureincloud/fattureincloud-ts-sdk";
|
} from "@fattureincloud/fattureincloud-ts-sdk";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
|
|
||||||
|
|
@ -12,7 +11,6 @@ const apiConfig = new Configuration({
|
||||||
export const companyId = Number.parseInt(env.FIC_COMPANY_ID);
|
export const companyId = Number.parseInt(env.FIC_COMPANY_ID);
|
||||||
export const clientsApi = new ClientsApi(apiConfig);
|
export const clientsApi = new ClientsApi(apiConfig);
|
||||||
|
|
||||||
export const infosApi = new InfoApi(apiConfig);
|
|
||||||
/*
|
/*
|
||||||
const FIC_COUNTRIES = [
|
const FIC_COUNTRIES = [
|
||||||
"Italia",
|
"Italia",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { servizioRouter } from "~/server/api/routers/servizio";
|
||||||
import { statsRouter } from "~/server/api/routers/stats";
|
import { statsRouter } from "~/server/api/routers/stats";
|
||||||
import { storageRouter } from "~/server/api/routers/storage";
|
import { storageRouter } from "~/server/api/routers/storage";
|
||||||
import { stripeRouter } from "~/server/api/routers/stripe";
|
import { stripeRouter } from "~/server/api/routers/stripe";
|
||||||
|
import { stripeReportsRouter } from "~/server/api/routers/stripe_reports";
|
||||||
import { testRouter } from "~/server/api/routers/test";
|
import { testRouter } from "~/server/api/routers/test";
|
||||||
import { usersRouter } from "~/server/api/routers/users";
|
import { usersRouter } from "~/server/api/routers/users";
|
||||||
import { createTRPCRouter } from "~/server/api/trpc";
|
import { createTRPCRouter } from "~/server/api/trpc";
|
||||||
|
|
@ -46,6 +47,7 @@ export const appRouter = createTRPCRouter({
|
||||||
stats: statsRouter,
|
stats: statsRouter,
|
||||||
storage: storageRouter,
|
storage: storageRouter,
|
||||||
stripe: stripeRouter,
|
stripe: stripeRouter,
|
||||||
|
stripe_reports: stripeReportsRouter,
|
||||||
test: testRouter,
|
test: testRouter,
|
||||||
users: usersRouter,
|
users: usersRouter,
|
||||||
//sync: syncRouter,
|
//sync: syncRouter,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { TRPCError } from "@trpc/server";
|
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
import type { AnnunciUpdate } from "~/schemas/public/Annunci";
|
import type { AnnunciUpdate } from "~/schemas/public/Annunci";
|
||||||
import {
|
import {
|
||||||
|
|
@ -15,6 +14,7 @@ import {
|
||||||
getAnnunciListHandler,
|
getAnnunciListHandler,
|
||||||
getAnnuncioData,
|
getAnnuncioData,
|
||||||
getAnnunciRicerca,
|
getAnnunciRicerca,
|
||||||
|
getAnnunciSchedaData,
|
||||||
getCodici_AnnunciHandler,
|
getCodici_AnnunciHandler,
|
||||||
getOptions_AnnunciHandler,
|
getOptions_AnnunciHandler,
|
||||||
getProprietarioDataHandler,
|
getProprietarioDataHandler,
|
||||||
|
|
@ -23,12 +23,12 @@ import {
|
||||||
type VideoToRemove,
|
type VideoToRemove,
|
||||||
} from "~/server/controllers/annunci.controller";
|
} from "~/server/controllers/annunci.controller";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { NewMail } from "~/server/services/mailer";
|
|
||||||
import {
|
import {
|
||||||
genPdfSchedaAnnuncio,
|
|
||||||
invalidateCache,
|
invalidateCache,
|
||||||
SCHEDA_ANNUNCIO_CACHE_PREFIX,
|
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";
|
import { zAnnuncioId, zServizioId } from "~/server/utils/zod_types";
|
||||||
|
|
||||||
export const annunciRouter = createTRPCRouter({
|
export const annunciRouter = createTRPCRouter({
|
||||||
|
|
@ -83,6 +83,32 @@ export const annunciRouter = createTRPCRouter({
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
return await getAnnunciById_rawImgUrls({ ...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 () => {
|
getCodici: publicProcedure.query(async () => {
|
||||||
return await getCodici_AnnunciHandler();
|
return await getCodici_AnnunciHandler();
|
||||||
}),
|
}),
|
||||||
|
|
@ -162,20 +188,4 @@ export const annunciRouter = createTRPCRouter({
|
||||||
...input,
|
...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}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -23,15 +23,10 @@ import {
|
||||||
deleteEmail,
|
deleteEmail,
|
||||||
getEmails,
|
getEmails,
|
||||||
} from "~/server/services/email.service";
|
} 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 { CONDIZIONI_DEFAULT } from "~/server/services/prezziario.service";
|
||||||
import { genPdfSchedaAnnuncioBase64 } from "~/server/services/puppeteer.service";
|
import { zEventId, zOrdineId, zUserId } from "~/server/utils/zod_types";
|
||||||
import {
|
|
||||||
zAnnuncioId,
|
|
||||||
zEventId,
|
|
||||||
zOrdineId,
|
|
||||||
zUserId,
|
|
||||||
} from "~/server/utils/zod_types";
|
|
||||||
|
|
||||||
export const comunicazioniRouter = createTRPCRouter({
|
export const comunicazioniRouter = createTRPCRouter({
|
||||||
getEmails: protectedProcedure
|
getEmails: protectedProcedure
|
||||||
|
|
@ -88,65 +83,6 @@ export const comunicazioniRouter = createTRPCRouter({
|
||||||
});
|
});
|
||||||
return { success: true };
|
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
|
sendUtenteInteressatoAdAnnuncioEmail: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,14 @@ import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
|
||||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||||
import {
|
import {
|
||||||
getPagamentoDataForCheckout,
|
getPagamentoDataForCheckout,
|
||||||
|
getRicevutaData,
|
||||||
type PreparedPaymentData,
|
type PreparedPaymentData,
|
||||||
preparePayment,
|
preparePayment,
|
||||||
previewSaldo,
|
previewSaldo,
|
||||||
} from "~/server/controllers/pagamenti.controller";
|
} from "~/server/controllers/pagamenti.controller";
|
||||||
|
|
||||||
import { db } from "~/server/db";
|
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";
|
import { zOrdineId, zRinnovoId, zServizioId } from "~/server/utils/zod_types";
|
||||||
// Create a new ratelimiter, that allows 10 requests per 10 seconds
|
// Create a new ratelimiter, that allows 10 requests per 10 seconds
|
||||||
/*
|
/*
|
||||||
|
|
@ -113,7 +114,29 @@ export const pagamentiRouter = createTRPCRouter({
|
||||||
.input(z.object({ ordineId: zOrdineId }))
|
.input(z.object({ ordineId: zOrdineId }))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { getCodici_AnnunciHandler } from "~/server/controllers/annunci.controlle
|
||||||
import {
|
import {
|
||||||
getCacheStats,
|
getCacheStats,
|
||||||
invalidateAllCaches,
|
invalidateAllCaches,
|
||||||
} from "~/server/services/puppeteer.service";
|
} from "~/server/services/cache.service";
|
||||||
import {
|
import {
|
||||||
revalidate,
|
revalidate,
|
||||||
revalidateMultiple,
|
revalidateMultiple,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica";
|
||||||
import {
|
import {
|
||||||
adminProcedure,
|
adminProcedure,
|
||||||
createTRPCRouter,
|
createTRPCRouter,
|
||||||
overridableProcedure,
|
|
||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
} from "~/server/api/trpc";
|
} from "~/server/api/trpc";
|
||||||
import { getPacksPerServizio } from "~/server/controllers/pagamenti.controller";
|
import { getPacksPerServizio } from "~/server/controllers/pagamenti.controller";
|
||||||
|
|
@ -25,7 +24,6 @@ import {
|
||||||
getAnnunciAvailableToAdd,
|
getAnnunciAvailableToAdd,
|
||||||
getCompatibileAnnunci,
|
getCompatibileAnnunci,
|
||||||
getDataPerAcquisto,
|
getDataPerAcquisto,
|
||||||
getOrdineRicevutaData,
|
|
||||||
getRichieste,
|
getRichieste,
|
||||||
getServiziByUserId,
|
getServiziByUserId,
|
||||||
getServizioDataById,
|
getServizioDataById,
|
||||||
|
|
@ -166,15 +164,6 @@ export const servizioRouter = createTRPCRouter({
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
return await getOrdineById(input.ordineId);
|
return await getOrdineById(input.ordineId);
|
||||||
}),
|
}),
|
||||||
getOrdineRicevutaData: overridableProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
ordineId: zOrdineId,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.query(async ({ input }) => {
|
|
||||||
return await getOrdineRicevutaData({ ordineId: input.ordineId });
|
|
||||||
}),
|
|
||||||
|
|
||||||
getOrdini: protectedProcedure
|
getOrdini: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
|
|
|
||||||
226
apps/infoalloggi/src/server/api/routers/stripe_reports.ts
Normal file
226
apps/infoalloggi/src/server/api/routers/stripe_reports.ts
Normal file
|
|
@ -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;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
@ -3,7 +3,6 @@ import {
|
||||||
protectedApiProcedure,
|
protectedApiProcedure,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
} from "~/server/api/trpc";
|
} from "~/server/api/trpc";
|
||||||
import { TypstRunner, TypstRunner2 } from "~/server/services/typst.service";
|
|
||||||
|
|
||||||
export const testRouter = createTRPCRouter({
|
export const testRouter = createTRPCRouter({
|
||||||
testApi: protectedApiProcedure.query(async () => {
|
testApi: protectedApiProcedure.query(async () => {
|
||||||
|
|
@ -15,9 +14,9 @@ export const testRouter = createTRPCRouter({
|
||||||
console.error("test console.error");
|
console.error("test console.error");
|
||||||
throw new Error("This is a test error");
|
throw new Error("This is a test error");
|
||||||
}),
|
}),
|
||||||
testTypst: publicProcedure.query(async () => {
|
// testTypst: publicProcedure.query(async () => {
|
||||||
return await TypstRunner2();
|
// return await genTesti();
|
||||||
}),
|
// }),
|
||||||
});
|
});
|
||||||
/*
|
/*
|
||||||
PROTECTED API CALL EXAMPLE
|
PROTECTED API CALL EXAMPLE
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
|
import { filteredCaratteristiche } from "~/hooks/schedaAnnuncioUtils";
|
||||||
import type { Result } from "~/lib/result";
|
import type { Result } from "~/lib/result";
|
||||||
import { getStorageUrl } from "~/lib/storage_utils";
|
import { getStorageUrl } from "~/lib/storage_utils";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -11,6 +12,7 @@ import type { ImagesRefs } from "~/schemas/public/ImagesRefs";
|
||||||
import type { ServizioServizioId } from "~/schemas/public/Servizio";
|
import type { ServizioServizioId } from "~/schemas/public/Servizio";
|
||||||
import type { VideosRefs } from "~/schemas/public/VideosRefs";
|
import type { VideosRefs } from "~/schemas/public/VideosRefs";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
|
import type { AnnuncioTemplateData } from "~/server/services/typst.service";
|
||||||
import { withImages, withVideos } from "~/utils/kysely-helper";
|
import { withImages, withVideos } from "~/utils/kysely-helper";
|
||||||
import { revalidate } from "../utils/revalidationHelper";
|
import { revalidate } from "../utils/revalidationHelper";
|
||||||
|
|
||||||
|
|
@ -555,3 +557,75 @@ export const removeAnnuncioMedia = async ({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getAnnunciSchedaData = async (
|
||||||
|
id: AnnunciId,
|
||||||
|
): Promise<AnnuncioTemplateData> => {
|
||||||
|
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}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import type {
|
||||||
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
|
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
|
||||||
import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
|
import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
|
||||||
import type { UsersId } from "~/schemas/public/Users";
|
import type { UsersId } from "~/schemas/public/Users";
|
||||||
|
import { getAnnunciSchedaData } from "~/server/controllers/annunci.controller";
|
||||||
|
import { getRicevutaData } from "~/server/controllers/pagamenti.controller";
|
||||||
import {
|
import {
|
||||||
type MinimalSMSData,
|
type MinimalSMSData,
|
||||||
SendMessage,
|
SendMessage,
|
||||||
|
|
@ -19,10 +21,14 @@ import {
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { NewMail, type NewMailProps } from "~/server/services/mailer";
|
import { NewMail, type NewMailProps } from "~/server/services/mailer";
|
||||||
import {
|
import {
|
||||||
genPdfCondizioniBase64,
|
convertHtmlToTypst,
|
||||||
genPdfRicevutaCortesiaBase64,
|
TypstGenerate,
|
||||||
genPdfSchedaAnnuncioBase64,
|
} from "~/server/services/typst.service";
|
||||||
} from "../services/puppeteer.service";
|
import { getKeydbClient } from "~/utils/keydb";
|
||||||
|
import {
|
||||||
|
CONDIZIONI_CACHE_PREFIX,
|
||||||
|
SCHEDA_ANNUNCIO_CACHE_PREFIX,
|
||||||
|
} from "../services/cache.service";
|
||||||
|
|
||||||
type GeneratedAttachmentTypes =
|
type GeneratedAttachmentTypes =
|
||||||
| { type: "scheda_annuncio"; annuncioId: AnnunciId }
|
| { type: "scheda_annuncio"; annuncioId: AnnunciId }
|
||||||
|
|
@ -41,12 +47,68 @@ export type Email_EventData = {
|
||||||
|
|
||||||
const generatePdfContent = async (params: GeneratedAttachmentTypes) => {
|
const generatePdfContent = async (params: GeneratedAttachmentTypes) => {
|
||||||
switch (params.type) {
|
switch (params.type) {
|
||||||
case "ricevuta_cortesia":
|
case "ricevuta_cortesia": {
|
||||||
return await genPdfRicevutaCortesiaBase64(params.ordineId);
|
const ricData = await getRicevutaData(params.ordineId);
|
||||||
case "condizioni":
|
const pdf = await TypstGenerate({
|
||||||
return await genPdfCondizioniBase64(params.stringId);
|
templateId: "receipt.typ",
|
||||||
case "scheda_annuncio":
|
data: ricData.data,
|
||||||
return await genPdfSchedaAnnuncioBase64(params.annuncioId);
|
});
|
||||||
|
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:
|
default:
|
||||||
throw new Error(`Unknown PDF type: `);
|
throw new Error(`Unknown PDF type: `);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,6 @@ export const getClientFromCF = async (cf: string) => {
|
||||||
return clients.data;
|
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) => {
|
export const exportUserToFIC = async (userId: UsersId) => {
|
||||||
try {
|
try {
|
||||||
const user = await db
|
const user = await db
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { TRPCError } from "@trpc/server";
|
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 OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
|
||||||
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
|
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -9,6 +11,8 @@ import type {
|
||||||
import type { RinnoviId } from "~/schemas/public/Rinnovi";
|
import type { RinnoviId } from "~/schemas/public/Rinnovi";
|
||||||
import type { ServizioServizioId } from "~/schemas/public/Servizio";
|
import type { ServizioServizioId } from "~/schemas/public/Servizio";
|
||||||
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
|
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 { db } from "../db";
|
||||||
import { createOrdine } from "../services/ordini.service";
|
import { createOrdine } from "../services/ordini.service";
|
||||||
import {
|
import {
|
||||||
|
|
@ -615,3 +619,124 @@ export const previewSaldo = async (
|
||||||
|
|
||||||
return saldo;
|
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}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,7 @@ import { TRPCError } from "@trpc/server";
|
||||||
import { add, isBefore } from "date-fns";
|
import { add, isBefore } from "date-fns";
|
||||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
|
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
import type { PurchaseData } from "~/components/acquisto_receipt";
|
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import type { PaymentType } from "~/i18n/stripe";
|
|
||||||
import { parseDBComune, parseDBNazione } from "~/lib/catasto";
|
|
||||||
import { withParsedDates } from "~/lib/dateParserJsonKysely";
|
import { withParsedDates } from "~/lib/dateParserJsonKysely";
|
||||||
import type { Annunci, AnnunciId } from "~/schemas/public/Annunci";
|
import type { Annunci, AnnunciId } from "~/schemas/public/Annunci";
|
||||||
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
|
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
|
||||||
|
|
@ -42,10 +39,8 @@ import {
|
||||||
} from "~/utils/kysely-helper";
|
} from "~/utils/kysely-helper";
|
||||||
import { GetUserInterests } from "../services/interests.service";
|
import { GetUserInterests } from "../services/interests.service";
|
||||||
import { createOrdine } from "../services/ordini.service";
|
import { createOrdine } from "../services/ordini.service";
|
||||||
import { genPdfCondizioniBase64 } from "../services/puppeteer.service";
|
|
||||||
import { getServizioById } from "../services/servizio.service";
|
import { getServizioById } from "../services/servizio.service";
|
||||||
import type { AnnuncioRicerca } from "./annunci.controller";
|
import type { AnnuncioRicerca } from "./annunci.controller";
|
||||||
import { getComuni, getNazioni } from "./catasto.controller";
|
|
||||||
import { SaldoSolver } from "./pagamenti.controller";
|
import { SaldoSolver } from "./pagamenti.controller";
|
||||||
|
|
||||||
export const getServiziByUserId = async (userId: UsersId) => {
|
export const getServiziByUserId = async (userId: UsersId) => {
|
||||||
|
|
@ -243,30 +238,38 @@ export const attivaServizio_SaltaPagamentoAcconto = async (
|
||||||
);
|
);
|
||||||
|
|
||||||
if (user.comms_enabled) {
|
if (user.comms_enabled) {
|
||||||
await NewMail({
|
await addEventToQueue({
|
||||||
template: {
|
data: {
|
||||||
mailType: "servizioAttivato",
|
tipo: "email",
|
||||||
},
|
data: {
|
||||||
mail: {
|
template: {
|
||||||
subject: "Pagamento Confermato - Acconto Infoalloggi.it",
|
mailType: "servizioAttivato",
|
||||||
to: ordine.email,
|
},
|
||||||
attachments: ordine.testo_condizioni
|
mail: {
|
||||||
? [
|
subject: "Pagamento Confermato - Acconto Infoalloggi.it",
|
||||||
{
|
to: ordine.email,
|
||||||
filename: `condizioni_contrattuali_${ordine.created_at.toISOString()}.pdf`,
|
attachments: ordine.testo_condizioni
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
filename: `condizioni_contrattuali_${ordine.created_at.toISOString()}.pdf`,
|
||||||
|
generate: {
|
||||||
|
type: "condizioni",
|
||||||
|
stringId:
|
||||||
|
ordine.testo_condizioni as TestiEStringheStingaId,
|
||||||
|
},
|
||||||
|
|
||||||
content: await genPdfCondizioniBase64(
|
encoding: "base64",
|
||||||
ordine.testo_condizioni as TestiEStringheStingaId,
|
contentType: "application/pdf",
|
||||||
),
|
},
|
||||||
|
]
|
||||||
encoding: "base64",
|
: [],
|
||||||
contentType: "application/pdf",
|
},
|
||||||
},
|
userId: ordine.userId,
|
||||||
]
|
},
|
||||||
: [],
|
|
||||||
},
|
},
|
||||||
userId: ordine.userId,
|
lock_expires: add(new Date(), { seconds: 30 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
await addEventToQueue({
|
await addEventToQueue({
|
||||||
data: {
|
data: {
|
||||||
tipo: "email",
|
tipo: "email",
|
||||||
|
|
@ -1398,101 +1401,6 @@ export const getCompatibileAnnunci = async ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getOrdineRicevutaData = async ({
|
|
||||||
ordineId,
|
|
||||||
}: {
|
|
||||||
ordineId: OrdiniOrdineId;
|
|
||||||
}): Promise<PurchaseData> => {
|
|
||||||
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 &
|
export type RichiesteData = Servizio &
|
||||||
Pick<Users, "username" | "note" | "telefono"> & {
|
Pick<Users, "username" | "note" | "telefono"> & {
|
||||||
annunci_servizio: ServizioAnnuncioData[];
|
annunci_servizio: ServizioAnnuncioData[];
|
||||||
|
|
|
||||||
64
apps/infoalloggi/src/server/services/cache.service.ts
Normal file
64
apps/infoalloggi/src/server/services/cache.service.ts
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -2,7 +2,8 @@ import { TRPCError } from "@trpc/server";
|
||||||
import type { EmailsIdEmail } from "~/schemas/public/Emails";
|
import type { EmailsIdEmail } from "~/schemas/public/Emails";
|
||||||
import type { UsersId } from "~/schemas/public/Users";
|
import type { UsersId } from "~/schemas/public/Users";
|
||||||
import { db } from "~/server/db";
|
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 ({
|
export const storeEmail = async ({
|
||||||
userId,
|
userId,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import {
|
import { AvvisoInterruzioneMailTag } from "emails/avviso-interruzione";
|
||||||
AvvisoInterruzioneMailTag,
|
|
||||||
} from "emails/avviso-interruzione";
|
|
||||||
import {
|
import {
|
||||||
ContattoAdminEmailPropsSchema,
|
ContattoAdminEmailPropsSchema,
|
||||||
ContattoAdminMailTag,
|
ContattoAdminMailTag,
|
||||||
|
|
@ -13,36 +11,23 @@ import {
|
||||||
ContattoInteressatoMailTag,
|
ContattoInteressatoMailTag,
|
||||||
ContattoInteressatoPropsSchema,
|
ContattoInteressatoPropsSchema,
|
||||||
} from "emails/email-interessato";
|
} from "emails/email-interessato";
|
||||||
import {
|
import { GenericMailTag, GenericPropsSchema } from "emails/gereric-email";
|
||||||
GenericMailTag,
|
|
||||||
GenericPropsSchema,
|
|
||||||
} from "emails/gereric-email";
|
|
||||||
import { InterruzioneMailTag } from "emails/interruzione";
|
import { InterruzioneMailTag } from "emails/interruzione";
|
||||||
import { InvitoMailTag, InvitoPropsSchema } from "emails/invito";
|
import { InvitoMailTag, InvitoPropsSchema } from "emails/invito";
|
||||||
import {
|
import { PagamentoConfermaMailTag } from "emails/pagamento-conferma";
|
||||||
PagamentoConfermaMailTag,
|
import { PagamentoErroreMailTag } from "emails/pagamento-errore";
|
||||||
} from "emails/pagamento-conferma";
|
import { PagamentoRicezioneMailTag } from "emails/pagamento-ricezione";
|
||||||
import {
|
|
||||||
PagamentoErroreMailTag,
|
|
||||||
} from "emails/pagamento-errore";
|
|
||||||
import {
|
|
||||||
PagamentoRicezioneMailTag,
|
|
||||||
} from "emails/pagamento-ricezione";
|
|
||||||
import {
|
import {
|
||||||
PwResetLinkMailTag,
|
PwResetLinkMailTag,
|
||||||
PwResetLinkPropsSchema,
|
PwResetLinkPropsSchema,
|
||||||
} from "emails/pw-reset-link";
|
} from "emails/pw-reset-link";
|
||||||
import { RecessoMailTag } from "emails/recesso";
|
import { RecessoMailTag } from "emails/recesso";
|
||||||
import {
|
import { RegistrazioneAvvenutaMailTag } from "emails/registrazione-avvenuta";
|
||||||
RegistrazioneAvvenutaMailTag,
|
|
||||||
} from "emails/registrazione-avvenuta";
|
|
||||||
import {
|
import {
|
||||||
SchedaContattoMailTag,
|
SchedaContattoMailTag,
|
||||||
SchedaContattoPropsSchema,
|
SchedaContattoPropsSchema,
|
||||||
} from "emails/scheda-annuncio";
|
} from "emails/scheda-annuncio";
|
||||||
import {
|
import { ServizioAttivatoMailTag } from "emails/servizio-attivato";
|
||||||
ServizioAttivatoMailTag,
|
|
||||||
} from "emails/servizio-attivato";
|
|
||||||
import {
|
import {
|
||||||
VerificaEmailMailTag,
|
VerificaEmailMailTag,
|
||||||
VerificaEmailPropsSchema,
|
VerificaEmailPropsSchema,
|
||||||
|
|
@ -93,9 +78,9 @@ export const TemplateKeys = Object.keys(Templates) as Array<
|
||||||
keyof typeof Templates
|
keyof typeof Templates
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export const TemplateSchemas = Object.entries(Templates)
|
// const TemplateSchemas = Object.entries(Templates)
|
||||||
.map(([_, value]) => value.props)
|
// .map(([_, value]) => value.props)
|
||||||
.filter((schema) => schema !== null);
|
// .filter((schema) => schema !== null);
|
||||||
|
|
||||||
export type TemplateType = keyof typeof Templates;
|
export type TemplateType = keyof typeof Templates;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,6 @@ import { GetFlagValueHandler } from "~/server/controllers/flags.controller";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { storeEmail } from "~/server/services/email.service";
|
import { storeEmail } from "~/server/services/email.service";
|
||||||
|
|
||||||
export {
|
|
||||||
type MailsTemplates,
|
|
||||||
TemplateKeys,
|
|
||||||
TemplateSchemas,
|
|
||||||
Templates,
|
|
||||||
type TemplateType,
|
|
||||||
} from "~/server/services/mail-templates";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type MailsTemplates,
|
type MailsTemplates,
|
||||||
Templates,
|
Templates,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -3,9 +3,7 @@ import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
|
import type { EstrattoContoData } from "~/server/api/routers/stripe_reports";
|
||||||
import { db } from "~/server/db";
|
|
||||||
import { getStringa } from "~/server/services/testi_stringhe.service";
|
|
||||||
|
|
||||||
const execPromise = promisify(exec);
|
const execPromise = promisify(exec);
|
||||||
const projectRoot = path.resolve(".");
|
const projectRoot = path.resolve(".");
|
||||||
|
|
@ -13,140 +11,11 @@ const fontsPath = path.join(projectRoot, "typst", "fonts");
|
||||||
|
|
||||||
const tmpDir = path.join(projectRoot, "tmp");
|
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.
|
* Converts HTML to Typst markup using Pandoc.
|
||||||
* works on Windows & Linux.
|
* works on Windows & Linux.
|
||||||
*/
|
*/
|
||||||
function convertHtmlToTypst(html: string): string {
|
export function convertHtmlToTypst(html: string): string {
|
||||||
try {
|
try {
|
||||||
// We pass the HTML via 'input' which writes to the process stdin
|
// We pass the HTML via 'input' which writes to the process stdin
|
||||||
const output = execSync(
|
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(() => {}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
/* Path Aliases */
|
/* Path Aliases */
|
||||||
|
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"checkJs": true,
|
"checkJs": true,
|
||||||
/* Base Options: */
|
/* Base Options: */
|
||||||
|
|
@ -51,7 +52,8 @@
|
||||||
"emails",
|
"emails",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
"tests",
|
"tests",
|
||||||
"flow-gen.ts"
|
"flow-gen.ts",
|
||||||
|
"check-unused-trpc.ts"
|
||||||
],
|
],
|
||||||
"ts-node": {
|
"ts-node": {
|
||||||
// these options are overrides used only by ts-node
|
// these options are overrides used only by ts-node
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,35 @@
|
||||||
#import "@preview/iconic-salmon-fa:1.1.0": fa-location-pin, fa-whatsapp
|
#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(
|
#set page(
|
||||||
paper: "a4",
|
paper: "a4",
|
||||||
margin: (bottom: 1.5cm, rest: 1cm),
|
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"))
|
#set text(font: "Inter 18pt", size: 10pt, lang: "it", fill: rgb("#1f1f1f"))
|
||||||
|
|
@ -28,112 +48,90 @@
|
||||||
Email: arca\@infoalloggi.it
|
Email: arca\@infoalloggi.it
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
#v(1em)
|
#v(0.5em)
|
||||||
#line(length: 100%, stroke: 0.5pt + luma(200))
|
|
||||||
#v(1em)
|
|
||||||
|
|
||||||
// --- TITLE & REFERENCE ---
|
|
||||||
#align(center)[#text(
|
#align(center)[#text(
|
||||||
size: 16pt,
|
size: 14pt,
|
||||||
weight: "bold",
|
weight: "semibold",
|
||||||
fill: rgb("#63493f"),
|
fill: rgb("#63493f"),
|
||||||
)[BASSANO S. VITO MONOLOCALE A USO TRANSITORIO - 01/06/26]]
|
)[#data.titolo_it]]
|
||||||
#v(0.5em)
|
#v(0.5em)
|
||||||
|
|
||||||
#align(center)[#text(
|
#align(center)[#text(
|
||||||
size: 14pt,
|
size: 13pt,
|
||||||
)[Riferimento: *3786B*]]
|
)[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(
|
#grid(
|
||||||
columns: 1fr,
|
columns: 1fr,
|
||||||
gutter: 14pt,
|
gutter: 14pt,
|
||||||
[#fa-whatsapp(size: 20pt, fill: rgb("#25d366"))#h(1em)#text(
|
[#fa-whatsapp(size: 16pt, fill: rgb("#25d366"))#h(1em)#text(
|
||||||
size: 16pt,
|
size: 14pt,
|
||||||
weight: "bold",
|
weight: "semibold",
|
||||||
fill: rgb("#63493f"),
|
fill: rgb("#63493f"),
|
||||||
)[Contatti]#h(.5em)#text(size: 12pt, fill: rgb("#63493f"))[(mandare un messaggio WhatsApp prima di chiamare)]],
|
)[Contatti]#h(.5em)#text(size: 12pt, fill: rgb("#63493f"))[(mandare un messaggio WhatsApp prima di chiamare)]],
|
||||||
[INFOALLOGGI (delegato)],
|
[#data.nominativo],
|
||||||
[*Tel. +393453944827*]
|
[*Tel. #data.numero*]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
#v(1.5em)
|
#v(.5em)
|
||||||
#grid(
|
#text(weight: "semibold")[Indirizzo]
|
||||||
columns: 1fr,
|
#line(length: 100%, stroke: 0.5pt + luma(200))
|
||||||
gutter: 10pt, [
|
#text(weight: "semibold")[#data.indirizzo]#h(3em)#link(
|
||||||
#text(size: 10pt, weight: "bold")[Indirizzo]], [
|
strfmt(
|
||||||
#line(length: 100%, stroke: 0.5pt + luma(200))], [
|
"https://www.google.com/maps/dir/?api=1&travelmode=driving&layer=traffic&destination={},{}",
|
||||||
#text(weight: "bold", size: 12pt)[VIA MONTE ASOLONE, 21 int 1 Bassano del Grappa 36061 (VI)]#h(3em)#link(
|
data.lat,
|
||||||
"https://maps.google.com",
|
data.lon,
|
||||||
)[#fa-location-pin(size: 20pt, fill: red)#text(fill: blue)[Apri in Google Maps]]
|
),
|
||||||
]
|
)[#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 ---
|
// --- KEY DETAILS TABLE ---
|
||||||
#table(
|
#table(
|
||||||
columns: (1fr, 1fr, 1fr),
|
columns: (1fr, 1fr, 1fr),
|
||||||
stroke: 0.5pt + luma(200),
|
stroke: none,
|
||||||
inset: 10pt,
|
//inset: 10pt,
|
||||||
[*Tipologia* \ Affitto Transitorio], [*Canone Mensile* \ 540,00 €], [*Disponibile da* \ 31/05/2026],
|
[#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 ---
|
// --- FEATURES ---
|
||||||
#text(size: 12pt, weight: "bold")[Dotazioni e Servizi]
|
#text(size: 12pt, weight: "semibold")[Dotazioni e Servizi]
|
||||||
#v(0.5em)
|
#v(0.5em)
|
||||||
#grid(
|
#grid(
|
||||||
columns: (1fr, 1fr, 1fr),
|
columns: (1fr, 1fr, 1fr),
|
||||||
gutter: 10pt,
|
gutter: 14pt,
|
||||||
[- *Tipo Impianto:* Radiatori], [- *Riscaldamento:* Autonomo], [- *Arredi:* Arredato],
|
|
||||||
[- *Piani Edificio:* Uno], [- *Internet:* No],
|
..data.caratteristiche.map(pair => [- *#pair.at(0)*: #pair.at(1)])
|
||||||
)
|
)
|
||||||
#v(1.5em)
|
#v(1.5em)
|
||||||
|
|
||||||
// --- DESCRIPTION ---
|
// --- DESCRIPTION ---
|
||||||
#text(size: 12pt, weight: "bold")[Descrizione]
|
#text(size: 12pt, weight: "semibold")[Descrizione]
|
||||||
#v(0.5em)
|
#line(length: 100%, stroke: 0.5pt + luma(200))
|
||||||
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.
|
#text(size: 9pt)[#data.desc_it]
|
||||||
|
|
||||||
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.
|
|
||||||
#v(1.5em)
|
#v(1.5em)
|
||||||
|
|
||||||
// --- OWNER REQUESTS ---
|
#text(size: 8pt, fill: rgb("#636363"))[
|
||||||
#rect(fill: rgb("#f5f5f5"), width: 100%, inset: 15pt, stroke: none)[
|
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
|
||||||
#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],
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
#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.
|
|
||||||
|
|
|
||||||
122
apps/infoalloggi/typst/estratto.typ
Normal file
122
apps/infoalloggi/typst/estratto.typ
Normal file
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
#import "utils.typ": format-price
|
#import "utils.typ": format-eur
|
||||||
|
|
||||||
#let data_file = sys.inputs.at("data_path", default: "none")
|
#let data_file = sys.inputs.at("data_path", default: "none")
|
||||||
|
|
||||||
|
|
@ -77,7 +77,7 @@
|
||||||
],
|
],
|
||||||
align(right)[
|
align(right)[
|
||||||
#text(fill: luma(120), size: 9pt)[Data] \
|
#text(fill: luma(120), size: 9pt)[Data] \
|
||||||
#data.date \
|
#text(size: 12pt)[#data.date] \
|
||||||
#data.datetime
|
#data.datetime
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
@ -111,11 +111,15 @@
|
||||||
.map(item => {
|
.map(item => {
|
||||||
(
|
(
|
||||||
item.title + if item.desc != none { "\n" + text(size: 9pt, fill: luma(100))[#item.desc] } else { "" },
|
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) {
|
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 {
|
} else {
|
||||||
[#format-price(item.amount)]
|
[#format-eur(item.amount)]
|
||||||
},
|
},
|
||||||
table.hline(stroke: 0.5pt + luma(200)),
|
table.hline(stroke: 0.5pt + luma(200)),
|
||||||
)
|
)
|
||||||
|
|
@ -130,8 +134,8 @@
|
||||||
columns: (1fr, auto),
|
columns: (1fr, auto),
|
||||||
align: (right, right),
|
align: (right, right),
|
||||||
gutter: 1.5em,
|
gutter: 1.5em,
|
||||||
[*Totale:*], [*#format-price(data.total)*],
|
[*Totale:*], [*#format-eur(data.total)*],
|
||||||
[#text(fill: luma(100))[di cui IVA (22%):]], [#text(fill: luma(100))[#format-price(data.vat)]],
|
[#text(fill: luma(100))[di cui IVA (22%):]], [#text(fill: luma(100))[#format-eur(data.vat)]],
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,38 @@
|
||||||
#import "@preview/oxifmt:1.0.0": strfmt
|
#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 format-eur(cents) = {
|
||||||
let decimal-str = if decimal < 10 { "0" + str(decimal) } else { str(decimal) }
|
// 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 €]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue