feat: add pdf-lib dependency and implement PDF generation for receipt
This commit is contained in:
parent
37f0521e76
commit
ccc27ddcb5
9 changed files with 276 additions and 3 deletions
43
apps/infoalloggi/package-lock.json
generated
43
apps/infoalloggi/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
BIN
apps/infoalloggi/public/ricevuta_basic.pdf
Normal file
BIN
apps/infoalloggi/public/ricevuta_basic.pdf
Normal file
Binary file not shown.
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
78
apps/infoalloggi/src/hooks/useRicevutaUfficio.ts
Normal file
78
apps/infoalloggi/src/hooks/useRicevutaUfficio.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue