feat: add pdf-lib dependency and implement PDF generation for receipt

This commit is contained in:
Marco Pedone 2026-03-26 17:12:33 +01:00
parent 37f0521e76
commit ccc27ddcb5
9 changed files with 276 additions and 3 deletions

View file

@ -62,6 +62,7 @@
"nodemailer": "^8.0.1",
"nodemailer-html-to-text": "^3.2.0",
"nuqs": "^2.8.6",
"pdf-lib": "^1.17.1",
"pg": "^8.19.0",
"postcss": "^8.5.6",
"puppeteer-core": "^24.37.5",
@ -2995,6 +2996,24 @@
"win32"
]
},
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@ -11913,6 +11932,12 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -12037,6 +12062,24 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT"
},
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",

View file

@ -75,6 +75,7 @@
"nodemailer": "^8.0.1",
"nodemailer-html-to-text": "^3.2.0",
"nuqs": "^2.8.6",
"pdf-lib": "^1.17.1",
"pg": "^8.19.0",
"postcss": "^8.5.6",
"puppeteer-core": "^24.37.5",

Binary file not shown.

View file

@ -14,6 +14,7 @@ import {
} from "~/components/custom_ui/credenza";
import { Button } from "~/components/ui/button";
import { FormEditOrder } from "~/forms/FormEditOrdine";
import { genRicevutaUfficio } from "~/hooks/useRicevutaUfficio";
import { formatCurrency } from "~/lib/utils";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import type { Prezziario } from "~/schemas/public/Prezziario";
@ -90,6 +91,34 @@ const AdminOrdiniModal = ({
</Link>
</div>
)}
<Button
disabled={!data.isActive}
onClick={() => {
if (!data.isActive || !data.codice_fiscale) return;
genRicevutaUfficio({
date:
data.paymentmethod !== "manual" && data.paid_at
? data.paid_at.toLocaleDateString("it-IT")
: data.created_at.toLocaleDateString("it-IT"),
codice_fiscale: data.codice_fiscale,
indirizzo: data.indirizzo,
nominativo: data.username,
pack1_codice: data.packid,
pack1_qnt: "1",
pack1_iva: "22",
pack1_importo: (data.amount_cent / 100)
.toFixed(2)
.replace(".", ","),
totale: formatCurrency(data.amount_cent / 100),
pack1_titolo:
prezzi.find((p) => p.idprezziario === data.packid)
?.nome_it || "",
});
}}
variant="orange"
>
Ricevuta Ufficio
</Button>
<Button
disabled={!data.isActive}
onClick={() => sendReceipt({ ordineId: data.ordine_id })}

View file

@ -6,9 +6,14 @@ import {
CircleEllipsis,
ClockFading,
ExternalLink,
NotebookPen,
SearchX,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
import LoadingButton from "~/components/custom_ui/loading-button";
import { Textarea } from "~/components/custom_ui/textarea";
import { AnnuncioCardContent } from "~/components/servizio/annuncio_card";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
@ -34,7 +39,9 @@ import {
import { UserAvatar } from "~/components/user_avatar";
import { cn } from "~/lib/utils";
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import type { UsersId } from "~/schemas/public/Users";
import type { RichiesteData } from "~/server/controllers/servizio.controller";
import { api } from "~/utils/api";
type RichiesteTableProps = {
data: RichiesteData[];
@ -45,6 +52,7 @@ export const RichiesteTable = ({ data }: RichiesteTableProps) => {
const columns_titles = {
id: "ID Ordine",
username: "Utente",
note: "Note",
decorrenza: "Decorrenza",
status: "Status",
tipologia: "Tipo",
@ -65,6 +73,18 @@ export const RichiesteTable = ({ data }: RichiesteTableProps) => {
type Column = (typeof tabledata)[number];
const utils = api.useUtils();
const { mutate, isPending } = api.users.editUser.useMutation({
onSuccess: async () => {
toast.success("Note aggiornate con successo");
await utils.users.invalidate();
await utils.servizio.invalidate();
},
onError: (error) => {
toast.error(error.message);
},
});
const columns: ColumnDef<Column>[] = [
{
accessorKey: "id",
@ -100,6 +120,24 @@ export const RichiesteTable = ({ data }: RichiesteTableProps) => {
/>
),
},
{
accessorKey: "note",
cell: ({ row }) => {
const note = row.original.note;
return (
<NotePopover
isPending={isPending}
mutate={mutate}
note={note}
userId={row.original.user_id}
/>
);
},
header: ({ column }) => (
<DataTableColumnHeader column={column} title={columns_titles.note} />
),
enableSorting: false,
},
{
accessorKey: "decorrenza",
cell: ({ row }) => {
@ -338,3 +376,60 @@ export const RichiesteTable = ({ data }: RichiesteTableProps) => {
</div>
);
};
const NotePopover = ({
userId,
note,
mutate,
isPending,
}: {
userId: UsersId;
note: string | null;
mutate: (data: { id: UsersId; data: { note: string | null } }) => void;
isPending: boolean;
}) => {
const [newNote, setNewNote] = useState<string | null>(note);
return (
<Popover>
<PopoverTrigger asChild>
<div className="relative inline-flex">
<Button className="inline-flex" size="sm" variant="orange">
<NotebookPen className="size-4" />
</Button>
{note && note.trim().length > 0 && (
<span className="absolute top-0 right-0 -mt-1 -mr-1 flex size-3">
<span className="relative inline-flex size-3 rounded-full bg-green-500"></span>
</span>
)}
</div>
</PopoverTrigger>
<PopoverContent
align="start"
className="max-h-[50vh] w-[80vw] overflow-y-auto sm:max-h-full sm:w-lg"
>
<PopoverHeader>
<PopoverTitle>Annunci Inseriti</PopoverTitle>
<PopoverDescription className="sr-only">Desc</PopoverDescription>
</PopoverHeader>
<div className="flex w-full flex-col gap-4">
<Textarea
className="min-h-64"
id="note"
onChange={(e) => setNewNote(e.target.value)}
placeholder=""
value={newNote || ""}
/>
<LoadingButton
disabled={newNote === note}
loading={isPending}
onClick={() => mutate({ id: userId, data: { note: newNote } })}
variant="success"
>
Salva
</LoadingButton>
</div>
</PopoverContent>
</Popover>
);
};

View file

@ -0,0 +1,78 @@
import { degrees, PDFDocument } from "pdf-lib";
type RicevutaData = {
codice_fiscale: string;
date: string;
indirizzo: string;
nominativo: string;
pack1_codice: string;
pack1_titolo: string;
pack1_qnt: string;
pack1_importo: string;
pack1_iva: string;
totale: string;
};
export const genRicevutaUfficio = async (data: RicevutaData) => {
try {
// 1. Fetch the PDF template
const formUrl = "/ricevuta_basic.pdf";
const formPdfBytes = await fetch(formUrl).then((res) => res.arrayBuffer());
// 2. Load the PDFDocument
const pdfDoc = await PDFDocument.load(formPdfBytes);
// 3. Get the form
const form = pdfDoc.getForm();
console.log(form.getFields().map((field) => field.getName()));
// 4. Safely fill fields with Type Casting
// We use try/catch or optional checks in case the field names change in the PDF
const codiceFiscaleField = form.getTextField("codice_fiscale");
const dataField = form.getTextField("data");
const indirizzoField = form.getTextField("indirizzo");
const nominativoField = form.getTextField("nominativo");
const pack1CodiceField = form.getTextField("pack1_codice");
const pack1TitoloField = form.getTextField("pack1_titolo");
const pack1QntField = form.getTextField("pack1_qnt");
const pack1ImportoField = form.getTextField("undefined.pack1_importo");
const pack1IvaField = form.getTextField("pack1_iva");
const totaleField = form.getTextField("undefined.totale");
if (codiceFiscaleField) codiceFiscaleField.setText(data.codice_fiscale);
if (dataField) dataField.setText(data.date);
if (indirizzoField) indirizzoField.setText(data.indirizzo);
if (nominativoField) nominativoField.setText(data.nominativo);
if (pack1CodiceField) pack1CodiceField.setText(data.pack1_codice);
if (pack1TitoloField) pack1TitoloField.setText(data.pack1_titolo);
if (pack1QntField) pack1QntField.setText(data.pack1_qnt);
if (pack1ImportoField) pack1ImportoField.setText(data.pack1_importo);
if (pack1IvaField) pack1IvaField.setText(data.pack1_iva);
if (totaleField) totaleField.setText(data.totale);
// 5. Flatten the form (prevents users from editing the values in the preview)
//form.flatten();
const pages = pdfDoc.getPages();
pages[0]?.setRotation(degrees(180));
// 6. Serialize to Uint8Array
const pdfBytes: Uint8Array = await pdfDoc.save();
// 7. Create Blob and Open in New Tab
const blob = new Blob([pdfBytes.buffer as ArrayBuffer], {
type: "application/pdf",
});
const url = URL.createObjectURL(blob);
const newTab = window.open(url, "_blank");
// Focus the new tab if it was blocked or loses focus
if (newTab) {
newTab.focus();
} else {
alert("Please allow popups to view the PDF");
}
} catch (error) {
console.error("Error generating PDF:", error);
}
};

View file

@ -5,6 +5,10 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const Capitalize = (str: string | null | undefined) => {
if (!str) return "";
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
};
export const redirectTo500 = {
redirect: {
destination: "/500",

View file

@ -1448,7 +1448,7 @@ export const getOrdineRicevutaData = async ({
}
};
export type RichiesteData = Servizio &
Pick<Users, "username"> & {
Pick<Users, "username" | "note"> & {
annunci_servizio: ServizioAnnuncioData[];
ordini_servizio: (Ordini & Pick<Prezziario, "testo_condizioni">)[];
};
@ -1460,7 +1460,7 @@ export const getRichieste = async (): Promise<RichiesteData[]> => {
.selectFrom("servizio")
.innerJoin("users", "users.id", "servizio.user_id")
.selectAll("servizio")
.select(["users.username"])
.select(["users.username", "users.note"])
.select((eb) => [
jsonArrayFrom(
eb

View file

@ -1,5 +1,7 @@
import { TRPCError } from "@trpc/server";
import type { Insertable } from "kysely";
import { parseDBComune, parseDBNazione } from "~/lib/catasto";
import { Capitalize } from "~/lib/utils";
import type OrdiniTable from "~/schemas/public/Ordini";
import type {
Ordini,
@ -7,6 +9,7 @@ import type {
OrdiniUpdate,
} from "~/schemas/public/Ordini";
import type { Users, UsersId } from "~/schemas/public/Users";
import { getComuni, getNazioni } from "~/server/controllers/catasto.controller";
import { db } from "~/server/db";
export type ServizioOrdiniTable = Omit<OrdiniTable, "rinnovo_id">;
@ -43,16 +46,36 @@ export const getOrdini = async (
export const getOrdineById = async (ordineId: OrdiniOrdineId) => {
try {
return await db
const data = await db
.selectFrom("ordini")
.selectAll("ordini")
.innerJoin("users", "users.id", "ordini.userid")
.select("users.username")
.innerJoin("users_anagrafica", "users_anagrafica.userid", "users.id")
.select([
"users_anagrafica.codice_fiscale",
"users_anagrafica.via_residenza",
"users_anagrafica.civico_residenza",
"users_anagrafica.comune_residenza",
"users_anagrafica.provincia_residenza",
"users_anagrafica.cap_residenza",
"users_anagrafica.nazione_residenza",
])
.where("ordini.ordine_id", "=", ordineId)
.orderBy("created_at", "desc")
.executeTakeFirstOrThrow(
() => new Error(`Ordine not found - ordineId: ${ordineId}`),
);
const comuni = await getComuni();
const nazioni = await getNazioni();
const parsedComune = parseDBComune(data.comune_residenza, comuni);
const parsedNazione = parseDBNazione(data.nazione_residenza, nazioni);
return {
...data,
indirizzo: `${data.via_residenza} ${data.civico_residenza}, ${Capitalize(parsedComune?.nome)} (${data.provincia_residenza}) ${data.cap_residenza}, ${Capitalize(parsedNazione?.nome)}`,
};
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",