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:
|
||||
- Log email fallite e retry
|
||||
- piu info negli annunci_servizio sheet
|
||||
- override magico per saltare inserimento strighe caparra e contratto
|
||||
|
||||
AFTER MVP:
|
||||
- TODO migrazione app router https://nextjs.org/docs/pages/building-your-application/upgrading/app-router-migration#migrating-from-pages-to-app
|
||||
|
|
|
|||
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",
|
||||
"analyze": "cross-env ANALYZE=true next build",
|
||||
"vitest": "vitest run",
|
||||
"flow": "ts-node flow-gen.ts"
|
||||
"flow": "ts-node flow-gen.ts",
|
||||
"unused-trpc": "ts-node check-unused-trpc.ts"
|
||||
},
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
|
|
@ -76,10 +77,10 @@
|
|||
"nodemailer": "^8.0.1",
|
||||
"nodemailer-html-to-text": "^3.2.0",
|
||||
"nuqs": "^2.8.6",
|
||||
"papaparse": "^5.5.3",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg": "^8.19.0",
|
||||
"postcss": "^8.5.6",
|
||||
"puppeteer-core": "^24.37.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.3",
|
||||
"react-colorful": "^5.6.1",
|
||||
|
|
@ -91,7 +92,7 @@
|
|||
"react-is": "^19.2.3",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-number-format": "^5.4.4",
|
||||
"react-pdf": "^10.2.0",
|
||||
"react-pdf": "^10.4.0",
|
||||
"react-phone-number-input": "^3.4.14",
|
||||
"react-reverse-portal": "^2.3.0",
|
||||
"react-select": "^5.10.2",
|
||||
|
|
@ -118,6 +119,7 @@
|
|||
"@types/node": "^24.2.0",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/nodemailer-html-to-text": "^3.1.3",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/react": "^19.2.9",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<CredenzaBody className="max-h-[80vh] space-y-2 overflow-auto pb-5">
|
||||
<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 && (
|
||||
<div>
|
||||
<Link
|
||||
|
|
@ -148,6 +148,44 @@ export const UserOrdiniModal = ({
|
|||
const { data } = api.servizio.getOrdineById.useQuery({
|
||||
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;
|
||||
|
||||
return (
|
||||
|
|
@ -191,14 +229,12 @@ export const UserOrdiniModal = ({
|
|||
</p>
|
||||
{data.isActive && (
|
||||
<div>
|
||||
<Link
|
||||
href={`/servizio/ricevuta-cortesia/${data.ordine_id}`}
|
||||
target="_blank"
|
||||
<Button
|
||||
onClick={() => generatePdf({ ordineId: data.ordine_id })}
|
||||
variant="outline"
|
||||
>
|
||||
<Button variant="outline">
|
||||
Ricevuta di cortesia <ReceiptText />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CredenzaBody>
|
||||
|
|
|
|||
|
|
@ -1,30 +1,9 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import "react-pdf/dist/Page/AnnotationLayer.css";
|
||||
import "react-pdf/dist/Page/TextLayer.css";
|
||||
|
||||
import type { PDFDocumentProxy } from "pdfjs-dist";
|
||||
import { Document, Page, pdfjs } from "react-pdf";
|
||||
import { LoadingPage } from "./loading";
|
||||
|
||||
import "pdfjs-dist/build/pdf.worker.min.mjs";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const Document = dynamic(
|
||||
() => import("react-pdf").then((mod) => ({ default: mod.Document })),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const Page = dynamic(
|
||||
() => import("react-pdf").then((mod) => ({ default: mod.Page })),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
//pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
|
||||
|
||||
// pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
// "pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
// import.meta.url,
|
||||
// ).toString();
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
|
||||
|
||||
const options = {
|
||||
cMapUrl: "/cmaps/",
|
||||
|
|
@ -37,7 +16,6 @@ export const PDFViewer = ({ url }: { url: string }) => {
|
|||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
const file = useMemo(() => ({ url }), [url]);
|
||||
|
||||
const onResize = useCallback<ResizeObserverCallback>((entries) => {
|
||||
const [entry] = entries;
|
||||
|
||||
|
|
@ -55,12 +33,6 @@ export const PDFViewer = ({ url }: { url: string }) => {
|
|||
}
|
||||
}, []);
|
||||
|
||||
function onDocumentLoadSuccess({
|
||||
numPages: nextNumPages,
|
||||
}: PDFDocumentProxy): void {
|
||||
setNumPages(nextNumPages);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
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={
|
||||
"Errore nel caricare il documento, riprova più tardi o Scaricalo"
|
||||
}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
onLoadSuccess={({ numPages }) => setNumPages(numPages)}
|
||||
options={options}
|
||||
>
|
||||
{Array.from(new Array(numPages), (_el, index) => (
|
||||
|
|
@ -83,6 +55,8 @@ export const PDFViewer = ({ url }: { url: string }) => {
|
|||
index + 1
|
||||
}`}
|
||||
pageNumber={index + 1}
|
||||
renderAnnotationLayer={false}
|
||||
renderTextLayer={false}
|
||||
width={width}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
className,
|
||||
data,
|
||||
|
|
|
|||
|
|
@ -696,7 +696,7 @@ export const WhatsAppIcon = (props: SVGProps<SVGSVGElement>) => (
|
|||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/*
|
||||
export const GoogleMapsIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
height="1em"
|
||||
|
|
@ -728,7 +728,7 @@ export const GoogleMapsIcon = (props: SVGProps<SVGSVGElement>) => (
|
|||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
*/
|
||||
export const PasswordSVG = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
height={800}
|
||||
|
|
|
|||
|
|
@ -541,11 +541,27 @@ export const RichiesteTable = ({ data }: RichiesteTableProps) => {
|
|||
{
|
||||
accessorKey: "ordini",
|
||||
cell: ({ row }) => {
|
||||
const paidOrders = row.original.ordini_servizio.filter(
|
||||
(o) => o.isActive,
|
||||
).length;
|
||||
const unpaidOrders = row.original.ordini_servizio.length - paidOrders;
|
||||
return (
|
||||
<OrdersModal isAdmin={true} ordini={row.original.ordini_servizio}>
|
||||
<div className="relative inline-flex">
|
||||
<Button className="flex-1" size="sm" variant="outline">
|
||||
<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>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const Schema = z.object({
|
|||
gestionale_id: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type FormValues = z.infer<typeof Schema>;
|
||||
type FormValues = z.infer<typeof Schema>;
|
||||
|
||||
export const FormRinnovo = ({
|
||||
initialData,
|
||||
|
|
|
|||
|
|
@ -3,9 +3,6 @@
|
|||
import type { IconType } from "~/components/IconComponents";
|
||||
import type { Caratteristiche } from "~/utils/kanel-types";
|
||||
|
||||
export type CaratteristicheFiltered = ReturnType<
|
||||
typeof filteredCaratteristiche
|
||||
>;
|
||||
export const filteredCaratteristiche = (c: Caratteristiche) => {
|
||||
const filtered = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -663,6 +663,10 @@ export const en: LangDict = {
|
|||
{ href: "/area-riservata/admin/incroci", title: "Incroci" },
|
||||
{ href: "/area-riservata/admin/annunci", title: "Ads" },
|
||||
{ href: "/area-riservata/admin/prezziario", title: "Pricing" },
|
||||
{
|
||||
href: "/area-riservata/admin/stripe-reports",
|
||||
title: "Account Statements",
|
||||
},
|
||||
],
|
||||
title: "Management",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -668,6 +668,10 @@ export const it: LangDict = {
|
|||
{ href: "/area-riservata/admin/incroci", title: "Incroci" },
|
||||
{ href: "/area-riservata/admin/annunci", title: "Annunci" },
|
||||
{ href: "/area-riservata/admin/prezziario", title: "Prezziario" },
|
||||
{
|
||||
href: "/area-riservata/admin/stripe-reports",
|
||||
title: "Estratti conto",
|
||||
},
|
||||
],
|
||||
title: "Gestione",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 Head from "next/head";
|
||||
import toast from "react-hot-toast";
|
||||
import { AreaRiservataLayout } from "~/components/Layout";
|
||||
import {
|
||||
SchedaAnnuncio,
|
||||
SchedaAnnuncioStampabile,
|
||||
} from "~/components/schedaAnnuncioStampabile";
|
||||
import { redirectTo500 } from "~/lib/utils";
|
||||
import { LoadingPage } from "~/components/loading";
|
||||
import { Status500 } from "~/components/status-page";
|
||||
import { LogoSvg, WhatsAppIcon } from "~/components/svgs";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import { replaceWithBr } from "~/lib/newlineToBr";
|
||||
import { formatCurrency, redirectTo500 } from "~/lib/utils";
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import type { AnnunciWithMedia } from "~/server/controllers/annunci.controller";
|
||||
import type { AnnunciId } from "~/schemas/public/Annunci";
|
||||
import { generateSSGHelper } from "~/server/utils/ssgHelper";
|
||||
import { zAnnuncioId } from "~/server/utils/zod_types";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
type PageProps = {
|
||||
raw: boolean;
|
||||
data: StringifiedAnnunciWithMedia;
|
||||
id: AnnunciId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pagina della scheda annuncio stampabile: /area-riservata/scheda-annuncio-stampa/[id]?raw=true
|
||||
*/
|
||||
const SchedaAnnuncioPage: NextPageWithLayout<PageProps> = ({
|
||||
raw,
|
||||
data,
|
||||
id,
|
||||
}: PageProps) => {
|
||||
const annuncioData = StringifiedToAnnuncio(data);
|
||||
if (raw) {
|
||||
return <SchedaAnnuncio data={annuncioData} raw />;
|
||||
const { data, isLoading } = api.annunci.getSchedaAnnuncioData.useQuery({
|
||||
id,
|
||||
});
|
||||
|
||||
const { mutate: generatePdf } = api.annunci.getSchedaAnnuncioPdf.useMutation({
|
||||
onMutate: () => {
|
||||
toast.loading("Generazione PDF in corso...", {
|
||||
id: "generate-pdf",
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (!data) {
|
||||
toast.error("PDF non disponibile al momento. Riprova più tardi.", {
|
||||
id: "generate-pdf",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.success("PDF generato con successo!", {
|
||||
id: "generate-pdf",
|
||||
});
|
||||
|
||||
const binaryString = atob(data.pdf);
|
||||
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
|
||||
const blob = new Blob([bytes], {
|
||||
type: "application/pdf",
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = data.title;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.dismiss("generate-pdf");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Errore durante la generazione del PDF: ${error.message}`, {
|
||||
id: "generate-pdf",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const companyAddress =
|
||||
"Via Beata Giovanna, 1 a Bassano del Grappa (VI)\nTel. +39 0424529869\nEmail: arca@infoalloggi.it";
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
if (!data) {
|
||||
return <Status500 />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Scheda Annuncio {annuncioData.codice}</title>
|
||||
<title>Scheda Annuncio {data.codice}</title>
|
||||
</Head>
|
||||
<div className="mx-auto w-full p-4">
|
||||
<SchedaAnnuncioStampabile data={annuncioData} />
|
||||
<div className="mx-auto w-full max-w-4xl p-4">
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
SchedaAnnuncioPage.getLayout = function getLayout(page) {
|
||||
if (page.props.raw) {
|
||||
return <>{page}</>;
|
||||
}
|
||||
return <AreaRiservataLayout noSidebar>{page}</AreaRiservataLayout>;
|
||||
};
|
||||
|
||||
|
|
@ -50,7 +231,6 @@ export default SchedaAnnuncioPage;
|
|||
|
||||
export const getServerSideProps = (async (context) => {
|
||||
const id = context.params?.id;
|
||||
const raw = context.query?.raw;
|
||||
|
||||
if (typeof id !== "string") {
|
||||
console.error("Error: id is not a string");
|
||||
|
|
@ -65,56 +245,14 @@ export const getServerSideProps = (async (context) => {
|
|||
|
||||
const helper = generateSSGHelper();
|
||||
|
||||
const data = await helper.annunci.getAnnuncioById_rawImgUrls.fetch({
|
||||
await helper.annunci.getSchedaAnnuncioData.prefetch({
|
||||
id: parsed.data,
|
||||
});
|
||||
if (!data) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
raw: raw === "true",
|
||||
id: parsed.data,
|
||||
trpcState: helper.dehydrate(),
|
||||
data: AnnuncioToStringified(data),
|
||||
},
|
||||
};
|
||||
}) satisfies GetServerSideProps<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({
|
||||
email: paymentData.email,
|
||||
name: paymentData.username,
|
||||
description: paymentData.username,
|
||||
metadata: {
|
||||
userId: paymentData.userid,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import type { GetServerSideProps } from "next";
|
||||
import { ReceiptGenerator } from "~/components/acquisto_receipt";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AreaRiservataLayout } from "~/components/Layout";
|
||||
import { LoadingPage } from "~/components/loading";
|
||||
import { Status500 } from "~/components/status-page";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { redirectTo500 } from "~/lib/utils";
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
|
||||
|
|
@ -10,8 +12,14 @@ import { generateSSGHelper } from "~/server/utils/ssgHelper";
|
|||
import { zOrdineId } from "~/server/utils/zod_types";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
const PDFViewer = dynamic(
|
||||
() => import("~/components/pdf-viewer").then((mod) => mod.PDFViewer),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <LoadingPage />,
|
||||
},
|
||||
);
|
||||
type PagamentoRecapProps = {
|
||||
raw: boolean;
|
||||
ordineId: OrdiniOrdineId;
|
||||
};
|
||||
|
||||
|
|
@ -19,22 +27,89 @@ type PagamentoRecapProps = {
|
|||
* Pagina di ricevuta di cortesia dopo un acquisto: /servizio/ricevuta-cortesia/[ordineId]?raw=true|false
|
||||
*/
|
||||
const PaymentRecapPage: NextPageWithLayout<PagamentoRecapProps> = ({
|
||||
raw,
|
||||
ordineId,
|
||||
}: PagamentoRecapProps) => {
|
||||
const { data, isLoading } = api.servizio.getOrdineRicevutaData.useQuery({
|
||||
const { data, isLoading } =
|
||||
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) {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
if (!data) {
|
||||
if (!data || !pdfUrl) {
|
||||
return <Status500 />;
|
||||
}
|
||||
return (
|
||||
<div className="mx-auto w-full p-4">
|
||||
<ReceiptGenerator data={data} raw={raw} />
|
||||
<div className="mx-auto w-full max-w-4xl p-4">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
@ -50,7 +125,7 @@ export default PaymentRecapPage;
|
|||
|
||||
export const getServerSideProps = (async (context) => {
|
||||
const id = context.params?.ordineId;
|
||||
const raw = context.query?.raw;
|
||||
|
||||
if (typeof id !== "string") {
|
||||
console.error("Error: ordineId is not a string");
|
||||
return redirectTo500;
|
||||
|
|
@ -59,18 +134,17 @@ export const getServerSideProps = (async (context) => {
|
|||
const parsed = zOrdineId.safeParse(id);
|
||||
if (!parsed.success) {
|
||||
console.error("Error parsing ordineId", parsed.error);
|
||||
return redirectTo500
|
||||
return redirectTo500;
|
||||
}
|
||||
|
||||
const helper = generateSSGHelper();
|
||||
if (helper) {
|
||||
await helper.servizio.getOrdineRicevutaData.prefetch({
|
||||
await helper.pagamenti.getRicevutaCortesiaPdfQuery.prefetch({
|
||||
ordineId: parsed.data,
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
raw: raw === "true",
|
||||
ordineId: parsed.data,
|
||||
trpcState: helper.dehydrate(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import {
|
||||
ClientsApi,
|
||||
Configuration,
|
||||
InfoApi,
|
||||
} from "@fattureincloud/fattureincloud-ts-sdk";
|
||||
import { env } from "~/env";
|
||||
|
||||
|
|
@ -12,7 +11,6 @@ const apiConfig = new Configuration({
|
|||
export const companyId = Number.parseInt(env.FIC_COMPANY_ID);
|
||||
export const clientsApi = new ClientsApi(apiConfig);
|
||||
|
||||
export const infosApi = new InfoApi(apiConfig);
|
||||
/*
|
||||
const FIC_COUNTRIES = [
|
||||
"Italia",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { servizioRouter } from "~/server/api/routers/servizio";
|
|||
import { statsRouter } from "~/server/api/routers/stats";
|
||||
import { storageRouter } from "~/server/api/routers/storage";
|
||||
import { stripeRouter } from "~/server/api/routers/stripe";
|
||||
import { stripeReportsRouter } from "~/server/api/routers/stripe_reports";
|
||||
import { testRouter } from "~/server/api/routers/test";
|
||||
import { usersRouter } from "~/server/api/routers/users";
|
||||
import { createTRPCRouter } from "~/server/api/trpc";
|
||||
|
|
@ -46,6 +47,7 @@ export const appRouter = createTRPCRouter({
|
|||
stats: statsRouter,
|
||||
storage: storageRouter,
|
||||
stripe: stripeRouter,
|
||||
stripe_reports: stripeReportsRouter,
|
||||
test: testRouter,
|
||||
users: usersRouter,
|
||||
//sync: syncRouter,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
import type { AnnunciUpdate } from "~/schemas/public/Annunci";
|
||||
import {
|
||||
|
|
@ -15,6 +14,7 @@ import {
|
|||
getAnnunciListHandler,
|
||||
getAnnuncioData,
|
||||
getAnnunciRicerca,
|
||||
getAnnunciSchedaData,
|
||||
getCodici_AnnunciHandler,
|
||||
getOptions_AnnunciHandler,
|
||||
getProprietarioDataHandler,
|
||||
|
|
@ -23,12 +23,12 @@ import {
|
|||
type VideoToRemove,
|
||||
} from "~/server/controllers/annunci.controller";
|
||||
import { db } from "~/server/db";
|
||||
import { NewMail } from "~/server/services/mailer";
|
||||
import {
|
||||
genPdfSchedaAnnuncio,
|
||||
invalidateCache,
|
||||
SCHEDA_ANNUNCIO_CACHE_PREFIX,
|
||||
} from "~/server/services/puppeteer.service";
|
||||
} from "~/server/services/cache.service";
|
||||
import { NewMail } from "~/server/services/mailer";
|
||||
import { TypstGenerate } from "~/server/services/typst.service";
|
||||
import { zAnnuncioId, zServizioId } from "~/server/utils/zod_types";
|
||||
|
||||
export const annunciRouter = createTRPCRouter({
|
||||
|
|
@ -83,6 +83,32 @@ export const annunciRouter = createTRPCRouter({
|
|||
.query(async ({ input }) => {
|
||||
return await getAnnunciById_rawImgUrls({ ...input });
|
||||
}),
|
||||
getSchedaAnnuncioData: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: zAnnuncioId,
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
return await getAnnunciSchedaData(input.id);
|
||||
}),
|
||||
getSchedaAnnuncioPdf: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: zAnnuncioId,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const data = await getAnnunciSchedaData(input.id);
|
||||
const pdfBuffer = await TypstGenerate({
|
||||
templateId: "annuncio.typ",
|
||||
data,
|
||||
});
|
||||
return {
|
||||
pdf: pdfBuffer.toString("base64"),
|
||||
title: `scheda-annuncio-${data.codice}.pdf`,
|
||||
};
|
||||
}),
|
||||
getCodici: publicProcedure.query(async () => {
|
||||
return await getCodici_AnnunciHandler();
|
||||
}),
|
||||
|
|
@ -162,20 +188,4 @@ export const annunciRouter = createTRPCRouter({
|
|||
...input,
|
||||
});
|
||||
}),
|
||||
getSchedaAnnuncio: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: zAnnuncioId,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
return await genPdfSchedaAnnuncio(input.id);
|
||||
} catch (e) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Errore nella generazione della scheda: ${(e as Error).message}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,15 +23,10 @@ import {
|
|||
deleteEmail,
|
||||
getEmails,
|
||||
} from "~/server/services/email.service";
|
||||
import { type MailsTemplates, NewMail } from "~/server/services/mailer";
|
||||
import type { MailsTemplates } from "~/server/services/mail-templates";
|
||||
import { NewMail } from "~/server/services/mailer";
|
||||
import { CONDIZIONI_DEFAULT } from "~/server/services/prezziario.service";
|
||||
import { genPdfSchedaAnnuncioBase64 } from "~/server/services/puppeteer.service";
|
||||
import {
|
||||
zAnnuncioId,
|
||||
zEventId,
|
||||
zOrdineId,
|
||||
zUserId,
|
||||
} from "~/server/utils/zod_types";
|
||||
import { zEventId, zOrdineId, zUserId } from "~/server/utils/zod_types";
|
||||
|
||||
export const comunicazioniRouter = createTRPCRouter({
|
||||
getEmails: protectedProcedure
|
||||
|
|
@ -88,65 +83,6 @@ export const comunicazioniRouter = createTRPCRouter({
|
|||
});
|
||||
return { success: true };
|
||||
}),
|
||||
sendSchedaAnnuncioEmail: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: zAnnuncioId,
|
||||
codice: z.string(),
|
||||
numero: z.string(),
|
||||
nominativo: z.string(),
|
||||
indirizzo: z.string(),
|
||||
to: z.email(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const scheda = await genPdfSchedaAnnuncioBase64(input.id);
|
||||
|
||||
await NewMail({
|
||||
template: {
|
||||
mailType: "emailSchedaContatto",
|
||||
props: {
|
||||
nominativo: input.nominativo,
|
||||
telefono: input.numero,
|
||||
indirizzo: input.indirizzo,
|
||||
},
|
||||
},
|
||||
mail: {
|
||||
subject: `Scheda Annuncio ${input.codice} - Infoalloggi.it`,
|
||||
to: input.to,
|
||||
attachments: [
|
||||
{
|
||||
filename: `scheda annuncio ${input.codice}.pdf`,
|
||||
content: scheda,
|
||||
encoding: "base64",
|
||||
contentType: "application/pdf",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await NewMail({
|
||||
template: {
|
||||
mailType: "generic",
|
||||
props: {
|
||||
title: `Ricevuta invio Scheda ${input.codice} a ${input.to}`,
|
||||
testo: `La scheda dell'annuncio ${input.codice} è stata inviata a ${input.to} con successo.`,
|
||||
},
|
||||
},
|
||||
mail: {
|
||||
subject: `Ricevuta invio Scheda ${input.codice} a ${input.to}`,
|
||||
to: "web@infoalloggi.it",
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Errore nell'invio dell'email: ${(e as Error).message}`,
|
||||
cause: e instanceof Error ? e : undefined,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
sendUtenteInteressatoAdAnnuncioEmail: publicProcedure
|
||||
.input(
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
|
|||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import {
|
||||
getPagamentoDataForCheckout,
|
||||
getRicevutaData,
|
||||
type PreparedPaymentData,
|
||||
preparePayment,
|
||||
previewSaldo,
|
||||
} from "~/server/controllers/pagamenti.controller";
|
||||
|
||||
import { db } from "~/server/db";
|
||||
import { genPdfRicevutaCortesia } from "~/server/services/puppeteer.service";
|
||||
import { TypstGenerate } from "~/server/services/typst.service";
|
||||
import { zOrdineId, zRinnovoId, zServizioId } from "~/server/utils/zod_types";
|
||||
// Create a new ratelimiter, that allows 10 requests per 10 seconds
|
||||
/*
|
||||
|
|
@ -113,7 +114,29 @@ export const pagamentiRouter = createTRPCRouter({
|
|||
.input(z.object({ ordineId: zOrdineId }))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
return await genPdfRicevutaCortesia(input.ordineId);
|
||||
const ricData = await getRicevutaData(input.ordineId);
|
||||
const pdf = await TypstGenerate({
|
||||
templateId: "receipt.typ",
|
||||
data: ricData.data,
|
||||
});
|
||||
return { title: ricData.title, pdf: pdf.toString("base64") };
|
||||
} catch (e) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Errore nella generazione della ricevuta di cortesia: ${(e as Error).message}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
getRicevutaCortesiaPdfQuery: protectedProcedure
|
||||
.input(z.object({ ordineId: zOrdineId }))
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const ricData = await getRicevutaData(input.ordineId);
|
||||
const pdf = await TypstGenerate({
|
||||
templateId: "receipt.typ",
|
||||
data: ricData.data,
|
||||
});
|
||||
return { title: ricData.title, pdf: pdf.toString("base64") };
|
||||
} catch (e) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { getCodici_AnnunciHandler } from "~/server/controllers/annunci.controlle
|
|||
import {
|
||||
getCacheStats,
|
||||
invalidateAllCaches,
|
||||
} from "~/server/services/puppeteer.service";
|
||||
} from "~/server/services/cache.service";
|
||||
import {
|
||||
revalidate,
|
||||
revalidateMultiple,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica";
|
|||
import {
|
||||
adminProcedure,
|
||||
createTRPCRouter,
|
||||
overridableProcedure,
|
||||
protectedProcedure,
|
||||
} from "~/server/api/trpc";
|
||||
import { getPacksPerServizio } from "~/server/controllers/pagamenti.controller";
|
||||
|
|
@ -25,7 +24,6 @@ import {
|
|||
getAnnunciAvailableToAdd,
|
||||
getCompatibileAnnunci,
|
||||
getDataPerAcquisto,
|
||||
getOrdineRicevutaData,
|
||||
getRichieste,
|
||||
getServiziByUserId,
|
||||
getServizioDataById,
|
||||
|
|
@ -166,15 +164,6 @@ export const servizioRouter = createTRPCRouter({
|
|||
.query(async ({ input }) => {
|
||||
return await getOrdineById(input.ordineId);
|
||||
}),
|
||||
getOrdineRicevutaData: overridableProcedure
|
||||
.input(
|
||||
z.object({
|
||||
ordineId: zOrdineId,
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
return await getOrdineRicevutaData({ ordineId: input.ordineId });
|
||||
}),
|
||||
|
||||
getOrdini: protectedProcedure
|
||||
.input(
|
||||
|
|
|
|||
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,
|
||||
publicProcedure,
|
||||
} from "~/server/api/trpc";
|
||||
import { TypstRunner, TypstRunner2 } from "~/server/services/typst.service";
|
||||
|
||||
export const testRouter = createTRPCRouter({
|
||||
testApi: protectedApiProcedure.query(async () => {
|
||||
|
|
@ -15,9 +14,9 @@ export const testRouter = createTRPCRouter({
|
|||
console.error("test console.error");
|
||||
throw new Error("This is a test error");
|
||||
}),
|
||||
testTypst: publicProcedure.query(async () => {
|
||||
return await TypstRunner2();
|
||||
}),
|
||||
// testTypst: publicProcedure.query(async () => {
|
||||
// return await genTesti();
|
||||
// }),
|
||||
});
|
||||
/*
|
||||
PROTECTED API CALL EXAMPLE
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { TRPCError } from "@trpc/server";
|
||||
import { env } from "~/env";
|
||||
import { filteredCaratteristiche } from "~/hooks/schedaAnnuncioUtils";
|
||||
import type { Result } from "~/lib/result";
|
||||
import { getStorageUrl } from "~/lib/storage_utils";
|
||||
import type {
|
||||
|
|
@ -11,6 +12,7 @@ import type { ImagesRefs } from "~/schemas/public/ImagesRefs";
|
|||
import type { ServizioServizioId } from "~/schemas/public/Servizio";
|
||||
import type { VideosRefs } from "~/schemas/public/VideosRefs";
|
||||
import { db } from "~/server/db";
|
||||
import type { AnnuncioTemplateData } from "~/server/services/typst.service";
|
||||
import { withImages, withVideos } from "~/utils/kysely-helper";
|
||||
import { revalidate } from "../utils/revalidationHelper";
|
||||
|
||||
|
|
@ -555,3 +557,75 @@ export const removeAnnuncioMedia = async ({
|
|||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getAnnunciSchedaData = async (
|
||||
id: AnnunciId,
|
||||
): Promise<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 { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
|
||||
import type { UsersId } from "~/schemas/public/Users";
|
||||
import { getAnnunciSchedaData } from "~/server/controllers/annunci.controller";
|
||||
import { getRicevutaData } from "~/server/controllers/pagamenti.controller";
|
||||
import {
|
||||
type MinimalSMSData,
|
||||
SendMessage,
|
||||
|
|
@ -19,10 +21,14 @@ import {
|
|||
import { db } from "~/server/db";
|
||||
import { NewMail, type NewMailProps } from "~/server/services/mailer";
|
||||
import {
|
||||
genPdfCondizioniBase64,
|
||||
genPdfRicevutaCortesiaBase64,
|
||||
genPdfSchedaAnnuncioBase64,
|
||||
} from "../services/puppeteer.service";
|
||||
convertHtmlToTypst,
|
||||
TypstGenerate,
|
||||
} from "~/server/services/typst.service";
|
||||
import { getKeydbClient } from "~/utils/keydb";
|
||||
import {
|
||||
CONDIZIONI_CACHE_PREFIX,
|
||||
SCHEDA_ANNUNCIO_CACHE_PREFIX,
|
||||
} from "../services/cache.service";
|
||||
|
||||
type GeneratedAttachmentTypes =
|
||||
| { type: "scheda_annuncio"; annuncioId: AnnunciId }
|
||||
|
|
@ -41,12 +47,68 @@ export type Email_EventData = {
|
|||
|
||||
const generatePdfContent = async (params: GeneratedAttachmentTypes) => {
|
||||
switch (params.type) {
|
||||
case "ricevuta_cortesia":
|
||||
return await genPdfRicevutaCortesiaBase64(params.ordineId);
|
||||
case "condizioni":
|
||||
return await genPdfCondizioniBase64(params.stringId);
|
||||
case "scheda_annuncio":
|
||||
return await genPdfSchedaAnnuncioBase64(params.annuncioId);
|
||||
case "ricevuta_cortesia": {
|
||||
const ricData = await getRicevutaData(params.ordineId);
|
||||
const pdf = await TypstGenerate({
|
||||
templateId: "receipt.typ",
|
||||
data: ricData.data,
|
||||
});
|
||||
return pdf.toString("base64");
|
||||
}
|
||||
|
||||
case "condizioni": {
|
||||
const keybd = getKeydbClient();
|
||||
const cacheKey = `${CONDIZIONI_CACHE_PREFIX}${params.stringId}`;
|
||||
if (keybd) {
|
||||
const cached = await keybd.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
const data = await db
|
||||
.selectFrom("testi_e_stringhe")
|
||||
.selectAll()
|
||||
.where("stinga_id", "=", params.stringId)
|
||||
.executeTakeFirst();
|
||||
if (!data || !data.stringa_value) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Stringa non trovata",
|
||||
});
|
||||
}
|
||||
|
||||
const stringa = convertHtmlToTypst(data.stringa_value);
|
||||
const pdf = await TypstGenerate({
|
||||
templateId: "testi.typ",
|
||||
data: { stringa },
|
||||
});
|
||||
const pdfBase64 = pdf.toString("base64");
|
||||
if (keybd) {
|
||||
await keybd.set(cacheKey, pdfBase64, "EX", 3600 * 24 * 5);
|
||||
}
|
||||
|
||||
return pdfBase64;
|
||||
}
|
||||
case "scheda_annuncio": {
|
||||
const keybd = getKeydbClient();
|
||||
const cacheKey = `${SCHEDA_ANNUNCIO_CACHE_PREFIX}${params.annuncioId}`;
|
||||
if (keybd) {
|
||||
const cached = await keybd.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
const data = await getAnnunciSchedaData(params.annuncioId);
|
||||
const pdf = await TypstGenerate({
|
||||
templateId: "annuncio.typ",
|
||||
data,
|
||||
});
|
||||
const pdfBase64 = pdf.toString("base64");
|
||||
if (keybd) {
|
||||
await keybd.set(cacheKey, pdfBase64, "EX", 3600 * 5);
|
||||
}
|
||||
return pdfBase64;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown PDF type: `);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,11 +44,6 @@ export const getClientFromCF = async (cf: string) => {
|
|||
return clients.data;
|
||||
};
|
||||
|
||||
export const addClient = async (client: Client) => {
|
||||
const newClient = await clientsApi.createClient(companyId, { data: client });
|
||||
return newClient.data;
|
||||
};
|
||||
|
||||
export const exportUserToFIC = async (userId: UsersId) => {
|
||||
try {
|
||||
const user = await db
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { TRPCError } from "@trpc/server";
|
||||
import { add, intervalToDuration } from "date-fns";
|
||||
import { add, format, intervalToDuration } from "date-fns";
|
||||
import { PaymentMethodToString, type PaymentType } from "~/i18n/stripe";
|
||||
import { parseDBComune, parseDBNazione } from "~/lib/catasto";
|
||||
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
|
||||
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
|
||||
import type {
|
||||
|
|
@ -9,6 +11,8 @@ import type {
|
|||
import type { RinnoviId } from "~/schemas/public/Rinnovi";
|
||||
import type { ServizioServizioId } from "~/schemas/public/Servizio";
|
||||
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
|
||||
import { getComuni, getNazioni } from "~/server/controllers/catasto.controller";
|
||||
import type { RicevutaProps } from "~/server/services/typst.service";
|
||||
import { db } from "../db";
|
||||
import { createOrdine } from "../services/ordini.service";
|
||||
import {
|
||||
|
|
@ -615,3 +619,124 @@ export const previewSaldo = async (
|
|||
|
||||
return saldo;
|
||||
};
|
||||
|
||||
export const getRicevutaData = async (
|
||||
ordineId: OrdiniOrdineId,
|
||||
): Promise<{ title: string; data: RicevutaProps }> => {
|
||||
try {
|
||||
const ordine = await db
|
||||
.selectFrom("ordini")
|
||||
.select([
|
||||
"ordini.ordine_id",
|
||||
"ordini.userid",
|
||||
"ordini.amount_cent",
|
||||
"ordini.sconto",
|
||||
"ordini.created_at",
|
||||
"ordini.paymentmethod",
|
||||
"ordini.type",
|
||||
"ordini.servizio_id",
|
||||
])
|
||||
.innerJoin("prezziario", "prezziario.idprezziario", "ordini.packid")
|
||||
.select("prezziario.nome_it")
|
||||
.where("ordini.ordine_id", "=", ordineId)
|
||||
.executeTakeFirstOrThrow(
|
||||
() => new Error(`Ordine not found - ordineId: ${ordineId}`),
|
||||
);
|
||||
|
||||
const anagrafica = await db
|
||||
.selectFrom("users")
|
||||
.select("users.username")
|
||||
.innerJoin("users_anagrafica", "users_anagrafica.userid", "users.id")
|
||||
.select([
|
||||
"users_anagrafica.via_residenza",
|
||||
"users_anagrafica.civico_residenza",
|
||||
"users_anagrafica.comune_residenza",
|
||||
"users_anagrafica.provincia_residenza",
|
||||
"users_anagrafica.cap_residenza",
|
||||
"users_anagrafica.nazione_residenza",
|
||||
"users_anagrafica.codice_fiscale",
|
||||
])
|
||||
.where("users.id", "=", ordine.userid)
|
||||
.executeTakeFirstOrThrow(
|
||||
() => new Error(`Anagrafica not found - userid: ${ordine.userid}`),
|
||||
);
|
||||
const comuni = await getComuni();
|
||||
const nazioni = await getNazioni();
|
||||
|
||||
const parsedComune = parseDBComune(anagrafica.comune_residenza, comuni);
|
||||
const parsedNazione = parseDBNazione(anagrafica.nazione_residenza, nazioni);
|
||||
|
||||
let previewSaldo: Prezziario | null = null;
|
||||
|
||||
if (ordine.type === OrderTypeEnum.Acconto && ordine.servizio_id) {
|
||||
const servizio = await getServizioById(ordine.servizio_id);
|
||||
if (servizio) {
|
||||
const saldo = await SaldoSolver({
|
||||
tipologia: servizio.tipologia,
|
||||
permanenza: servizio.permanenza,
|
||||
budget: servizio.budget,
|
||||
});
|
||||
if (saldo.success) {
|
||||
previewSaldo = saldo.pack;
|
||||
} else {
|
||||
console.error(
|
||||
`Errore nel calcolo del saldo per il servizio ${ordine.servizio_id}: ${saldo.message}, procedura getRicevutaData`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`Servizio non trovato per ordine ${ordineId} con servizio_id ${ordine.servizio_id}, procedura getRicevutaData`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!anagrafica.codice_fiscale) {
|
||||
throw new Error(`Codice fiscale mancante per utente ${ordine.userid}`);
|
||||
}
|
||||
|
||||
console.log(previewSaldo);
|
||||
|
||||
const data = {
|
||||
ordineId: ordine.ordine_id,
|
||||
datetime: format(ordine.created_at, "HH:mm"),
|
||||
date: format(ordine.created_at, "dd/MM/yyyy"),
|
||||
nominativo: anagrafica.username,
|
||||
codicefiscale: anagrafica.codice_fiscale,
|
||||
indirizzo: `${anagrafica.via_residenza} ${anagrafica.civico_residenza}\n${parsedComune?.nome} (${anagrafica.provincia_residenza}) ${anagrafica.cap_residenza}`,
|
||||
nazione: parsedNazione?.nome || "N/A",
|
||||
method: PaymentMethodToString(
|
||||
(ordine.paymentmethod || "manual") as PaymentType,
|
||||
),
|
||||
items: [
|
||||
{
|
||||
title: ordine.nome_it,
|
||||
amount: ordine.amount_cent,
|
||||
discount: ordine.sconto,
|
||||
strike: false,
|
||||
desc: null,
|
||||
},
|
||||
...(previewSaldo
|
||||
? [
|
||||
{
|
||||
title: previewSaldo.nome_it,
|
||||
amount: previewSaldo.prezzo_cent,
|
||||
discount: null,
|
||||
strike: true,
|
||||
desc: "Pagamento differito, non è dovuto in questo momento",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
total: ordine.amount_cent - ordine.sconto,
|
||||
vat: (ordine.amount_cent - ordine.sconto) * 0.22,
|
||||
};
|
||||
return {
|
||||
title: `ricevuta-cortesia-${anagrafica.username.replace(/\s+/g, "-")}-${format(ordine.created_at, "dd-MM-yyyy")}.pdf`,
|
||||
data,
|
||||
};
|
||||
} catch (e) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Error fetching ordine ricevuta data: ${(e as Error).message}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,10 +2,7 @@ import { TRPCError } from "@trpc/server";
|
|||
import { add, isBefore } from "date-fns";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
|
||||
import { z } from "zod/v4";
|
||||
import type { PurchaseData } from "~/components/acquisto_receipt";
|
||||
import { env } from "~/env";
|
||||
import type { PaymentType } from "~/i18n/stripe";
|
||||
import { parseDBComune, parseDBNazione } from "~/lib/catasto";
|
||||
import { withParsedDates } from "~/lib/dateParserJsonKysely";
|
||||
import type { Annunci, AnnunciId } from "~/schemas/public/Annunci";
|
||||
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
|
||||
|
|
@ -42,10 +39,8 @@ import {
|
|||
} from "~/utils/kysely-helper";
|
||||
import { GetUserInterests } from "../services/interests.service";
|
||||
import { createOrdine } from "../services/ordini.service";
|
||||
import { genPdfCondizioniBase64 } from "../services/puppeteer.service";
|
||||
import { getServizioById } from "../services/servizio.service";
|
||||
import type { AnnuncioRicerca } from "./annunci.controller";
|
||||
import { getComuni, getNazioni } from "./catasto.controller";
|
||||
import { SaldoSolver } from "./pagamenti.controller";
|
||||
|
||||
export const getServiziByUserId = async (userId: UsersId) => {
|
||||
|
|
@ -243,7 +238,10 @@ export const attivaServizio_SaltaPagamentoAcconto = async (
|
|||
);
|
||||
|
||||
if (user.comms_enabled) {
|
||||
await NewMail({
|
||||
await addEventToQueue({
|
||||
data: {
|
||||
tipo: "email",
|
||||
data: {
|
||||
template: {
|
||||
mailType: "servizioAttivato",
|
||||
},
|
||||
|
|
@ -254,10 +252,11 @@ export const attivaServizio_SaltaPagamentoAcconto = async (
|
|||
? [
|
||||
{
|
||||
filename: `condizioni_contrattuali_${ordine.created_at.toISOString()}.pdf`,
|
||||
|
||||
content: await genPdfCondizioniBase64(
|
||||
generate: {
|
||||
type: "condizioni",
|
||||
stringId:
|
||||
ordine.testo_condizioni as TestiEStringheStingaId,
|
||||
),
|
||||
},
|
||||
|
||||
encoding: "base64",
|
||||
contentType: "application/pdf",
|
||||
|
|
@ -266,7 +265,11 @@ export const attivaServizio_SaltaPagamentoAcconto = async (
|
|||
: [],
|
||||
},
|
||||
userId: ordine.userId,
|
||||
},
|
||||
},
|
||||
lock_expires: add(new Date(), { seconds: 30 }),
|
||||
});
|
||||
|
||||
await addEventToQueue({
|
||||
data: {
|
||||
tipo: "email",
|
||||
|
|
@ -1398,101 +1401,6 @@ export const getCompatibileAnnunci = async ({
|
|||
}
|
||||
};
|
||||
|
||||
export const getOrdineRicevutaData = async ({
|
||||
ordineId,
|
||||
}: {
|
||||
ordineId: OrdiniOrdineId;
|
||||
}): Promise<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 &
|
||||
Pick<Users, "username" | "note" | "telefono"> & {
|
||||
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 { UsersId } from "~/schemas/public/Users";
|
||||
import { db } from "~/server/db";
|
||||
import { genMail, type MailsTemplates } from "~/server/services/mailer";
|
||||
import type { MailsTemplates } from "~/server/services/mail-templates";
|
||||
import { genMail } from "~/server/services/mailer";
|
||||
|
||||
export const storeEmail = async ({
|
||||
userId,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import {
|
||||
AvvisoInterruzioneMailTag,
|
||||
} from "emails/avviso-interruzione";
|
||||
import { AvvisoInterruzioneMailTag } from "emails/avviso-interruzione";
|
||||
import {
|
||||
ContattoAdminEmailPropsSchema,
|
||||
ContattoAdminMailTag,
|
||||
|
|
@ -13,36 +11,23 @@ import {
|
|||
ContattoInteressatoMailTag,
|
||||
ContattoInteressatoPropsSchema,
|
||||
} from "emails/email-interessato";
|
||||
import {
|
||||
GenericMailTag,
|
||||
GenericPropsSchema,
|
||||
} from "emails/gereric-email";
|
||||
import { GenericMailTag, GenericPropsSchema } from "emails/gereric-email";
|
||||
import { InterruzioneMailTag } from "emails/interruzione";
|
||||
import { InvitoMailTag, InvitoPropsSchema } from "emails/invito";
|
||||
import {
|
||||
PagamentoConfermaMailTag,
|
||||
} from "emails/pagamento-conferma";
|
||||
import {
|
||||
PagamentoErroreMailTag,
|
||||
} from "emails/pagamento-errore";
|
||||
import {
|
||||
PagamentoRicezioneMailTag,
|
||||
} from "emails/pagamento-ricezione";
|
||||
import { PagamentoConfermaMailTag } from "emails/pagamento-conferma";
|
||||
import { PagamentoErroreMailTag } from "emails/pagamento-errore";
|
||||
import { PagamentoRicezioneMailTag } from "emails/pagamento-ricezione";
|
||||
import {
|
||||
PwResetLinkMailTag,
|
||||
PwResetLinkPropsSchema,
|
||||
} from "emails/pw-reset-link";
|
||||
import { RecessoMailTag } from "emails/recesso";
|
||||
import {
|
||||
RegistrazioneAvvenutaMailTag,
|
||||
} from "emails/registrazione-avvenuta";
|
||||
import { RegistrazioneAvvenutaMailTag } from "emails/registrazione-avvenuta";
|
||||
import {
|
||||
SchedaContattoMailTag,
|
||||
SchedaContattoPropsSchema,
|
||||
} from "emails/scheda-annuncio";
|
||||
import {
|
||||
ServizioAttivatoMailTag,
|
||||
} from "emails/servizio-attivato";
|
||||
import { ServizioAttivatoMailTag } from "emails/servizio-attivato";
|
||||
import {
|
||||
VerificaEmailMailTag,
|
||||
VerificaEmailPropsSchema,
|
||||
|
|
@ -93,9 +78,9 @@ export const TemplateKeys = Object.keys(Templates) as Array<
|
|||
keyof typeof Templates
|
||||
>;
|
||||
|
||||
export const TemplateSchemas = Object.entries(Templates)
|
||||
.map(([_, value]) => value.props)
|
||||
.filter((schema) => schema !== null);
|
||||
// const TemplateSchemas = Object.entries(Templates)
|
||||
// .map(([_, value]) => value.props)
|
||||
// .filter((schema) => schema !== null);
|
||||
|
||||
export type TemplateType = keyof typeof Templates;
|
||||
|
||||
|
|
|
|||
|
|
@ -26,14 +26,6 @@ import { GetFlagValueHandler } from "~/server/controllers/flags.controller";
|
|||
import { db } from "~/server/db";
|
||||
import { storeEmail } from "~/server/services/email.service";
|
||||
|
||||
export {
|
||||
type MailsTemplates,
|
||||
TemplateKeys,
|
||||
TemplateSchemas,
|
||||
Templates,
|
||||
type TemplateType,
|
||||
} from "~/server/services/mail-templates";
|
||||
|
||||
import {
|
||||
type MailsTemplates,
|
||||
Templates,
|
||||
|
|
|
|||
|
|
@ -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 { promisify } from "node:util";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
|
||||
import { db } from "~/server/db";
|
||||
import { getStringa } from "~/server/services/testi_stringhe.service";
|
||||
import type { EstrattoContoData } from "~/server/api/routers/stripe_reports";
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
const projectRoot = path.resolve(".");
|
||||
|
|
@ -13,140 +11,11 @@ const fontsPath = path.join(projectRoot, "typst", "fonts");
|
|||
|
||||
const tmpDir = path.join(projectRoot, "tmp");
|
||||
|
||||
type RicevutaProps = {
|
||||
ordineId: string;
|
||||
date: string;
|
||||
datetime: string;
|
||||
nominativo: string;
|
||||
codicefiscale: string;
|
||||
indirizzo: string;
|
||||
nazione: string;
|
||||
items: {
|
||||
title: string;
|
||||
desc: string | null;
|
||||
discount: number | null;
|
||||
amount: number;
|
||||
strike: boolean;
|
||||
}[];
|
||||
total: number;
|
||||
vat: number;
|
||||
method: string;
|
||||
};
|
||||
|
||||
export const TypstRunner = async () => {
|
||||
const timestamp = Date.now();
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
const jsonFileName = `data-${timestamp}.json`;
|
||||
const jsonPath = path.join(tmpDir, jsonFileName);
|
||||
const templatePath = path.join(projectRoot, "typst", "receipt.typ");
|
||||
const pdfPath = path.join(tmpDir, `receipt-${timestamp}.pdf`);
|
||||
|
||||
const props: RicevutaProps = {
|
||||
ordineId: "dniuawndiandioawmodmawodmawdaw",
|
||||
date: "10 gennaio 2026",
|
||||
datetime: "14:55",
|
||||
nominativo: "Di Grigoli Angelo Damiano",
|
||||
codicefiscale: "DGRNLD97C13B602Z",
|
||||
indirizzo: "Via Aldo Fantina 15, PIEVE DEL GRAPPA (TV) 31017,",
|
||||
nazione: "Italia",
|
||||
items: [
|
||||
{
|
||||
title: "Acconto Cerco Transitorio",
|
||||
desc: null,
|
||||
discount: null,
|
||||
amount: 3500,
|
||||
strike: false,
|
||||
},
|
||||
{
|
||||
title: "Saldo Cerco Affitto Transitorio (6 mesi)",
|
||||
desc: "Pagamento differito, non è dovuto in questo momento",
|
||||
discount: null,
|
||||
amount: 37500,
|
||||
strike: true,
|
||||
},
|
||||
],
|
||||
total: 3500,
|
||||
vat: 3500 * 0.22,
|
||||
method: "Carta di credito",
|
||||
};
|
||||
|
||||
await fs.writeFile(jsonPath, JSON.stringify(props));
|
||||
|
||||
try {
|
||||
const command = `typst compile --input data_path=/tmp/${jsonFileName} --root "${projectRoot}" --font-path "${fontsPath}" "${templatePath}" "${pdfPath}"`;
|
||||
|
||||
await execPromise(command);
|
||||
|
||||
const pdfBuffer = await fs.readFile(pdfPath);
|
||||
return new Response(pdfBuffer, {
|
||||
headers: { "Content-Type": "application/pdf" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("typst compile failed:", err);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to generate PDF with Typst",
|
||||
cause: err instanceof Error ? err : undefined,
|
||||
});
|
||||
} finally {
|
||||
// Cleanup — runs even on failure
|
||||
await Promise.all([
|
||||
fs.unlink(jsonPath).catch(() => {}),
|
||||
//fs.unlink(pdfFile).catch(() => {}),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
export const TypstRunner2 = async () => {
|
||||
const s = await getStringa({
|
||||
db: db,
|
||||
stringaId: "CONDIZIONI_CERCHI" as TestiEStringheStingaId,
|
||||
});
|
||||
if (!s || !s.stringa_value) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Stringa non trovata",
|
||||
});
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
const jsonFileName = `tmp-${timestamp}.json`;
|
||||
const jsonPath = path.join(tmpDir, jsonFileName);
|
||||
const templatePath = path.join(projectRoot, "typst", "testi.typ");
|
||||
const pdfFile = path.join(tmpDir, `doc-${timestamp}.pdf`);
|
||||
|
||||
const p = convertHtmlToTypst(s.stringa_value);
|
||||
|
||||
await fs.writeFile(jsonPath, JSON.stringify({ stringa: p }));
|
||||
|
||||
try {
|
||||
const command = `typst compile --input data_path=/tmp/${jsonFileName} --root "${projectRoot}" --font-path "${fontsPath}" "${templatePath}" "${pdfFile}"`;
|
||||
await execPromise(command);
|
||||
const pdfBuffer = await fs.readFile(pdfFile);
|
||||
return new Response(pdfBuffer, {
|
||||
headers: { "Content-Type": "application/pdf" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("typst compile failed:", err);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to generate PDF with Typst",
|
||||
cause: err instanceof Error ? err : undefined,
|
||||
});
|
||||
} finally {
|
||||
// Cleanup — runs even on failure
|
||||
await Promise.all([
|
||||
fs.unlink(jsonPath).catch(() => {}),
|
||||
//fs.unlink(pdfFile).catch(() => {}),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts HTML to Typst markup using Pandoc.
|
||||
* works on Windows & Linux.
|
||||
*/
|
||||
function convertHtmlToTypst(html: string): string {
|
||||
export function convertHtmlToTypst(html: string): string {
|
||||
try {
|
||||
// We pass the HTML via 'input' which writes to the process stdin
|
||||
const output = execSync(
|
||||
|
|
@ -165,3 +34,95 @@ function convertHtmlToTypst(html: string): string {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
type EstrattoContoTemplate = {
|
||||
templateId: "estratto.typ";
|
||||
data: EstrattoContoData;
|
||||
};
|
||||
export type RicevutaProps = {
|
||||
ordineId: string;
|
||||
date: string;
|
||||
datetime: string;
|
||||
nominativo: string;
|
||||
codicefiscale: string;
|
||||
indirizzo: string;
|
||||
nazione: string;
|
||||
items: {
|
||||
title: string;
|
||||
desc: string | null;
|
||||
discount: number | null;
|
||||
amount: number;
|
||||
strike: boolean;
|
||||
}[];
|
||||
total: number;
|
||||
vat: number;
|
||||
method: string;
|
||||
};
|
||||
type RicevutaTemplate = {
|
||||
templateId: "receipt.typ";
|
||||
data: RicevutaProps;
|
||||
};
|
||||
|
||||
type TestiTemplate = {
|
||||
templateId: "testi.typ";
|
||||
data: {
|
||||
/**
|
||||
* const striga = convertHtmlToTypst(stringa_value);
|
||||
*/
|
||||
stringa: string;
|
||||
};
|
||||
};
|
||||
export type AnnuncioTemplateData = {
|
||||
codice: string;
|
||||
nominativo: string;
|
||||
numero: string;
|
||||
indirizzo: string;
|
||||
lat: string;
|
||||
lon: string;
|
||||
tipo: string;
|
||||
prezzo: number;
|
||||
titolo_it: string;
|
||||
desc_it: string;
|
||||
disponibile_da: string;
|
||||
caratteristiche: [string, string][];
|
||||
};
|
||||
type AnnuncioTemplate = {
|
||||
templateId: "annuncio.typ";
|
||||
data: AnnuncioTemplateData;
|
||||
};
|
||||
type TypstInput =
|
||||
| EstrattoContoTemplate
|
||||
| RicevutaTemplate
|
||||
| TestiTemplate
|
||||
| AnnuncioTemplate;
|
||||
|
||||
export const TypstGenerate = async ({ templateId, data }: TypstInput) => {
|
||||
const timestamp = Date.now();
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
const tmpDataFile = `data-${timestamp}.json`;
|
||||
const tmpDataPath = path.join(tmpDir, tmpDataFile);
|
||||
const templatePath = path.join(projectRoot, "typst", templateId);
|
||||
const resultPath = path.join(tmpDir, `doc-${timestamp}.pdf`);
|
||||
|
||||
await fs.writeFile(tmpDataPath, JSON.stringify(data));
|
||||
|
||||
try {
|
||||
const command = `typst compile --input data_path=/tmp/${tmpDataFile} --root "${projectRoot}" --font-path "${fontsPath}" "${templatePath}" "${resultPath}"`;
|
||||
|
||||
await execPromise(command);
|
||||
|
||||
return await fs.readFile(resultPath);
|
||||
} catch (err) {
|
||||
console.error(`typst compile failed, template: ${templateId}:`, err);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Failed to generate PDF with Typst, template: ${templateId}`,
|
||||
cause: err instanceof Error ? err : undefined,
|
||||
});
|
||||
} finally {
|
||||
await Promise.all([
|
||||
fs.unlink(tmpDataPath).catch(() => {}),
|
||||
fs.unlink(resultPath).catch(() => {}),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
/* Path Aliases */
|
||||
|
||||
"baseUrl": ".",
|
||||
"checkJs": true,
|
||||
/* Base Options: */
|
||||
|
|
@ -51,7 +52,8 @@
|
|||
"emails",
|
||||
".next/types/**/*.ts",
|
||||
"tests",
|
||||
"flow-gen.ts"
|
||||
"flow-gen.ts",
|
||||
"check-unused-trpc.ts"
|
||||
],
|
||||
"ts-node": {
|
||||
// these options are overrides used only by ts-node
|
||||
|
|
|
|||
|
|
@ -1,15 +1,35 @@
|
|||
#import "@preview/iconic-salmon-fa:1.1.0": fa-location-pin, fa-whatsapp
|
||||
#import "utils.typ": format-eur
|
||||
#import "@preview/oxifmt:1.0.0": strfmt
|
||||
#let data_file = sys.inputs.at("data_path", default: "none")
|
||||
|
||||
#let data = if data_file != "none" { json(data_file) } else {
|
||||
(
|
||||
codice: "3786B",
|
||||
nominativo: "INFOALLOGGI (delegato)",
|
||||
numero: "+393453944827",
|
||||
indirizzo: "VIA MONTE ASOLONE, 21 int 1 Bassano del Grappa 36061 (VI)",
|
||||
lat: "45.7705",
|
||||
lon: "11.74959",
|
||||
tipo: "Transitorio",
|
||||
prezzo: 54000,
|
||||
titolo_it: "BASSANO S. VITO MONOLOCALE A USO TRANSITORIO - 01/05/26",
|
||||
desc_it: "CODICE: 3786B - LIBERO DA MAGGIO - AFFITTO TRANSITORIO -\n\nConfortevole monolocale situato nella tranquilla zona di San Vito, a Bassano del Grappa. Libero da giugno, questo appartamento è la soluzione ideale per chi cerca un affitto transitorio. Completamente ammobiliato e arredato con gusto, il monolocale si trova al piano terra e offre un soggiorno luminoso con un angolo cottura recente. La camera, dotata di un comodo letto alla francese, è separata da una parete per garantire privacy. Classe energetica F.\n\nIl bagno è completo di doccia e lavatrice nuova, perfetto per le esigenze quotidiane. Inoltre, è disponibile un posto auto scoperto riservato, rendendo la vita ancora più comoda. Le utenze sono attive, e il contratto di affitto prevede una permanenza da 6 a 12 mesi, con un canone mensile di 540,00 € e spese condominiali di 80,00 €/mese.\n\nQuesto monolocale è ideale per single e si trova in un contesto tranquillo, lontano dal caos cittadino. Non è consentito fumare all'interno dei locali e si preferiscono inquilini senza animali. Non perdere l'occasione di vivere in un ambiente accogliente e funzionale! Contattaci per maggiori informazioni e per organizzare una visita.\n\nRICHIESTE DEL PROPRIETARIO:\n\nTipologia contratto di locazione: TRANSITORIO (No stabile)\nRegime fiscale: CEDOLARE SECCA (Senza imposte di registro e bolli)\nData disponibilità: 1 MAGGIO\nPermanenza: DA 6 a 12 MESI\nPreferenza: SINGLE\nContratto di lavoro: A TEMPO DETERMINATO (o altra motivazione prevista per legge)\nCanone mensile: 540,00 €\nSpese condominiali e accessorie: 80,00 €/MESE\nDeposito cauzionale: IN BASE ALLA PERMANENZA\nConsumi utenze: Gas/Riscaldamento - Luce - Acqua (Rimborso a CONSUMO)\nFumatori: NON E' CONSENTITO FUMARE ALL'INTERNO DEI LOCALI\nAnimali: NON E' CONSENTITO\n\nCOSA FARE PER VISITARE L'IMMOBILE\n \nInfoalloggi.it è una piattaforma immobiliare che ti consente di avere un contatto diretto con i proprietari previa attivazione del servizio. La piattaforma e i nostri referenti sono a tua disposizione per assisterti nella tua ricerca. \nLa geo-localizzazione indica la zona dell'immobile, l'indirizzo esatto lo ricevi con l'attivazione del servizio.\n",
|
||||
disponibile_da: "01/05/2026",
|
||||
caratteristiche: (
|
||||
("Tipo Impianto", "Radiatori"),
|
||||
("Riscaldamento", "Autonomo"),
|
||||
("Arredi", "Arredato"),
|
||||
("Piani Edificio", "Uno"),
|
||||
("Internet", "No"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
#set page(
|
||||
paper: "a4",
|
||||
margin: (bottom: 1.5cm, rest: 1cm),
|
||||
footer: [
|
||||
#line(length: 100%, stroke: 0.5pt + luma(200))
|
||||
#set text(size: 8pt, fill: luma(100))
|
||||
#grid(
|
||||
columns: (1fr, auto),
|
||||
[Arcenia Srl. Tel. +39 0424529869 | Email: arca\@infoalloggi.it], [Documento generato in data: 31/3/2026],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
#set text(font: "Inter 18pt", size: 10pt, lang: "it", fill: rgb("#1f1f1f"))
|
||||
|
|
@ -28,112 +48,90 @@
|
|||
Email: arca\@infoalloggi.it
|
||||
],
|
||||
)
|
||||
#v(1em)
|
||||
#line(length: 100%, stroke: 0.5pt + luma(200))
|
||||
#v(1em)
|
||||
|
||||
// --- TITLE & REFERENCE ---
|
||||
#v(0.5em)
|
||||
#align(center)[#text(
|
||||
size: 16pt,
|
||||
weight: "bold",
|
||||
size: 14pt,
|
||||
weight: "semibold",
|
||||
fill: rgb("#63493f"),
|
||||
)[BASSANO S. VITO MONOLOCALE A USO TRANSITORIO - 01/06/26]]
|
||||
)[#data.titolo_it]]
|
||||
#v(0.5em)
|
||||
|
||||
#align(center)[#text(
|
||||
size: 14pt,
|
||||
)[Riferimento: *3786B*]]
|
||||
size: 13pt,
|
||||
)[Riferimento: *#data.codice*]]
|
||||
|
||||
#v(1.5em)
|
||||
#v(.5em)
|
||||
|
||||
#box(stroke: (paint: rgb("#25d366"), thickness: 1.5pt), width: 100%, inset: 16pt, radius: 10pt)[
|
||||
#box(stroke: (paint: rgb("#25d366"), thickness: 1.5pt), width: 100%, inset: 16pt, radius: 10pt, fill: rgb("#fcfcfc"))[
|
||||
#grid(
|
||||
columns: 1fr,
|
||||
gutter: 14pt,
|
||||
[#fa-whatsapp(size: 20pt, fill: rgb("#25d366"))#h(1em)#text(
|
||||
size: 16pt,
|
||||
weight: "bold",
|
||||
[#fa-whatsapp(size: 16pt, fill: rgb("#25d366"))#h(1em)#text(
|
||||
size: 14pt,
|
||||
weight: "semibold",
|
||||
fill: rgb("#63493f"),
|
||||
)[Contatti]#h(.5em)#text(size: 12pt, fill: rgb("#63493f"))[(mandare un messaggio WhatsApp prima di chiamare)]],
|
||||
[INFOALLOGGI (delegato)],
|
||||
[*Tel. +393453944827*]
|
||||
[#data.nominativo],
|
||||
[*Tel. #data.numero*]
|
||||
)
|
||||
]
|
||||
#v(1.5em)
|
||||
#grid(
|
||||
columns: 1fr,
|
||||
gutter: 10pt, [
|
||||
#text(size: 10pt, weight: "bold")[Indirizzo]], [
|
||||
#line(length: 100%, stroke: 0.5pt + luma(200))], [
|
||||
#text(weight: "bold", size: 12pt)[VIA MONTE ASOLONE, 21 int 1 Bassano del Grappa 36061 (VI)]#h(3em)#link(
|
||||
"https://maps.google.com",
|
||||
)[#fa-location-pin(size: 20pt, fill: red)#text(fill: blue)[Apri in Google Maps]]
|
||||
]
|
||||
)
|
||||
#v(.5em)
|
||||
#text(weight: "semibold")[Indirizzo]
|
||||
#line(length: 100%, stroke: 0.5pt + luma(200))
|
||||
#text(weight: "semibold")[#data.indirizzo]#h(3em)#link(
|
||||
strfmt(
|
||||
"https://www.google.com/maps/dir/?api=1&travelmode=driving&layer=traffic&destination={},{}",
|
||||
data.lat,
|
||||
data.lon,
|
||||
),
|
||||
)[#fa-location-pin(size: 18pt, fill: red)#text(fill: blue)[Apri in Google Maps]]
|
||||
|
||||
#v(0.5em)
|
||||
#v(1em)
|
||||
|
||||
#v(0.5em)
|
||||
|
||||
|
||||
#v(1.5em)
|
||||
|
||||
// --- KEY DETAILS TABLE ---
|
||||
#table(
|
||||
columns: (1fr, 1fr, 1fr),
|
||||
stroke: 0.5pt + luma(200),
|
||||
inset: 10pt,
|
||||
[*Tipologia* \ Affitto Transitorio], [*Canone Mensile* \ 540,00 €], [*Disponibile da* \ 31/05/2026],
|
||||
stroke: none,
|
||||
//inset: 10pt,
|
||||
[#box(
|
||||
inset: 0.8em,
|
||||
fill: luma(245),
|
||||
height: auto,
|
||||
width: 100%,
|
||||
)[Tipologia \ #text(weight: "semibold", size: 12pt)[Affitto #data.tipo]] ],
|
||||
[#box(
|
||||
inset: 0.8em,
|
||||
fill: luma(245),
|
||||
height: auto,
|
||||
width: 100%,
|
||||
)[Canone Mensile \ #text(weight: "semibold", size: 12pt)[#format-eur(data.prezzo)]]],
|
||||
[#box(
|
||||
inset: 0.8em,
|
||||
fill: luma(245),
|
||||
height: auto,
|
||||
width: 100%,
|
||||
)[Disponibile da \ #text(weight: "semibold", size: 12pt)[#data.disponibile_da]]],
|
||||
)
|
||||
#v(1.5em)
|
||||
#v(.5em)
|
||||
|
||||
// --- FEATURES ---
|
||||
#text(size: 12pt, weight: "bold")[Dotazioni e Servizi]
|
||||
#text(size: 12pt, weight: "semibold")[Dotazioni e Servizi]
|
||||
#v(0.5em)
|
||||
#grid(
|
||||
columns: (1fr, 1fr, 1fr),
|
||||
gutter: 10pt,
|
||||
[- *Tipo Impianto:* Radiatori], [- *Riscaldamento:* Autonomo], [- *Arredi:* Arredato],
|
||||
[- *Piani Edificio:* Uno], [- *Internet:* No],
|
||||
gutter: 14pt,
|
||||
|
||||
..data.caratteristiche.map(pair => [- *#pair.at(0)*: #pair.at(1)])
|
||||
)
|
||||
#v(1.5em)
|
||||
|
||||
// --- DESCRIPTION ---
|
||||
#text(size: 12pt, weight: "bold")[Descrizione]
|
||||
#v(0.5em)
|
||||
CODICE: 3786B LIBERO DA GIUGNO AFFITTO TRANSITORIO - Confortevole monolocale situato nella tranquilla zona di San Vito, a Bassano del Grappa. Libero da giugno, questo appartamento è la soluzione ideale per chi cerca un affitto transitorio.
|
||||
|
||||
Completamente ammobiliato e arredato con gusto, il monolocale si trova al piano terra e offre un soggiorno luminoso con un angolo cottura recente. La camera, dotata di un comodo letto alla francese, è separata da una parete per garantire privacy. Classe energetica F.
|
||||
|
||||
Il bagno è completo di doccia e lavatrice nuova, perfetto per le esigenze quotidiane. Inoltre, è disponibile un posto auto scoperto riservato, rendendo la vita ancora più comoda. Le utenze sono attive, e il contratto di affitto prevede una permanenza da 6 a 12 mesi, con un canone mensile di 540,00 € e spese condominiali di 80,00 €/mese.
|
||||
|
||||
Questo monolocale è ideale per single e si trova in un contesto tranquillo, lontano dal caos cittadino. Non è consentito fumare all'interno dei locali e si preferiscono inquilini senza animali. Non perdere l'occasione di vivere in un ambiente accogliente e funzionale! Contattaci per maggiori informazioni e per organizzare una visita.
|
||||
#text(size: 12pt, weight: "semibold")[Descrizione]
|
||||
#line(length: 100%, stroke: 0.5pt + luma(200))
|
||||
#text(size: 9pt)[#data.desc_it]
|
||||
#v(1.5em)
|
||||
|
||||
// --- OWNER REQUESTS ---
|
||||
#rect(fill: rgb("#f5f5f5"), width: 100%, inset: 15pt, stroke: none)[
|
||||
#text(size: 12pt, weight: "bold")[RICHIESTE DEL PROPRIETARIO:]
|
||||
#v(0.5em)
|
||||
#list(
|
||||
[*Tipologia contratto di locazione:* TRANSITORIO (No stabile)],
|
||||
[*Regime fiscale:* CEDOLARE SECCA (Senza imposte di registro e bolli)],
|
||||
[*Data disponibilità:* 1 GIUGNO],
|
||||
[*Permanenza:* DA 6 a 12 MESI],
|
||||
[*Preferenza:* SINGLE],
|
||||
[*Contratto di lavoro:* A TEMPO DETERMINATO (o altra motivazione prevista per legge)],
|
||||
[*Canone mensile:* 540,00 €],
|
||||
[*Spese condominiali e accessorie:* 80,00 €/MESE],
|
||||
[*Deposito cauzionale:* IN BASE ALLA PERMANENZA],
|
||||
[*Consumi utenze:* Gas/Riscaldamento - Luce Acqua (Rimborso a CONSUMO)],
|
||||
[*Fumatori:* NON E' CONSENTITO FUMARE ALL'INTERNO DEI LOCALI],
|
||||
[*Animali:* NON E' CONSENTITO],
|
||||
)
|
||||
#text(size: 8pt, fill: rgb("#636363"))[
|
||||
Informazione strettamente riservata ed indirizzata all'Utente Arca-Infoalloggi nel rispetto dell' Art. F. Precisazione dei Tuoi Obblighi e responsabilità delle Condizioni Generali di Contratto (CGC) accettate
|
||||
]
|
||||
#v(1.5em)
|
||||
|
||||
// --- HOW TO VISIT ---
|
||||
#text(size: 12pt, weight: "bold")[COSA FARE PER VISITARE L'IMMOBILE]
|
||||
#v(0.5em)
|
||||
Infoalloggi.it è una piattaforma immobiliare che ti consente di avere un contatto diretto con i proprietari previa attivazione del servizio. La piattaforma e i nostri referenti sono a tua disposizione per assisterti nella tua ricerca.
|
||||
|
||||
La geo-localizzazione indica la zona dell'immobile, l'indirizzo esatto lo ricevi con l'attivazione del servizio. Informazione strettamente riservata ed indirizzata all'Utente Arca-Infoalloggi nel rispetto dell' Art. F. Precisazione dei Tuoi Obblighi e responsabilità delle Condizioni Generali di Contratto (CGC) accettate.
|
||||
|
|
|
|||
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")
|
||||
|
||||
|
|
@ -77,7 +77,7 @@
|
|||
],
|
||||
align(right)[
|
||||
#text(fill: luma(120), size: 9pt)[Data] \
|
||||
#data.date \
|
||||
#text(size: 12pt)[#data.date] \
|
||||
#data.datetime
|
||||
],
|
||||
)
|
||||
|
|
@ -111,11 +111,15 @@
|
|||
.map(item => {
|
||||
(
|
||||
item.title + if item.desc != none { "\n" + text(size: 9pt, fill: luma(100))[#item.desc] } else { "" },
|
||||
if item.at("discount", default: none) != none { [#item.discount %] } else [],
|
||||
if item.at("discount", default: none) != none {
|
||||
if item.discount > 0 {
|
||||
[#item.discount %]
|
||||
}
|
||||
} else [],
|
||||
if item.at("strike", default: false) {
|
||||
strike(stroke: 1pt + luma(100))[#text(fill: luma(100))[#format-price(item.amount)]]
|
||||
strike(stroke: 1pt + luma(100))[#text(fill: luma(100))[#format-eur(item.amount)]]
|
||||
} else {
|
||||
[#format-price(item.amount)]
|
||||
[#format-eur(item.amount)]
|
||||
},
|
||||
table.hline(stroke: 0.5pt + luma(200)),
|
||||
)
|
||||
|
|
@ -130,8 +134,8 @@
|
|||
columns: (1fr, auto),
|
||||
align: (right, right),
|
||||
gutter: 1.5em,
|
||||
[*Totale:*], [*#format-price(data.total)*],
|
||||
[#text(fill: luma(100))[di cui IVA (22%):]], [#text(fill: luma(100))[#format-price(data.vat)]],
|
||||
[*Totale:*], [*#format-eur(data.total)*],
|
||||
[#text(fill: luma(100))[di cui IVA (22%):]], [#text(fill: luma(100))[#format-eur(data.vat)]],
|
||||
)
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,38 @@
|
|||
#import "@preview/oxifmt:1.0.0": strfmt
|
||||
|
||||
|
||||
#let format-price(cents) = {
|
||||
// Handle case where cents might be null or not a number
|
||||
if cents == none { return "0,00 €" }
|
||||
|
||||
let euros = int(cents / 100)
|
||||
let decimal = calc.rem(cents, 100)
|
||||
|
||||
// Create the decimal string and pad with a leading zero if needed (e.g., 5 cents -> "05")
|
||||
let decimal-str = if decimal < 10 { "0" + str(decimal) } else { str(decimal) }
|
||||
#let format-eur(cents) = {
|
||||
// 1. Gestione del segno
|
||||
let is-negative = cents < 0
|
||||
let abs-cents = calc.abs(cents)
|
||||
|
||||
return str(euros) + "," + decimal-str + " €"
|
||||
// 2. Calcolo parte intera e frazionaria
|
||||
// Usiamo calc.round per sicurezza se l'input non fosse un intero puro
|
||||
let total-abs = calc.round(abs-cents)
|
||||
let whole = calc.trunc(total-abs / 100)
|
||||
let frac = calc.rem(total-abs, 100)
|
||||
|
||||
// 3. Formattazione Migliaia (punti)
|
||||
let chars = str(whole).clusters()
|
||||
let formatted-whole = ""
|
||||
let count = 0
|
||||
for c in chars.rev() {
|
||||
if count > 0 and calc.rem(count, 3) == 0 {
|
||||
formatted-whole = "." + formatted-whole
|
||||
}
|
||||
formatted-whole = c + formatted-whole
|
||||
count += 1
|
||||
}
|
||||
|
||||
// 4. Formattazione Centesimi (sempre 2 cifre)
|
||||
// Usiamo lo zero-padding per casi come 5 centesimi -> "05"
|
||||
let frac-str = if frac < 10 { "0" + str(frac) } else { str(frac) }
|
||||
|
||||
// 5. Output finale
|
||||
let sign = if is-negative { "-" } else { "" }
|
||||
|
||||
// Ritorna il testo formattato
|
||||
[#sign #formatted-whole,#frac-str €]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue