feat: add order editing form and payment processing pages

- Implemented FormEditOrder component for editing orders with validation and submission handling.
- Created SchedaAnnuncioPage for displaying printable announcement details.
- Added BonificoManualePage for manual bank transfer payment instructions.
- Developed PagamentoPage for Stripe payment processing with PaymentIntent creation.
- Introduced PreviewPurchasePage for preparing payment with service details.
- Added pagamenti.controller for handling payment preparation and retrieval of payment data.
This commit is contained in:
Marco Pedone 2026-03-08 01:02:57 +01:00
parent 6211be289c
commit 6c36bb9b74
64 changed files with 2647 additions and 2546 deletions

View file

@ -0,0 +1,28 @@
ALTER TABLE IF EXISTS public.servizio
ADD COLUMN "onboardOk" boolean NOT NULL DEFAULT FALSE;
ALTER TABLE IF EXISTS public.ordini
ADD COLUMN amount_cent integer NOT NULL;
ALTER TABLE IF EXISTS public.ordini ADD COLUMN paymentmethod TEXT;
ALTER TABLE IF EXISTS public.ordini
ADD COLUMN paid_at TIMESTAMP WITHOUT TIME ZONE;
ALTER TABLE IF EXISTS public.ordini ADD COLUMN intent_id TEXT;
ALTER TABLE IF EXISTS public.ordini
ADD COLUMN paymentstatus "PaymentStatusEnum";
ALTER TABLE IF EXISTS public.ordini
ADD COLUMN condizioni TEXT NOT NULL;
ALTER TABLE IF EXISTS public.ordini
ADD COLUMN sconto integer NOT NULL DEFAULT 0;
INSERT INTO
public.flags (id, VALUE)
VALUES (
'STRIPE_DISABLED'::TEXT,
FALSE::boolean
);

View file

@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.2/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.6/schema.json",
"assist": {
"actions": {
"source": {

View file

@ -28,6 +28,7 @@ async function createNextConfig(): Promise<NextConfig> {
scrollRestoration: true,
// esmExternals: "loose", // This if react-pdf gives compilation issues
},
allowedDevOrigins: ["host.docker.internal"],
generateBuildId: async () => buildId,
devIndicators: false,
headers: async () => {

View file

@ -80,7 +80,6 @@
"react-phone-number-input": "^3.4.14",
"react-reverse-portal": "^2.3.0",
"react-select": "^5.10.2",
"react-to-print": "^3.2.0",
"react-use": "^17.6.0",
"sharp": "^0.34.5",
"stripe": "^20.4.0",
@ -96,11 +95,9 @@
"zod": "^4.1.12"
},
"devDependencies": {
"@biomejs/biome": "^2.4.2",
"@biomejs/biome": "^2.4.6",
"@hookform/devtools": "^4.4.0",
"@react-email/preview-server": "^5.2.8",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@total-typescript/ts-reset": "^0.6.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.2.0",
@ -542,9 +539,9 @@
}
},
"node_modules/@biomejs/biome": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.2.tgz",
"integrity": "sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w==",
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.6.tgz",
"integrity": "sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
@ -558,20 +555,20 @@
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.4.2",
"@biomejs/cli-darwin-x64": "2.4.2",
"@biomejs/cli-linux-arm64": "2.4.2",
"@biomejs/cli-linux-arm64-musl": "2.4.2",
"@biomejs/cli-linux-x64": "2.4.2",
"@biomejs/cli-linux-x64-musl": "2.4.2",
"@biomejs/cli-win32-arm64": "2.4.2",
"@biomejs/cli-win32-x64": "2.4.2"
"@biomejs/cli-darwin-arm64": "2.4.6",
"@biomejs/cli-darwin-x64": "2.4.6",
"@biomejs/cli-linux-arm64": "2.4.6",
"@biomejs/cli-linux-arm64-musl": "2.4.6",
"@biomejs/cli-linux-x64": "2.4.6",
"@biomejs/cli-linux-x64-musl": "2.4.6",
"@biomejs/cli-win32-arm64": "2.4.6",
"@biomejs/cli-win32-x64": "2.4.6"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.2.tgz",
"integrity": "sha512-3pEcKCP/1POKyaZZhXcxFl3+d9njmeAihZ17k8lL/1vk+6e0Cbf0yPzKItFiT+5Yh6TQA4uKvnlqe0oVZwRxCA==",
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.6.tgz",
"integrity": "sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ==",
"cpu": [
"arm64"
],
@ -586,9 +583,9 @@
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.2.tgz",
"integrity": "sha512-P7hK1jLVny+0R9UwyGcECxO6sjETxfPyBm/1dmFjnDOHgdDPjPqozByunrwh4xPKld8sxOr5eAsSqal5uKgeBg==",
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.6.tgz",
"integrity": "sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw==",
"cpu": [
"x64"
],
@ -603,9 +600,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.2.tgz",
"integrity": "sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==",
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.6.tgz",
"integrity": "sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew==",
"cpu": [
"arm64"
],
@ -620,9 +617,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.2.tgz",
"integrity": "sha512-/x04YK9+7erw6tYEcJv9WXoBHcULI/wMOvNdAyE9S3JStZZ9yJyV67sWAI+90UHuDo/BDhq0d96LDqGlSVv7WA==",
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.6.tgz",
"integrity": "sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A==",
"cpu": [
"arm64"
],
@ -637,9 +634,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.2.tgz",
"integrity": "sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==",
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.6.tgz",
"integrity": "sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw==",
"cpu": [
"x64"
],
@ -654,9 +651,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.2.tgz",
"integrity": "sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==",
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.6.tgz",
"integrity": "sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg==",
"cpu": [
"x64"
],
@ -671,9 +668,9 @@
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.2.tgz",
"integrity": "sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==",
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.6.tgz",
"integrity": "sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg==",
"cpu": [
"arm64"
],
@ -688,9 +685,9 @@
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.2.tgz",
"integrity": "sha512-9ma7C4g8Sq3cBlRJD2yrsHXB1mnnEBdpy7PhvFrylQWQb4PoyCmPucdX7frvsSBQuFtIiKCrolPl/8tCZrKvgQ==",
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.6.tgz",
"integrity": "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg==",
"cpu": [
"x64"
],
@ -5879,55 +5876,6 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/react": {
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@testing-library/dom": "^10.0.0",
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@tiptap/core": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.0.tgz",
@ -6461,13 +6409,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -6969,16 +6910,6 @@
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/array-buffer-byte-length": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
@ -8194,13 +8125,6 @@
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@ -10962,16 +10886,6 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -12407,41 +12321,6 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/pretty-format/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
},
"node_modules/prism-react-renderer": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz",
@ -13478,15 +13357,6 @@
}
}
},
"node_modules/react-to-print": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/react-to-print/-/react-to-print-3.2.0.tgz",
"integrity": "sha512-IX2D0mebKMgYTBD6s5tf9B7YRL3RFWjRoevYK8JKgRwn94Rep7PFZyeOTGjCmXofKB1SKzvPSzDrAMG4I2PIwg==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ~19"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",

View file

@ -93,7 +93,6 @@
"react-phone-number-input": "^3.4.14",
"react-reverse-portal": "^2.3.0",
"react-select": "^5.10.2",
"react-to-print": "^3.2.0",
"react-use": "^17.6.0",
"sharp": "^0.34.5",
"stripe": "^20.4.0",
@ -109,11 +108,9 @@
"zod": "^4.1.12"
},
"devDependencies": {
"@biomejs/biome": "^2.4.2",
"@biomejs/biome": "^2.4.6",
"@hookform/devtools": "^4.4.0",
"@react-email/preview-server": "^5.2.8",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@total-typescript/ts-reset": "^0.6.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.2.0",

View file

@ -3,12 +3,13 @@
import { format } from "date-fns";
import { it } from "date-fns/locale";
import Image from "next/image";
import { useRef } from "react";
import { useReactToPrint } from "react-to-print";
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 { api } from "~/utils/api";
export function ReceiptGenerator({
data,
@ -17,10 +18,35 @@ export function ReceiptGenerator({
data: PurchaseData;
raw: boolean;
}) {
const contentRef = useRef<HTMLDivElement>(null);
const reactToPrintFn = useReactToPrint({
contentRef,
//print: async (target) => {console.log("Printing...", target.contentDocument);},
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-${format(data.date, "yyyy-MM-dd")}.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} />;
@ -31,12 +57,12 @@ export function ReceiptGenerator({
<div className="flex items-center justify-between">
<h1 className="font-bold text-2xl">Ricevuta di cortesia</h1>
<Button className="print:hidden" onClick={() => reactToPrintFn()}>
<Button onClick={() => generatePdf({ ordineId: data.ordineId })}>
Stampa
</Button>
</div>
<div ref={contentRef}>
<Card className="p-4 print:border-none print:shadow-none">
<div>
<Card className="p-4">
<Receipt data={data} />
</Card>
</div>
@ -52,7 +78,7 @@ interface Item {
}
export interface PurchaseData {
paymentId: string;
ordineId: OrdiniOrdineId;
date: Date;
clienteNome: string;
@ -102,8 +128,8 @@ function Receipt({ data }: ReceiptProps) {
</h2>
<div className="mt-2 flex justify-between">
<div>
<p className="text-muted-foreground text-sm">ID Pagamento</p>
<p className="text-sm">{data.paymentId}</p>
<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>

View file

@ -192,23 +192,13 @@ export const InteressatoButtonServizio = ({
}: {
annuncioId: AnnunciId;
}) => {
const { isAdmin, servizioId, userId } = useServizio();
const { isAdmin, servizioId } = useServizio();
const { locale } = useTranslation();
const utils = api.useUtils();
const invalidateAll = async () => {
await utils.intrests.getUserInterests.invalidate();
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
await utils.servizio.getServizioData.invalidate({servizioId});
await utils.servizio.getAnnunciAvailableToAdd.invalidate({
servizioId,
});
await utils.servizio.AnnuncioServizioTipologiaMatch.invalidate({
userId,
});
await utils.servizio.getCompatibileAnnunci.invalidate({
servizioId,
});
await utils.servizio.invalidate();
};
const { mutate: adminAdd, isPending: isAdminPending } =
api.servizio.addServizioAnnunci.useMutation({

View file

@ -492,7 +492,10 @@ export const CarouselAnnuncio = ({
media: "image",
},
})}
key={`videoplayer-${idx}-${openModal}`}
key={`videoplayer-${
// biome-ignore lint/suspicious/noArrayIndexKey: <ok>
idx
}-${openModal}`}
videoSrc={getStorageUrl({
storageId: video.video,
params: {

View file

@ -1,88 +0,0 @@
import { useState } from "react";
import { FormNewOrder } from "~/forms/FormNewOrdine";
import type { ServizioServizioId } from "~/schemas/public/Servizio";
import type { UsersId } from "~/schemas/public/Users";
import { api } from "~/utils/api";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTrigger,
} from "../custom_ui/credenza";
import { LoadingPage } from "../loading";
import { Button } from "../ui/button";
export const OrdineManualeDialog = ({
userId,
servizioId,
}: {
userId: UsersId;
servizioId: ServizioServizioId;
}) => {
const [open, setOpen] = useState(false);
return (
<Credenza onOpenChange={setOpen} open={open}>
<CredenzaTrigger asChild>
<Button size="sm" variant="outline">
Nuovo Ordine Manuale
</Button>
</CredenzaTrigger>
<CredenzaContent className="max-h-[90vh] w-full px-3 sm:max-w-5xl">
<CredenzaHeader>
<CredenzaTitle>Nuovo Ordine Manuale</CredenzaTitle>
<CredenzaDescription className="sr-only">
nuovo ordine modal
</CredenzaDescription>
</CredenzaHeader>
<Content servizioId={servizioId} setOpen={setOpen} userId={userId} />
</CredenzaContent>
</Credenza>
);
};
const Content = ({
userId,
servizioId,
setOpen,
}: {
userId: UsersId;
servizioId: ServizioServizioId;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
const { data, isLoading } = api.prezziario.getPrezziario.useQuery();
if (isLoading) return <LoadingPage />;
if (!data)
return (
<>
<CredenzaBody className="max-h-[80vh] w-full overflow-y-auto pb-5">
<p className="text-center text-muted-foreground text-sm">
Errore nel caricamento del prezziario.
</p>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button aria-label="Close Credenza" variant="outline">
Chiudi
</Button>
</CredenzaClose>
</CredenzaFooter>
</>
);
return (
<FormNewOrder
prezziario={data}
servizioId={servizioId}
setOpen={setOpen}
userId={userId}
/>
);
};

View file

@ -1,7 +1,6 @@
import { CircleCheck, CircleSlash, ReceiptText, Trash2 } from "lucide-react";
import { CircleCheck, CircleSlash, ReceiptText } from "lucide-react";
import Link from "next/link";
import toast from "react-hot-toast";
import { Confirm } from "~/components/confirm";
import type { Dispatch, SetStateAction } from "react";
import {
Credenza,
CredenzaBody,
@ -12,87 +11,93 @@ import {
CredenzaHeader,
CredenzaTitle,
} from "~/components/custom_ui/credenza";
import { PaymentsTable } from "~/components/tables/payments-table";
import { Button } from "~/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { cn } from "~/lib/utils";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import { FormEditOrder } from "~/forms/FormEditOrdine";
import { formatCurrency } from "~/lib/utils";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum";
import type { UsersId } from "~/schemas/public/Users";
import type { Prezziario } from "~/schemas/public/Prezziario";
import { api } from "~/utils/api";
export const OrdiniModal = ({
isAdmin,
open,
setModalOpen,
export const AdminWrapperOrdiniModal = ({
ordineId,
userId,
setSelected,
}: {
isAdmin: boolean;
open: boolean;
setModalOpen: (open: boolean) => void;
ordineId: OrdiniOrdineId;
userId: UsersId | undefined;
ordineId: OrdiniOrdineId | null;
setSelected: Dispatch<SetStateAction<OrdiniOrdineId | null>>;
}) => {
const utils = api.useUtils();
const { mutate: remove } = api.servizio.removeOrder.useMutation({
onError: (error) => {
toast.error(`Errore durante la rimozione ordine: ${error.message}`);
},
onSuccess: async () => {
toast.success("Ordine rimosso con successo!");
await utils.servizio.getOrdini.invalidate({
userId,
});
setModalOpen(false);
},
});
const { mutate: update } = api.servizio.updateOrder.useMutation({
onError: (error) => {
toast.error(`Errore durante la modifica: ${error.message}`);
},
onSuccess: async (data) => {
if (data.type === OrderTypeEnum.Acconto && data.isActive) {
updateServizio({
data: {
decorrenza: new Date(),
isOkAcconto: true,
},
servizioId: data.servizio_id,
});
}
toast.success("Ordine aggiornato con successo!");
await utils.servizio.getOrdini.invalidate({
userId,
});
await utils.servizio.getOrdineById.invalidate({
const { data: prezzi, isLoading } = api.prezziario.getPrezziario.useQuery();
if (isLoading || !ordineId || !prezzi) return null;
return (
<AdminOrdiniModal
ordineId={ordineId}
prezzi={prezzi}
setSelected={setSelected}
/>
);
};
const AdminOrdiniModal = ({
prezzi,
setSelected,
ordineId,
}: {
prezzi: Prezziario[];
setSelected: Dispatch<SetStateAction<OrdiniOrdineId | null>>;
ordineId: OrdiniOrdineId;
}) => {
const { data } = api.servizio.getOrdineById.useQuery({
ordineId,
});
},
});
const { mutate: updateServizio } = api.servizio.updateServizio.useMutation({
onError: (error) => {
toast.error(error.message);
},
onSuccess: async (data) => {
toast.success("Servizio modificato con successo");
await utils.servizio.getUserServizi.invalidate();
await utils.servizio.getPacksSolution.invalidate({
servizioId: data.servizio_id,
});
},
});
if (!data) return null;
return (
<Credenza
onOpenChange={(b) => {
if (!b) setSelected(null);
}}
open={ordineId !== null}
>
<CredenzaContent className="max-h-[90vh] max-w-2xl sm:max-w-4xl">
<CredenzaHeader>
<CredenzaTitle>Dettagli Ordine</CredenzaTitle>
<CredenzaDescription>
Visualizza i dettagli e i pagamenti associati.
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody className="max-h-[80vh] space-y-2 overflow-auto pb-5">
<p>Id Ordine: {data.ordine_id}</p>
{data.isActive &&
data.paymentstatus === PaymentStatusEnum.success && (
<div>
<Link
href={`/servizio/ricevuta-cortesia/${data.ordine_id}`}
target="_blank"
>
<Button variant="outline">
Ricevuta di cortesia <ReceiptText />
</Button>
</Link>
</div>
)}
<FormEditOrder
close={() => setSelected(null)}
data={data}
prezziario={prezzi}
/>
</CredenzaBody>
</CredenzaContent>
</Credenza>
);
};
export const UserOrdiniModal = ({
setSelected,
ordineId,
}: {
setSelected: (ordineId: OrdiniOrdineId | null) => void;
ordineId: OrdiniOrdineId;
}) => {
const { data } = api.servizio.getOrdineById.useQuery({
ordineId,
});
@ -101,9 +106,9 @@ export const OrdiniModal = ({
return (
<Credenza
onOpenChange={(b) => {
setModalOpen(b);
if (!b) setSelected(null);
}}
open={open}
open={ordineId !== null}
>
<CredenzaContent className="max-h-[90vh] max-w-2xl sm:max-w-4xl">
<CredenzaHeader>
@ -115,12 +120,7 @@ export const OrdiniModal = ({
<CredenzaBody className="max-h-[80vh] space-y-2 overflow-auto pb-5">
<p>Id Ordine: {data.ordine_id}</p>
<p>
Data Ordine:
{new Date(data.created_at).toLocaleDateString("it-IT", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
Data Ordine: {new Date(data.created_at).toLocaleDateString("it-IT")}
</p>
<p>Tipologia: {data.type.toString()}</p>
<p>Pack ID: {data.packid}</p>
@ -138,11 +138,12 @@ export const OrdiniModal = ({
</span>
)}
</p>
<p>
Importo: {formatCurrency(data.amount_cent / 100)}{" "}
{data.sconto > 0 && <span>({data.sconto}%)</span>}
</p>
{data.isActive &&
data.pagamenti.find(
(p) => p.paymentstatus === PaymentStatusEnum.success,
) && (
data.paymentstatus === PaymentStatusEnum.success && (
<div>
<Link
href={`/servizio/ricevuta-cortesia/${data.ordine_id}`}
@ -154,59 +155,8 @@ export const OrdiniModal = ({
</Link>
</div>
)}
<PaymentsTable data={data.pagamenti} isAdmin={isAdmin} isUserView />
</CredenzaBody>
<CredenzaFooter
className={cn(
"flex w-full",
isAdmin ? "sm:justify-between" : "sm:justify-end",
)}
>
{isAdmin && (
<div className="flex items-center gap-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button aria-label="Modifica Status" variant="outline">
Modifica Status
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Status</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
onValueChange={(v) => {
update({
data: { isActive: v === "true" },
ordineId,
});
}}
value={data.isActive ? "true" : "false"}
>
<DropdownMenuRadioItem value="true">
Attivo
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="false">
Non Attivo
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Confirm
description="Sei sicuro di voler eliminare l'ordine?"
onConfirm={() => {
remove({ ordineId });
}}
title="Elimina ordine"
>
<Button aria-label="Elimina Ordine" variant="destructive">
<Trash2 className="size-6" />
Elimina Ordine
</Button>
</Confirm>
</div>
)}
<CredenzaFooter className="flex w-full sm:justify-end">
<CredenzaClose asChild>
<Button variant="ghost">Chiudi</Button>
</CredenzaClose>

View file

@ -93,7 +93,10 @@ export const ChatAttachments = ({
return (
<li
className="flex flex-row items-center justify-between border-b py-1"
key={`${file.originalName}-${idx}`}
key={`${file.originalName}-${
// biome-ignore lint/suspicious/noArrayIndexKey: <ok>
idx
}`}
>
<Link
aria-label="Visualizza Allegato"

View file

@ -1,3 +1,4 @@
import { TriangleAlertIcon } from "lucide-react";
import type { ReactNode } from "react";
import {
AlertDialog,
@ -10,8 +11,6 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { buttonVariants } from "~/components/ui/button";
import { cn } from "~/lib/utils";
export const Confirm = ({
children,
title,
@ -42,18 +41,20 @@ export const Confirm = ({
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
)}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogHeader className="place-items-center! items-center">
<div className="mx-auto mb-2 flex size-12 items-center justify-center rounded-full bg-destructive/10">
<TriangleAlertIcon className="size-6 text-destructive" />
</div>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
<AlertDialogDescription className="text-center">
{description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => onCancel?.()}>
{cancelText || "Annulla"}
</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={() => onConfirm()}
>
<AlertDialogAction onClick={() => onConfirm()} variant="destructive">
{confirmText || "Conferma"}
</AlertDialogAction>
</AlertDialogFooter>

View file

@ -38,7 +38,7 @@ export function DataTableViewOptions<TData>({
Filtro
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[150px]">
<DropdownMenuContent align="end" className="w-37.5">
<DropdownMenuLabel>Filtra colonne</DropdownMenuLabel>
<DropdownMenuSeparator />
{hidableColumns.map((column) => {

View file

@ -78,7 +78,10 @@ export const PDFViewer = ({ url }: { url: string }) => {
>
{Array.from(new Array(numPages), (_el, index) => (
<Page
key={`page_${index + 1}`}
key={`page_${
// biome-ignore lint/suspicious/noArrayIndexKey: <ok>
index + 1
}`}
pageNumber={index + 1}
width={width}
/>

View file

@ -52,7 +52,6 @@ import {
} from "../ui/card";
import { Label } from "../ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Switch } from "../ui/switch";
import { UploadModal } from "../upload_modal";
import { ServizioPacksInfos } from "./servizio_actions";
import { DocCombo, DocInfo } from "./shared";
@ -106,7 +105,7 @@ export const AdminLavoraConferma = () => {
};
const DebugActions = () => {
const { servizioId, userId } = useServizio();
const { servizioId } = useServizio();
const { annuncioId } = useServizioAnnuncio();
const utils = api.useUtils();
const { mutate } = api.servizio.updateServizioAnnunci.useMutation({
@ -114,10 +113,7 @@ const DebugActions = () => {
toast.error(error.message);
},
onSuccess: async () => {
await utils.servizio.getAllServizioAnnunci.invalidate({
userId: userId,
});
await utils.servizio.getServizioData.invalidate({ servizioId });
await utils.servizio.invalidate();
toast.success("Dati salvati con successo");
},
@ -166,6 +162,10 @@ const DebugActions = () => {
const DocSection = () => {
const { userId } = useServizio();
const { data, updateServizioAnnunci } = useServizioAnnuncio();
const initialDoc = data.doc_conferma_ref;
const [selectedDoc, setSelectedDoc] = useState<string | null>(
data.doc_conferma_ref,
);
const utils = api.useUtils();
@ -181,6 +181,18 @@ const DocSection = () => {
},
});
const handleSave = async () => {
await updateServizioAnnunci({
doc_conferma_added: selectedDoc != null,
doc_conferma_ref: selectedDoc,
});
if (selectedDoc) {
setUserStorage({
storageId: selectedDoc,
userId,
});
}
};
if (isLoading) return <LoadingPage />;
if (files === undefined) {
return <p>Errore nel caricamento dei documenti</p>;
@ -194,55 +206,69 @@ const DocSection = () => {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{data.doc_conferma_ref && (
<div className="flex flex-wrap items-start gap-3">
<DocInfo storageId={data.doc_conferma_ref}>
{selectedDoc && (
<div className="flex flex-wrap items-center gap-3">
<DocInfo storageId={selectedDoc}>
<Button
aria-label="Reset Conferma"
aria-label="Reset Contratto"
className="flex items-center gap-3"
onClick={async () => {
await updateServizioAnnunci({
doc_conferma_added: false,
doc_conferma_ref: null,
});
setSelectedDoc(null);
}}
variant="destructive"
>
<Trash2 />
<Trash2 className="size-6" />
</Button>
</DocInfo>
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<DocCombo
current={data.doc_conferma_ref}
current={selectedDoc}
files={files}
onSelect={async (f) => {
await updateServizioAnnunci({
doc_conferma_added: true,
doc_conferma_ref: f.id,
});
setUserStorage({
storageId: f.id,
userId,
});
onSelect={(f) => {
setSelectedDoc(f.id);
}}
/>
<UploadModal
cb_onUpload={async ({ storageIds }) => {
cb_onUpload={({ storageIds }) => {
if (storageIds.length === 1 && storageIds[0]) {
await updateServizioAnnunci({
doc_conferma_added: true,
doc_conferma_ref: storageIds[0],
});
setSelectedDoc(storageIds[0]);
}
}}
isAdmin
userId={userId}
/>
</div>
</CardContent>
<CardFooter className="gap-4">
<Button
aria-label="Save Contratto"
disabled={selectedDoc === initialDoc}
onClick={async () => {
await handleSave();
}}
variant="success"
>
Salva
</Button>
{selectedDoc !== initialDoc ? (
<div className="flex items-center gap-2">
<CircleAlert className="size-5 text-orange-500" />
<span className="text-orange-500">Salva modifiche</span>
</div>
) : (
initialDoc &&
selectedDoc === initialDoc && (
<div className="flex items-center gap-2">
<CircleCheck className="size-5 text-green-500" />
<span className="text-green-500">Dati salvati</span>
</div>
)
)}
</CardFooter>
</Card>
);
};
@ -539,7 +565,7 @@ const TransitorioSection = () => {
const { servizio } = useServizio();
const { data } = useServizioAnnuncio();
const { t } = useTranslation();
const { servizioId, userId } = useServizio();
const { servizioId } = useServizio();
const utils = api.useUtils();
const { mutate: update } = api.servizio.updateServizio.useMutation({
onError: (error) => {
@ -547,10 +573,7 @@ const TransitorioSection = () => {
},
onSuccess: async () => {
toast.success("Servizio modificato con successo");
await utils.servizio.getUserServizi.invalidate();
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
await utils.servizio.getServizioData.invalidate({ servizioId });
await utils.servizio.getPacksSolution.invalidate({ servizioId });
await utils.servizio.invalidate();
},
});
@ -604,7 +627,7 @@ const TransitorioSection = () => {
};
const StabileSection = () => {
const { servizio, servizioId, userId } = useServizio();
const { servizio, servizioId } = useServizio();
const { data } = useServizioAnnuncio();
const [value, setValue] = useState(servizio.budget);
const utils = api.useUtils();
@ -614,10 +637,7 @@ const StabileSection = () => {
},
onSuccess: async () => {
toast.success("Servizio modificato con successo");
await utils.servizio.getUserServizi.invalidate();
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
await utils.servizio.getServizioData.invalidate({ servizioId });
await utils.servizio.getPacksSolution.invalidate({ servizioId });
await utils.servizio.invalidate();
},
});
@ -664,7 +684,7 @@ const GeneralSection = () => {
const { data } = useServizioAnnuncio();
const { t } = useTranslation();
const { servizio, servizioId, userId } = useServizio();
const { servizio, servizioId } = useServizio();
const utils = api.useUtils();
const { mutate: update } = api.servizio.updateServizio.useMutation({
onError: (error) => {
@ -672,10 +692,7 @@ const GeneralSection = () => {
},
onSuccess: async () => {
toast.success("Servizio modificato con successo");
await utils.servizio.getUserServizi.invalidate();
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
await utils.servizio.getServizioData.invalidate({ servizioId });
await utils.servizio.getPacksSolution.invalidate({ servizioId });
await utils.servizio.invalidate();
},
});
@ -795,10 +812,7 @@ const SendConferma = () => {
const { mutate: handleUpdate } =
api.servizio.adminUpdateConfermaStatus.useMutation({
onSuccess: async () => {
await utils.servizio.getAllServizioAnnunci.invalidate();
await utils.servizio.getServizioData.invalidate({
servizioId: servizio.servizio_id,
});
await utils.servizio.invalidate();
toast.success("Status aggiornato con successo");
},
@ -848,24 +862,28 @@ const SendConferma = () => {
completi
</p>
)}
<p className="font-semibold text-lg">
ATTENZIONE: Il saldo verrà aggiunto (se necessario), in base ai
parametri, all'invio della conferma
</p>
<div className="mt-4 flex items-center gap-3">
<Switch
aria-label="Conferma Lavorata"
checked={data.hasConfermaAdmin}
disabled={!docOk || !caparraOk}
id="conferma"
onCheckedChange={(c) => {
<Button
aria-label="Send Conferma"
disabled={!docOk || !caparraOk || data.hasConfermaAdmin}
onClick={() => {
handleUpdate({
servizioId: servizio.servizio_id,
annuncioId: data.id,
status: c,
status: true,
});
}}
/>
<Label className="text-base" htmlFor="conferma">
Imposta Conferma come Lavorata
</Label>
size="lg"
variant="orange"
>
<CircleAlert className="size-5" />
<span>Invia la conferma</span>
</Button>
</div>
</div>
</CardContent>

View file

@ -278,7 +278,7 @@ const ContrattoImport = () => {
<CardDescription className="sr-only">desc</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
<Button
aria-label="Import Contratto"
onClick={async () => {
@ -289,7 +289,7 @@ const ContrattoImport = () => {
variant="info"
>
<ClipboardPaste className="size-4" />
Clicca qui per importare la stringa dal gestionale
Incolla la stringa dal gestionale
</Button>
<Button
aria-label="Reset Contratto"

View file

@ -11,7 +11,6 @@ import {
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import toast from "react-hot-toast";
import { Button, type ButtonConfigs } from "~/components/ui/button";
import {
@ -24,6 +23,7 @@ import { cn } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import { useServizio, useServizioAnnuncio } from "~/providers/ServizioProvider";
import type { AnnunciId } from "~/schemas/public/Annunci";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import { api } from "~/utils/api";
import { Confirm } from "../confirm";
import { AdminLavoraConferma } from "./admin_conferma";
@ -32,7 +32,7 @@ import { ConfermaAnnuncioModal, ConfermaInLavorazioneModal } from "./conferma";
import { ContattiProprietarioModal } from "./modale_contatti";
export const AdminActions = () => {
const { servizioId, userId, isAdmin } = useServizio();
const { servizioId, isAdmin } = useServizio();
const { data, annuncioId } = useServizioAnnuncio();
const utils = api.useUtils();
@ -42,11 +42,7 @@ export const AdminActions = () => {
toast.error("Errore durante la rimozione dell'annuncio");
},
onSuccess: async () => {
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
await utils.servizio.getServizioData.invalidate({ servizioId });
await utils.servizio.getCompatibileAnnunci.invalidate({
servizioId,
});
await utils.servizio.invalidate();
toast.success("Annuncio rimosso");
},
});
@ -54,7 +50,7 @@ export const AdminActions = () => {
return (
<div className="flex w-full max-w-fit flex-wrap justify-start gap-3">
{data.open_contatti_at != null && <AdminSendScheda />}
{<AdminSendScheda />}
{data.user_confirmed_at != null && <AdminLavoraConferma />}
{data.accettato_conferma_at != null && (
<>
@ -93,7 +89,7 @@ export const AdminActions = () => {
</Link>
<Link
href={`/area-riservata/admin/scheda-annuncio-stampa/${data.id}`}
href={`/area-riservata/scheda-annuncio-stampa/${data.id}`}
target="_blank"
>
<Button size="sm" type="button" variant="outline">
@ -160,7 +156,7 @@ const AdminSendScheda = () => {
variant="outline"
>
<Mail />
<span>Re-Invia email contatti</span>
<span>Invia email contatti</span>
</Button>
);
};
@ -277,7 +273,7 @@ const PostConfermaSection = () => {
}
if (!data.doc_contratto_added) {
return (
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1 rounded-md border border-card bg-card p-4">
<h3 className="font-semibold text-green-500">
{locale === "it"
? "Ci siamo quasi! Stiamo preparando il contratto per te."
@ -303,16 +299,17 @@ const PostConfermaSection = () => {
? "Il contratto è pronto, puoi visualizzarlo e procedere al saldo:"
: "The contract is ready, you can view it and proceed to payment:"}
</h3>
<div className="flex flex-wrap gap-2">
<div className="flex w-full flex-wrap gap-2">
{data.doc_contratto_ref ? (
<Link
aria-label={
locale === "it" ? "Visualizza contratto" : "View contract"
}
className="w-full sm:w-fit"
href={`/servizio/contratto/${servizioId}/${annuncioId}`}
target="_blank"
>
<Button className="w-full sm:w-fit">
<Button className="w-full">
<FileText />
{locale === "it"
? "Visualizza il contratto"
@ -338,7 +335,11 @@ const PostConfermaSection = () => {
</TooltipProvider>
)}
{!consulenzaPaid && data.contratto_tipo && <SaldoConsulenzaButton />}
{!consulenzaPaid &&
servizio.ordini.some(
(o) => o.type === OrderTypeEnum.Consulenza && !o.isActive,
) &&
data.contratto_tipo && <SaldoConsulenzaButton />}
{data.doc_registrazione_added &&
(data.doc_registrazione_ref ? (
@ -346,10 +347,11 @@ const PostConfermaSection = () => {
aria-label={
locale === "it" ? "Visualizza registrazione" : "View recording"
}
className="w-full sm:w-fit"
href={`/servizio/contratto-registrazione/${servizioId}/${annuncioId}`}
target="_blank"
>
<Button className="w-fit">
<Button className="w-full">
<FileBadge />
{locale === "it"
? "Visualizza la registrazione"
@ -380,7 +382,6 @@ const PostConfermaSection = () => {
};
export const SaldoButton = ({
annuncioId,
variant = "info",
className,
}: {
@ -390,40 +391,21 @@ export const SaldoButton = ({
}) => {
const { locale } = useTranslation();
const { servizioId } = useServizio();
const router = useRouter();
const { mutate } = api.servizio.setupSaldoServizio.useMutation({
onMutate: () => {
toast.loading(locale === "it" ? "Caricamento ..." : "Loading ...", {
id: "preparazioneSaldo",
});
},
onSuccess: async (data) => {
toast.success(
locale === "it" ? "Pagamento saldo preparato" : "Payment prepared",
{
id: "preparazioneSaldo",
},
);
await router.push(`/servizio/pagamento/${data}`);
},
});
return (
<Link
className="w-full sm:w-fit"
href={`/servizio/pagamento/${servizioId}?type=${OrderTypeEnum.Saldo}`}
>
<Button
aria-label={locale === "it" ? "Procedi al saldo" : "Proceed to payment"}
className={cn("w-fit", className)}
onClick={() => {
mutate({
annuncioId,
servizioId,
});
}}
className={cn("w-full", className)}
variant={variant}
>
<PackageCheck />
{locale === "it" ? "Procedi al saldo" : "Proceed to payment"}
</Button>
</Link>
);
};
@ -435,41 +417,20 @@ const SaldoConsulenzaButton = ({
className?: string;
}) => {
const { locale } = useTranslation();
const { annuncioId } = useServizioAnnuncio();
const { servizioId } = useServizio();
const router = useRouter();
const { mutate } = api.servizio.setupSaldoConsulenzaServizio.useMutation({
onMutate: () => {
toast.loading(locale === "it" ? "Caricamento ..." : "Loading ...", {
id: "preparazioneSaldo",
});
},
onSuccess: async (data) => {
toast.success(
locale === "it" ? "Pagamento saldo preparato" : "Payment prepared",
{
id: "preparazioneSaldo",
},
);
await router.push(`/servizio/pagamento/${data}`);
},
});
return (
<Link
className="w-full sm:w-fit"
href={`/servizio/pagamento/${servizioId}?type=${OrderTypeEnum.Consulenza}`}
>
<Button
aria-label={
locale === "it"
? "Procedi al saldo consulenza"
: "Proceed to payment for the consultation"
}
className={cn("w-fit", className)}
onClick={() => {
mutate({
annuncioId,
servizioId,
});
}}
className={cn("w-full", className)}
variant={variant}
>
<PackageCheck />
@ -477,5 +438,6 @@ const SaldoConsulenzaButton = ({
? "Procedi al saldo della consulenza"
: "Proceed to payment for the consultation"}
</Button>
</Link>
);
};

View file

@ -102,7 +102,7 @@ export const AnnuncioDisplay = ({
return (
<div
className={cn(
"grid @md:grid-cols-[12rem_1fr] grid-cols-[7rem_1fr] gap-x-0 gap-y-2 rounded-md bg-card md:max-w-3xl lg:gap-x-4 xl:gap-x-6",
"grid @md:grid-cols-[12rem_1fr] grid-cols-[7rem_1fr] @sm:gap-x-4 gap-x-1 gap-y-2 rounded-md bg-card md:max-w-3xl lg:gap-x-4 xl:gap-x-6",
className,
)}
>

View file

@ -90,7 +90,7 @@ export const ConfermaAnnuncioModal = () => {
};
const InvioConferma = ({ closeModal }: { closeModal: () => void }) => {
const { userId, servizioId } = useServizio();
const { servizioId } = useServizio();
const { annuncioId } = useServizioAnnuncio();
const { t } = useTranslation();
const utils = api.useUtils();
@ -107,8 +107,7 @@ const InvioConferma = ({ closeModal }: { closeModal: () => void }) => {
id: "updateRichiestaToast",
});
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
await utils.servizio.getServizioData.invalidate({ servizioId });
await utils.servizio.invalidate();
closeModal();
},
},
@ -117,7 +116,11 @@ const InvioConferma = ({ closeModal }: { closeModal: () => void }) => {
return (
<>
<CredenzaBody className="flex flex-col gap-4">
<p className="text-center font-semibold">
Hai fatto la visita? Se no contatta il proprietario!
</p>
<p>{t.richieste.conferma_txt}</p>
<p className="text-sm">{t.richieste.conf_alert}</p>
</CredenzaBody>
<CredenzaFooter>
@ -130,9 +133,9 @@ const InvioConferma = ({ closeModal }: { closeModal: () => void }) => {
servizioId,
});
}}
variant="success"
variant="info"
>
{t.conferma}
{t.procedi}
</LoadingButton>
<CredenzaClose asChild>
@ -145,17 +148,11 @@ const InvioConferma = ({ closeModal }: { closeModal: () => void }) => {
const DocCheckpoint = () => {
const { t, locale } = useTranslation();
const { userId, servizio, isAdmin, servizioId } = useServizio();
const { userId, servizio, isAdmin } = useServizio();
const utils = api.useUtils();
const { mutate, isPending } = api.servizio.updateServizio.useMutation({
onSuccess: async () => {
await utils.servizio.getAllServizioAnnunci.invalidate({
userId,
});
await utils.servizio.getServizioData.invalidate({ servizioId });
await utils.storage.retrieveUserFileData.invalidate({ userId });
await utils.servizio.getPacksSolution.invalidate({ servizioId });
await utils.servizio.invalidate();
},
onError: (err) => {
toast.error(`Errore aggiornamento servizio: ${err.message}`);

View file

@ -87,12 +87,7 @@ const DocCheckpoint = () => {
const utils = api.useUtils();
const { mutate, isPending } = api.users.editUserAnagrafica.useMutation({
onSuccess: async () => {
await utils.servizio.getAllServizioAnnunci.invalidate({
userId,
});
await utils.servizio.getServizioData.invalidate({
servizioId: servizio.servizio_id,
});
await utils.servizio.invalidate();
await utils.storage.retrieveUserFileData.invalidate({ userId });
},
onError: (err) => {
@ -183,7 +178,7 @@ const DocCheckpoint = () => {
};
const PreUnlock = () => {
const { userId, servizioId } = useServizio();
const { servizioId } = useServizio();
const { annuncioId } = useServizioAnnuncio();
const { t, locale } = useTranslation();
@ -211,10 +206,7 @@ const PreUnlock = () => {
toast.success("Successo", {
id: "unlock-contacts",
});
await utils.servizio.getAllServizioAnnunci.invalidate({
userId,
});
await utils.servizio.getServizioData.invalidate({ servizioId });
await utils.servizio.invalidate();
},
},
);

View file

@ -54,8 +54,7 @@ const NewServizioModal = ({
},
onSuccess: async () => {
toast.success("Servizio creato con successo");
await utils.servizio.getUserServizi.invalidate();
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
await utils.servizio.invalidate();
setEditData(undefined);
setOpen(false);
@ -66,20 +65,9 @@ const NewServizioModal = ({
onError: (error) => {
toast.error(error.message);
},
onSuccess: async (data) => {
onSuccess: async () => {
toast.success("Servizio modificato con successo");
await utils.servizio.getUserServizi.invalidate();
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
await utils.servizio.getServizioData.invalidate({
servizioId: data.servizio_id,
});
await utils.servizio.getCompatibileAnnunci.invalidate({
servizioId: data.servizio_id,
});
await utils.servizio.getPacksSolution.invalidate({
servizioId: data.servizio_id,
});
await utils.servizio.invalidate();
setEditData(undefined);
setOpen(false);
},

View file

@ -1,12 +1,14 @@
import { add } from "date-fns";
import {
ArrowRight,
BadgeCheck,
CalendarClock,
CircleCheck,
CircleCheckBig,
CircleMinus,
Clock,
ClockFading,
ExternalLink,
type LucideIcon,
PackageCheck,
Trash2,
@ -20,11 +22,23 @@ import {
ServizioProvider,
useServizio,
} from "~/providers/ServizioProvider";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import type { UsersId } from "~/schemas/public/Users";
import type { ServizioData } from "~/server/controllers/servizio.controller";
import { api } from "~/utils/api";
import { Confirm } from "../confirm";
import { AlarmClockSVG } from "../svgs";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "../ui/alert-dialog";
import { Button } from "../ui/button";
import {
Card,
@ -36,11 +50,7 @@ import {
import { SaldoButton } from "./annuncio_actions";
import { AnnuncioCard, AnnuncioDisplay } from "./annuncio_card";
import { AnnunciCompatibili } from "./compatibili_dialog";
import {
AdminActions,
ServizioBasicActions,
ServizioPacksInfos,
} from "./servizio_actions";
import { ServizioInfos } from "./servizio_actions";
export const ServizioList = ({
servizi,
@ -189,18 +199,18 @@ const Content = () => {
const { t } = useTranslation();
const dec_txt = servizio.decorrenza
? new Date(servizio.decorrenza).toLocaleDateString("it-IT")
: "N/A";
: "";
const end_txt = servizio.decorrenza
? new Date(add(servizio.decorrenza, { days: 60 })).toLocaleDateString(
"it-IT",
)
: "N/A";
: "";
return (
<div className="w-full space-y-4">
<div className="flex flex-col flex-wrap items-start justify-between gap-4 sm:flex-row">
<div className="flex flex-col gap-1">
<h3 className="font-semibold text-xl">
<h3 className="font-semibold text-2xl">
{t.servizio.servizio_titolo} {servizio.tipologia}
</h3>
@ -217,51 +227,32 @@ const Content = () => {
<span>{t.servizio.durata_servizio}</span>
</div>
<span>
{dec_txt} - {end_txt}
{servizio.decorrenza !== null
? `${dec_txt} - ${end_txt}`
: "In attesa di attivazione"}
</span>
</div>
</div>
<div className="flex flex-col gap-4">
<AdminActions />
<ServizioBasicActions className="w-full sm:justify-end" />
<ServizioInfos />
</div>
</div>
<ServizioPacksInfos />
<Main />
</div>
);
};
const Main = () => {
const { servizio, isAdmin, userId, servizioId } = useServizio();
const { servizio, isAdmin } = useServizio();
const { t } = useTranslation();
const utils = api.useUtils();
const { mutate: updateServizio } = api.servizio.updateServizio.useMutation({
onError: (error) => {
toast.error(error.message);
const { mutate: attivazioneSenzaPagamento } =
api.servizio.attivazioneSenzaPagamento.useMutation({
onError: () => {
toast.error("Errore durante l'attivazione del servizio");
},
onSuccess: async () => {
toast.success("Servizio modificato con successo");
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
await utils.servizio.getServizioData.invalidate({ servizioId });
await utils.servizio.getPacksSolution.invalidate({ servizioId });
},
});
const { mutate: removeAnnuncioServizio } =
api.servizio.removeAnnuncioServizio.useMutation({
onError: (error) => {
console.error(error);
toast.error("Errore durante la rimozione dell'annuncio");
},
onSuccess: async () => {
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
await utils.servizio.getServizioData.invalidate({ servizioId });
await utils.servizio.getCompatibileAnnunci.invalidate({
servizioId,
});
toast.success("Annuncio rimosso");
await utils.servizio.invalidate();
},
});
@ -273,48 +264,240 @@ const Main = () => {
</>
);
}
if (!servizio.isOkAcconto || servizio.decorrenza === null) {
if (!servizio.onboardOk) {
return (
<div className="flex w-full flex-col items-center justify-center gap-4">
<div className="flex w-full flex-col items-center justify-center gap-8">
<div className="flex w-full max-w-md flex-col items-center justify-center gap-4 text-center">
<AlarmClockSVG className="size-46" />
<h1 className="text-xl">{t.servizio.non_attivo}</h1>
<Link
aria-label="Attiva Servizio"
className="w-full"
href={`/servizio/onboard/${servizio.servizio_id}`}
href={
isAdmin
? `/area-riservata/admin/user-view/onboard/${servizio.servizio_id}`
: `/servizio/onboard/${servizio.servizio_id}`
}
>
<Button className="w-full text-lg" variant="success">
<span>{t.servizio.attiva_cta}</span>
<ArrowRight />
</Button>
</Link>
</div>
{isAdmin && (
<Button
className="w-full"
onClick={() =>
updateServizio({
data: {
decorrenza: new Date(),
isOkAcconto: true,
},
servizioId: servizio.servizio_id,
})
<AnnunciAnteprima />
</div>
);
}
variant="success"
if (!servizio.isOkAcconto || servizio.decorrenza === null) {
const acconti = servizio.ordini.filter(
(o) => o.type === OrderTypeEnum.Acconto,
);
const activeAcconto = acconti.find((a) => a.isActive);
return (
<div className="flex w-full flex-col items-center justify-center gap-8">
<div className="flex w-full max-w-md flex-col items-center justify-center gap-4 text-center">
<PackageCheck className="size-46 stroke-1" />
<h1 className="text-xl">Parametri di ricerca salvati</h1>
{activeAcconto ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
// se acconto inserito, attivato ma servizio non ancora attivo
className="w-full text-lg"
variant="info"
>
Attiva Manualmente (Azione Admin)
<span>Attiva il servizio</span>
<ArrowRight />
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="sm:max-w-xl">
<AlertDialogHeader>
<div className="mb-4 flex size-9 items-center justify-center rounded-full bg-sky-600/10 sm:mx-0 dark:bg-sky-400/10">
<BadgeCheck className="size-4.5 text-sky-600 dark:text-sky-400" />
</div>
<AlertDialogTitle className="text-xl">
Attivazione del Servizio
</AlertDialogTitle>
<AlertDialogDescription>
Procedendo all'attivazione del servizio, confermi che i
parametri di ricerca salvati sono corretti e accetti di
essere contattato per eventuali verifiche.
</AlertDialogDescription>
{activeAcconto?.testo_condizioni && (
<div className="mt-4 w-full space-y-4">
<Link
aria-label="Leggi le condizioni"
className="block w-fit"
href={`/servizio/condizioni/${activeAcconto.testo_condizioni}`}
target="_blank"
>
<Button className="w-full text-sm">
<span>{t.acquisto.leggi_condizioni}</span>
<ExternalLink />
</Button>
</Link>
<p className="text-muted-foreground text-sm">
Riceverai copia delle condizioni anche via email
</p>
</div>
)}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t.annulla}</AlertDialogCancel>
<AlertDialogAction
className="bg-sky-600 text-white hover:bg-sky-600 focus-visible:ring-sky-600 dark:bg-sky-400 dark:focus-visible:ring-sky-400 dark:hover:bg-sky-400"
onClick={() => {
attivazioneSenzaPagamento({
ordineId: activeAcconto.ordine_id,
});
}}
>
<span>Attiva il servizio</span>
<ArrowRight className="size-4" />
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<Link
aria-label="Attiva Servizio"
className="w-full"
href={`/servizio/pagamento/${servizio.servizio_id}?type=${OrderTypeEnum.Acconto}`}
>
<Button className="w-full text-lg" variant="info">
<span>Procedi al pagamento</span>
<ArrowRight />
</Button>
</Link>
)}
</div>
{servizio.annunci.length > 0 && (
<AnnunciAnteprima />
</div>
);
}
if (
new Date() >
add(servizio.decorrenza, {
days: 60,
}) &&
!servizio.isOkSaldo
) {
return (
<>
<p className="font-medium text-lg">{t.servizio.scaduto}</p>
<p>{t.servizio.scaduto_desc}</p>
</>
);
}
const annuncioConfermato = servizio.annunci.filter(
(a) => a.accettato_conferma_at !== null,
);
if (servizio.isOkSaldo && annuncioConfermato.length > 0) {
return (
<>
{annuncioConfermato.map((data) => {
return (
<ServizioAnnuncioProvider
data={data}
key={data.id}
servizioId={servizio.servizio_id}
>
<AnnuncioCard className="max-w-full" />
</ServizioAnnuncioProvider>
);
})}
</>
);
}
return (
<>
{annuncioConfermato.length > 0 && (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<PackageCheck className="size-6 text-primary" />
<span className="font-medium">{t.servizio.saldi_in_attesa}</span>
</div>
{servizio.annunci
.filter((a) => a.accettato_conferma_at !== null)
.map((a) => (
<div
className="flex items-center gap-2 rounded-md bg-sky-400 p-2"
key={a.annunci_id}
>
<h3>
{a.codice} -{" "}
{a.accettato_conferma_at &&
new Date(a.accettato_conferma_at).toLocaleDateString("it", {
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
month: "2-digit",
year: "numeric",
})}
</h3>
<SaldoButton
annuncioId={a.annunci_id}
className="border-none"
variant={"outline"}
/>
</div>
))}
</div>
)}
<div className="@container grid grid-flow-row @sm:grid-cols-2 grid-cols-1 gap-4">
{servizio.annunci.map((data) => {
return (
<ServizioAnnuncioProvider
data={data}
key={data.id}
servizioId={servizio.servizio_id}
>
<AnnuncioCard />
</ServizioAnnuncioProvider>
);
})}
{servizio.annunci.length % 2 === 1 && (
<div className="@sm:block hidden size-full rounded-md border border-(--pattern-fg) bg-[repeating-linear-gradient(45deg,var(--pattern-fg)_0,var(--pattern-fg)_1px,transparent_0,transparent_50%)] bg-size-[10px_10px] bg-fixed opacity-10 [--pattern-fg:#696969] dark:[--pattern-fg:#e1e1e1]" />
)}
</div>
<div className="flex flex-col items-center justify-center gap-4 py-4">
{servizio.annunci.length === 0 && (
<h2 className="font-medium text-lg">Cerca un annuncio ora!</h2>
)}
<AnnunciCompatibili />
</div>
</>
);
};
const AnnunciAnteprima = () => {
const { servizio, isAdmin } = useServizio();
const { t } = useTranslation();
const utils = api.useUtils();
const { mutate: removeAnnuncioServizio } =
api.servizio.removeAnnuncioServizio.useMutation({
onError: () => {
toast.error("Errore durante la rimozione dell'annuncio dal servizio");
},
onSuccess: async () => {
await utils.servizio.invalidate();
toast.success("Annuncio rimosso");
},
});
if (servizio.annunci.length > 0) {
return (
<div className="w-full space-y-2">
<p>
{t.servizio.per_te_1} {servizio.annunci.length}{" "}
{t.servizio.per_te_2}
{t.servizio.per_te_1} {servizio.annunci.length} {t.servizio.per_te_2}
</p>
<div className="grid gap-4 sm:grid-cols-2">
{servizio.annunci.map((data) => (
@ -359,101 +542,7 @@ const Main = () => {
)}
</div>
</div>
)}
</div>
);
}
if (
new Date() >
add(servizio.decorrenza, {
days: 60,
}) &&
!servizio.isOkSaldo
) {
return (
<>
<p className="font-medium text-lg">{t.servizio.scaduto}</p>
<p>{t.servizio.scaduto_desc}</p>
</>
);
}
const annuncioConfermato = servizio.annunci.filter(
(a) => a.accettato_conferma_at !== null,
);
if (servizio.isOkSaldo && annuncioConfermato.length > 0) {
return (
<>
{annuncioConfermato.map((data) => {
return (
<ServizioAnnuncioProvider
data={data}
key={data.id}
servizioId={servizio.servizio_id}
userId={userId}
>
<AnnuncioCard className="max-w-full" />
</ServizioAnnuncioProvider>
);
})}
</>
);
}
return (
<>
{annuncioConfermato.length > 0 && (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<PackageCheck className="size-6 text-primary" />
<span className="font-medium">{t.servizio.saldi_in_attesa}</span>
</div>
{servizio.annunci
.filter((a) => a.accettato_conferma_at !== null)
.map((a) => (
<div
className="flex items-center gap-2 rounded-md bg-sky-400 p-2"
key={a.annunci_id}
>
<h3>
{a.codice} -{" "}
{a.accettato_conferma_at &&
new Date(a.accettato_conferma_at).toLocaleDateString("it", {
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
month: "2-digit",
year: "numeric",
})}
</h3>
<SaldoButton
annuncioId={a.annunci_id}
className="border-none"
variant={"outline"}
/>
</div>
))}
</div>
)}
<div className="grid grid-flow-row grid-cols-1 gap-4 sm:grid-cols-2">
{servizio.annunci.map((data) => {
return (
<ServizioAnnuncioProvider
data={data}
key={data.id}
servizioId={servizio.servizio_id}
userId={userId}
>
<AnnuncioCard />
</ServizioAnnuncioProvider>
);
})}
{servizio.annunci.length % 2 === 1 && (
<div className="hidden size-full rounded-md border border-(--pattern-fg) bg-[repeating-linear-gradient(45deg,var(--pattern-fg)_0,var(--pattern-fg)_1px,transparent_0,transparent_50%)] bg-size-[10px_10px] bg-fixed opacity-10 [--pattern-fg:#696969] sm:block dark:[--pattern-fg:#e1e1e1]" />
)}
</div>
<div className="flex items-center justify-center">
<AnnunciCompatibili />
</div>
</>
);
return null;
};

View file

@ -6,12 +6,14 @@ import {
Cog,
Euro,
ExternalLink,
Logs,
Plus,
Search,
SlidersHorizontal,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import toast from "react-hot-toast";
import {
@ -55,6 +57,7 @@ import {
type FormValues as EditFormValues,
FormEditServizio,
} from "~/forms/FormEditServizioAdmin";
import { FormNewOrder } from "~/forms/FormNewOrdine";
import { FormNewServizio, type FormValues } from "~/forms/FormNewServizio";
import { cn, formatCurrency } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
@ -65,27 +68,76 @@ import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import type { UsersId } from "~/schemas/public/Users";
import { api } from "~/utils/api";
import { Confirm } from "../confirm";
import { Separator } from "../ui/separator";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../ui/table";
export const AdminActions = () => {
export const ServizioInfos = () => {
return (
<div className="grid w-full grid-cols-2 gap-2 sm:grid-cols-4">
<ServizioPrezzo />
<ServizioActions />
<ServizioOrders />
</div>
);
};
const ServizioActions = () => {
const { isAdmin } = useServizio();
return (
<Dialog>
<DialogTrigger asChild>
<Button className="flex-1" variant="outline">
<SlidersHorizontal />
<span>Impostazioni</span>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Azioni Servizio</DialogTitle>
<DialogDescription className="sr-only">
Azioni disponibili per questo servizio
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-4">
{isAdmin && (
<div className="w-full text-center">
<span>Azioni Admin</span>
<Separator />
</div>
)}
<AdminActions />
{isAdmin && (
<div className="w-full text-center">
<span>Azioni Utente</span>
<Separator />
</div>
)}
<UserActions />
</div>
</DialogContent>
</Dialog>
);
};
const AdminActions = () => {
const { servizio, isAdmin, userId } = useServizio();
if (!isAdmin) return null;
return (
<div className="flex flex-wrap items-center gap-3">
<AddAnnuncio servizioId={servizio.servizio_id} userId={userId} />
<EditServizioAdmin />
<Link
href={`/area-riservata/admin/user-view/ordini/${userId}?servizioId=${servizio.servizio_id}`}
>
<Button variant="warning">
<Search />
<span>Ordini</span>
</Button>
</Link>
</div>
);
};
export const ServizioBasicActions = ({ className }: { className?: string }) => {
const UserActions = ({ className }: { className?: string }) => {
const { servizio, isAdmin } = useServizio();
const canUserEdit = servizio.isOkAcconto && servizio.decorrenza !== null;
@ -106,6 +158,229 @@ export const ServizioBasicActions = ({ className }: { className?: string }) => {
return null;
};
const ServizioPrezzo = () => {
const { servizio } = useServizio();
const { t } = useTranslation();
const { data: packs } = api.servizio.getPacksSolution.useQuery({
servizioId: servizio.servizio_id,
});
if (!packs) return null;
return (
<Dialog>
<DialogTrigger asChild>
<Button className="flex-1" variant="outline">
<Euro />
<span>Prezzi</span>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Prezzi Servizio</DialogTitle>
<DialogDescription className="sr-only">
Prezzi disponibili per questo servizio
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<span className="text-muted-foreground text-sm">
{t.servizio.prezzi.sub}
</span>
<div className="flex w-full flex-col gap-4 md:flex-row">
<div className="flex flex-1 flex-col gap-2 rounded-md bg-secondary/50 p-2">
{packs.acconto.success ? (
<>
<span className="font-medium">
{packs.acconto.pack.nome_it}
</span>
<span className="text-muted-foreground text-sm">
{packs.acconto.pack.desc_it}
</span>
<span className="font-semibold text-lg">
{formatCurrency(packs.acconto.pack.prezzo_cent / 100)}
</span>
<span className="text-muted-foreground text-sm">
COD: {packs.acconto.pack.idprezziario}
</span>
</>
) : (
<span className="font-medium">{packs.acconto.message}</span>
)}
</div>
<div className="flex flex-1 flex-col gap-2 rounded-md bg-secondary/50 p-2">
{packs.saldo.success ? (
<>
<span className="font-medium">
{packs.saldo.pack.nome_it}
</span>
<span className="text-muted-foreground text-sm">
{packs.saldo.pack.desc_it}
</span>
<span className="font-semibold text-lg">
{formatCurrency(packs.saldo.pack.prezzo_cent / 100)}
</span>
<span className="text-muted-foreground text-sm">
COD: {packs.saldo.pack.idprezziario}
</span>
</>
) : (
<span className="font-medium">{packs.saldo.message}</span>
)}
</div>
{servizio.tipologia === TipologiaPosizioneEnum.Stabile && (
<div className="flex flex-1 flex-col gap-2 rounded-md bg-secondary/50 p-2">
<>
<span className="font-medium">
{t.servizio.prezzi.consulenza}
</span>
<span className="text-muted-foreground text-sm">
{t.servizio.prezzi.consulenza_desc}
</span>
<span className="font-semibold">
<Link
className="flex items-center gap-2 underline underline-offset-2"
href={"/prezzi#contratti"}
target="_blank"
>
<span>{t.servizio.prezzi.gotoprezzi}</span>
<ExternalLink className="size-4 stroke-foreground" />
</Link>
</span>
</>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
};
const ServizioOrders = () => {
const { isAdmin, servizio } = useServizio();
const [openOrders, setOpenOrders] = useState(false);
const [openEdit, setOpenEdit] = useState(false);
const swapToEdit = () => {
setOpenOrders(false);
setOpenEdit(true);
};
const swapToOrders = () => {
setOpenEdit(false);
setOpenOrders(true);
};
return (
<>
<Dialog onOpenChange={setOpenOrders} open={openOrders}>
<DialogTrigger asChild>
<Button className="flex-1" variant="outline">
<Logs />
<span>Ordini</span>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader className="flex-row items-center gap-4">
<DialogTitle>Ordini Servizio</DialogTitle>
<DialogDescription className="sr-only">
Ordini disponibili per questo servizio
</DialogDescription>
{isAdmin && (
<Button
className="w-fit"
onClick={swapToEdit}
size="sm"
variant="outline"
>
<Plus />
</Button>
)}
</DialogHeader>
<div className="flex flex-col items-center gap-4 overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Data</TableHead>
<TableHead>ID</TableHead>
<TableHead>Importo</TableHead>
<TableHead>Status</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{servizio.ordini.length === 0 && (
<tr>
<td className="py-4 text-center" colSpan={4}>
<span>Nessun ordine disponibile</span>
</td>
</tr>
)}
{servizio.ordini.map((ordine) => (
<TableRow key={ordine.ordine_id}>
<TableCell>
{ordine.created_at.toLocaleDateString("it-IT")}
</TableCell>
<TableCell className="font-medium">
{ordine.packid}
</TableCell>
<TableCell>
{formatCurrency(ordine.amount_cent / 100)}
</TableCell>
<TableCell>
{ordine.isActive ? "Attivo" : "Non attivo"}
</TableCell>
<TableCell>
<Link
href={`/area-riservata/${isAdmin ? "admin/user-view/" : ""}ordini${isAdmin ? `/${ordine.userid}` : ""}?ordineId=${ordine.ordine_id}`}
target="_blank"
>
<Button size="sm" variant="outline">
<Search />
</Button>
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</DialogContent>
</Dialog>
<Dialog onOpenChange={setOpenEdit} open={openEdit}>
<DialogContent>
<DialogHeader>
<DialogTitle>Nuovo Ordine</DialogTitle>
<DialogDescription className="sr-only">nuovo</DialogDescription>
</DialogHeader>
<AddOrderDialogContent swapBack={swapToOrders} />
</DialogContent>
</Dialog>
</>
);
};
const AddOrderDialogContent = ({ swapBack }: { swapBack: () => void }) => {
const { servizioId, userId } = useServizio();
const { data, isLoading } = api.prezziario.getPrezziario.useQuery();
if (isLoading) return <LoadingPage />;
if (!data)
return (
<>
<span>Errore nel caricamento del prezziario.</span>
</>
);
return (
<FormNewOrder
prezziario={data}
servizioId={servizioId}
setOpen={swapBack}
userId={userId}
/>
);
};
export const ServizioPacksInfos = () => {
const { servizio } = useServizio();
const { t } = useTranslation();
@ -203,7 +478,6 @@ export const ServizioPacksInfos = () => {
const AddAnnuncio = ({
servizioId,
userId,
}: {
servizioId: ServizioServizioId;
userId: UsersId;
@ -226,15 +500,7 @@ const AddAnnuncio = ({
toast.error("Errore durante l'aggiunta dell'annuncio");
},
onSuccess: async () => {
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
await utils.servizio.getServizioData.invalidate({ servizioId });
await utils.servizio.getAnnunciAvailableToAdd.invalidate({
servizioId,
});
await utils.servizio.getCompatibileAnnunci.invalidate({
servizioId,
});
await utils.servizio.getUserServizi.invalidate({ userId });
await utils.servizio.invalidate();
toast.success("Annuncio aggiunto");
setOpenModal(false);
setSelectedAnnunci([]);
@ -319,7 +585,7 @@ const AddAnnuncio = ({
};
const InterruzioneServizio = () => {
const { servizio, userId, isAdmin } = useServizio();
const { servizio, isAdmin } = useServizio();
const { t } = useTranslation();
const beforeInterruzioneLimit =
servizio.decorrenza != null &&
@ -335,10 +601,7 @@ const InterruzioneServizio = () => {
toast.error("Errore durante la richiesta di interruzione");
},
onSuccess: async () => {
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
await utils.servizio.getServizioData.invalidate({
servizioId: servizio.servizio_id,
});
await utils.servizio.invalidate();
toast.success("Interruzione richiesta");
},
});
@ -381,7 +644,7 @@ const InterruzioneServizio = () => {
};
const EditParametri = () => {
const { servizioId, userId, isAdmin, servizio } = useServizio();
const { servizioId, isAdmin, servizio } = useServizio();
const { t } = useTranslation();
const [openEditParametri, setOpenEditParametri] = useState(false);
@ -393,13 +656,8 @@ const EditParametri = () => {
},
onSuccess: async () => {
toast.success("Servizio modificato con successo");
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
await utils.servizio.getServizioData.invalidate({ servizioId });
await utils.servizio.invalidate();
await utils.servizio.getCompatibileAnnunci.invalidate({
servizioId,
});
await utils.servizio.getPacksSolution.invalidate({ servizioId });
setOpenEditParametri(false);
},
});
@ -452,22 +710,15 @@ const EditParametri = () => {
const EditServizioAdmin = () => {
const { servizioId, userId, servizio } = useServizio();
const [open, setOpen] = useState(false);
const utils = api.useUtils();
const router = useRouter();
const { mutate: update } = api.servizio.updateServizio.useMutation({
onError: (error) => {
toast.error(error.message);
},
onSuccess: async () => {
toast.success("Servizio modificato con successo");
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
await utils.servizio.getServizioData.invalidate({ servizioId });
await utils.servizio.getCompatibileAnnunci.invalidate({
servizioId,
});
await utils.servizio.getPacksSolution.invalidate({ servizioId });
await utils.servizio.invalidate();
setOpen(false);
},
});
@ -475,10 +726,14 @@ const EditServizioAdmin = () => {
onError: (error) => {
toast.error(error.message);
},
onMutate: async () => {
await router.push(`/area-riservata/admin/user-view/ricerca/${userId}`);
},
onSuccess: async () => {
toast.success("Servizio rimosso con successo");
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
await utils.servizio.getServizioData.invalidate({ servizioId });
await utils.servizio.invalidate();
},
});

View file

@ -7,7 +7,7 @@ export const Status500 = () => {
const router = useRouter();
const { t } = useTranslation();
return (
<div className="mx-auto flex h-screen max-w-screen-xl items-center justify-start px-4 md:px-8">
<div className="mx-auto flex h-screen max-w-7xl items-center justify-start px-4 md:px-8">
<div className="mx-auto max-w-lg space-y-3 text-center">
<ShieldExclamationIcon className="mx-auto size-20 text-red-600" />
<h3 className="font-semibold text-4xl text-accent-foreground sm:text-5xl">

View file

@ -1,8 +1,11 @@
import type { ColumnDef } from "@tanstack/react-table";
import { CircleCheck, ExternalLink } from "lucide-react";
import { Circle, CircleCheck, ExternalLink } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { OrdiniModal } from "~/components/area-riservata/ordini";
import {
AdminWrapperOrdiniModal,
UserOrdiniModal,
} from "~/components/area-riservata/ordini";
import { DataTable } from "~/components/custom_ui/data-table";
import { DataTableColumnHeader } from "~/components/custom_ui/dataTable-header";
import type {
@ -10,28 +13,47 @@ import type {
SearchFiltro,
} from "~/components/custom_ui/dataTable-toolbar";
import { Button } from "~/components/ui/button";
import { formatCurrency } from "~/lib/utils";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum";
import type { UsersId } from "~/schemas/public/Users";
import type { OrdiniWPagamenti } from "~/server/controllers/ordini.controller";
import type { Ordini_w_Utente } from "~/server/controllers/ordini.controller";
type PaymentsTableProps = {
data: OrdiniWPagamenti[];
data: Ordini_w_Utente[];
isAdmin: boolean;
userId?: UsersId;
filter: OrdiniOrdineId | null;
};
export const OrdiniTable = ({ data, isAdmin, userId }: PaymentsTableProps) => {
const StatusMapping = (status: PaymentStatusEnum | null) => {
switch (status) {
case PaymentStatusEnum.success:
return "Successo";
case PaymentStatusEnum.processing:
return "In lavorazione";
case PaymentStatusEnum.failed:
return "Errore";
default:
return "Non completato";
}
};
export const OrdiniTable = ({
data,
isAdmin,
userId,
filter,
}: PaymentsTableProps) => {
const tabledata = data.map((row) => {
return {
actions: "Azioni",
created_at: row.created_at,
id: row.ordine_id,
isActive: row.isActive,
isPaid: row.pagamenti.some(
(p) => p.paymentstatus === PaymentStatusEnum.success,
),
status: row.isActive,
payment_status: row.paymentstatus,
importo: row.amount_cent,
packid: row.packid,
tipo: row.type,
userId: row.userid,
@ -40,16 +62,19 @@ export const OrdiniTable = ({ data, isAdmin, userId }: PaymentsTableProps) => {
});
const columns_titles = {
id: "ID Ordine",
actions: "Azioni",
created_at: "Creato il",
isPaid: "Pagato",
importo: "Importo",
status: "Status",
payment_status: "Pagamento",
packid: "ID Pacchetto",
tipo: "Tipo",
username: "Utente",
};
const isPaidOptions = [
{ label: "Pagato", value: "true" },
{ label: "Non pagato", value: "false" },
const statusOptions = [
{ label: "Attivo", value: "true" },
{ label: "Non attivo", value: "false" },
];
const type_options = [
@ -59,7 +84,6 @@ export const OrdiniTable = ({ data, isAdmin, userId }: PaymentsTableProps) => {
{ label: "Altro", value: OrderTypeEnum.Altro },
];
const [modalOpen, setModalOpen] = useState(false);
const [selectedOrdine, setSelectedOrdine] = useState<OrdiniOrdineId | null>(
null,
);
@ -67,6 +91,13 @@ export const OrdiniTable = ({ data, isAdmin, userId }: PaymentsTableProps) => {
type Column = (typeof tabledata)[number];
const columns: ColumnDef<Column>[] = [
{
accessorKey: "id",
cell: () => null,
enableHiding: false,
header: () => null,
},
{
accessorKey: "username",
cell: ({ row }) => {
@ -125,30 +156,35 @@ export const OrdiniTable = ({ data, isAdmin, userId }: PaymentsTableProps) => {
),
},
{
accessorKey: "isPaid",
accessorKey: "importo",
cell: ({ row }) => {
const isPaid = row.original.isPaid;
const isActive = row.original.isActive;
if (isActive) {
return <span>{formatCurrency(row.original.importo / 100)}</span>;
},
header: ({ column }) => (
<DataTableColumnHeader column={column} title={columns_titles.importo} />
),
enableHiding: false,
sortingFn: (a, b, id) => {
const aValue = a.getValue(id) as number;
const bValue = b.getValue(id) as number;
return aValue - bValue;
},
},
{
accessorKey: "status",
cell: ({ row }) => {
if (!row.original.status) {
return (
<span className="inline-flex items-center gap-1 text-green-500">
<CircleCheck className="size-5" />
{isPaid ? "Pagato" : "Attivato da Admin"}
</span>
);
}
if (isPaid) {
return (
<span className="inline-flex items-center gap-1 text-green-500">
<CircleCheck className="size-5" />
Pagato
<span className="inline-flex items-center gap-1">
<Circle className="size-5" />
Non attivo
</span>
);
}
return (
<span className="inline-flex items-center gap-1 text-red-500">
<span className="inline-flex items-center gap-1 text-green-500">
<CircleCheck className="size-5" />
Non pagato
Attivo
</span>
);
},
@ -157,10 +193,25 @@ export const OrdiniTable = ({ data, isAdmin, userId }: PaymentsTableProps) => {
return value.includes(row.getValue(id));
},
header: ({ column }) => (
<DataTableColumnHeader column={column} title={columns_titles.isPaid} />
<DataTableColumnHeader column={column} title={columns_titles.status} />
),
},
{
accessorKey: "payment_status",
cell: ({ row }) => {
return <span>{StatusMapping(row.original.payment_status)}</span>;
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={columns_titles.payment_status}
/>
),
},
{
cell: ({ row }) => {
return (
@ -168,13 +219,11 @@ export const OrdiniTable = ({ data, isAdmin, userId }: PaymentsTableProps) => {
aria-label="Apri Dettagli Ordine"
onClick={() => {
setSelectedOrdine(row.original.id);
setModalOpen(true);
}}
size="sm"
variant="outline"
>
<ExternalLink className="size-6" />
Apri Dettagli
<ExternalLink className="size-5" />
</Button>
);
},
@ -183,23 +232,17 @@ export const OrdiniTable = ({ data, isAdmin, userId }: PaymentsTableProps) => {
},
];
const searchFiltro: SearchFiltro =
isAdmin && !userId
? {
columnName: "username",
placeholder: "Cerca utente...",
}
: {
columnName: "packid",
placeholder: "Cerca pacchetto...",
const searchFiltro: SearchFiltro = {
columnName: "id",
placeholder: "Cerca ordine...",
};
const pinnedFiltri: PinnedFiltro[] = [
{
columnName: "isPaid",
columnName: "status",
options: isPaidOptions,
title: "Stato Pagamento",
options: statusOptions,
title: "Stato Ordine",
},
{
columnName: "tipo",
@ -223,18 +266,25 @@ export const OrdiniTable = ({ data, isAdmin, userId }: PaymentsTableProps) => {
tipo: true,
username: isAdmin && !userId,
}}
defaultFilterState={
filter ? [{ id: "id", value: filter.toString() }] : []
}
defaultSort={[{ desc: true, id: "created_at" }]}
pinnedFiltri={pinnedFiltri}
searchColumn={searchFiltro}
/>
{selectedOrdine && (
<OrdiniModal
isAdmin={isAdmin}
open={modalOpen}
{isAdmin ? (
<AdminWrapperOrdiniModal
ordineId={selectedOrdine}
setModalOpen={setModalOpen}
userId={userId}
setSelected={setSelectedOrdine}
/>
) : (
selectedOrdine && (
<UserOrdiniModal
ordineId={selectedOrdine}
setSelected={setSelectedOrdine}
/>
)
)}
</div>
);

View file

@ -1,262 +0,0 @@
import type { ColumnDef } from "@tanstack/react-table";
import { EllipsisVertical, Trash2, Wrench } from "lucide-react";
import { DataTable } from "~/components/custom_ui/data-table";
import { DataTableColumnHeader } from "~/components/custom_ui/dataTable-header";
import type {
PinnedFiltro,
SearchFiltro,
} from "~/components/custom_ui/dataTable-toolbar";
import { Button } from "~/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { formatCurrency } from "~/lib/utils";
import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum";
import type { OrdiniWPagamenti } from "~/server/controllers/ordini.controller";
import { api } from "~/utils/api";
type PaymentsTableProps = {
data: OrdiniWPagamenti["pagamenti"];
isAdmin: boolean;
isUserView?: boolean;
};
const StatusMapping = (status: PaymentStatusEnum | null) => {
switch (status) {
case PaymentStatusEnum.success:
return "Successo";
case PaymentStatusEnum.processing:
return "In lavorazione";
case PaymentStatusEnum.failed:
return "Errore";
default:
return "Non completato";
}
};
const EnumFromString = (value: string) => {
switch (value) {
case "Successo":
return PaymentStatusEnum.success;
case "In lavorazione":
return PaymentStatusEnum.processing;
case "Errore":
return PaymentStatusEnum.failed;
default:
return null;
}
};
export const PaymentsTable = ({
data,
isAdmin,
isUserView = false,
}: PaymentsTableProps) => {
const tabledata = data.map((row) => {
return {
actions: "Azioni",
amount: row.amount_cent,
id: row.id,
paid_at: row.paid_at,
raw_status: row.paymentstatus,
status: StatusMapping(row.paymentstatus),
intent_id: row.intent_id,
};
});
const columns_titles = {
actions: "Azioni",
amount: "Importo",
paid_at: "Pagato il",
status: "Stato",
username: "Utente",
};
const status_options = [
{ label: "Inutilizzato", value: "Inutilizzato" },
{ label: "Successo", value: "Successo" },
{ label: "In lavorazione", value: "In lavorazione" },
{ label: "Errore", value: "Errore" },
];
const utils = api.useUtils();
const { mutate: update } = api.servizio.updatePagamento.useMutation({
onSuccess: async () => {
await utils.servizio.getAllPagamenti.invalidate();
await utils.servizio.getOrdini.invalidate();
await utils.servizio.getOrdineById.invalidate({
ordineId: data[0]?.ordine_id,
});
if ((!isAdmin || isUserView) && data[0]) {
await utils.servizio.getUserPagamenti.invalidate({
userId: data[0].userid,
});
}
},
});
const { mutate: remove } = api.servizio.removePagamento.useMutation({
onSuccess: async () => {
await utils.servizio.getAllPagamenti.invalidate();
await utils.servizio.getOrdini.invalidate();
await utils.servizio.getOrdineById.invalidate({
ordineId: data[0]?.ordine_id,
});
if ((!isAdmin || isUserView) && data[0]) {
await utils.servizio.getUserPagamenti.invalidate({
userId: data[0].userid,
});
}
},
});
type Column = (typeof tabledata)[number];
const columns: ColumnDef<Column>[] = [
{
accessorKey: "paid_at",
cell: ({ row }) => {
const date = row.getValue("paid_at");
return new Date(date as Date).toLocaleDateString();
},
enableHiding: false,
header: ({ column }) => (
<DataTableColumnHeader column={column} title={columns_titles.paid_at} />
),
sortingFn: "datetime",
},
{
accessorKey: "status",
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
header: ({ column }) => (
<DataTableColumnHeader column={column} title={columns_titles.status} />
),
},
{
accessorKey: "amount",
cell: ({ row }) => {
return <span>{formatCurrency(row.original.amount / 100)}</span>;
},
header: ({ column }) => (
<DataTableColumnHeader column={column} title={columns_titles.amount} />
),
},
{
cell: ({ row }) => {
const data = row.original;
if (!isAdmin) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="size-8 p-0" variant="ghost">
<span className="sr-only">Open menu</span>
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Azioni</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Wrench />
<span>Aggiorna status</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
onValueChange={(v) => {
update({
data: {
paymentstatus: EnumFromString(v),
paid_at: v === "Successo" ? new Date() : null,
},
pagamentoId: data.id,
});
}}
value={StatusMapping(data.raw_status)}
>
<DropdownMenuRadioItem value="Inutilizzato">
Inutilizzato
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="Successo">
Successo
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="In lavorazione">
In lavorazione
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="Errore">
Errore
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
aria-label="Elimina Pagamento"
className="flex items-center gap-3 hover:cursor-pointer"
onClick={() =>
confirm("Sei sicuro di voler eliminare il servizio?") &&
remove({ pagamentoId: data.id })
}
>
<Trash2 className="size-5" />
Elimina Pagamento
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
enableHiding: false,
id: "actions",
},
];
const searchFiltro: SearchFiltro | undefined =
isAdmin && !isUserView
? {
columnName: "username",
placeholder: "Cerca utente...",
}
: undefined;
const pinnedFiltri: PinnedFiltro[] = [
{
columnName: "status",
options: status_options,
title: "Status",
},
];
return (
<div className="w-full py-6">
<DataTable
columns={columns}
columns_titles={columns_titles}
data={tabledata}
defaultColumnVisibility={{
actions: true,
amount: true,
packid: true,
paid_at: true,
status: true,
username: isAdmin && !isUserView,
}}
defaultSort={[{ desc: true, id: "paid_at" }]}
pinnedFiltri={pinnedFiltri}
searchColumn={searchFiltro}
/>
</div>
);
};

View file

@ -1,7 +1,9 @@
"use client";
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
import type * as React from "react";
import { buttonVariants } from "~/components/ui/button";
import { cn } from "~/lib/utils";
import { Button } from "./button";
function AlertDialog({
...props
@ -32,7 +34,7 @@ function AlertDialogOverlay({
return (
<AlertDialogPrimitive.Overlay
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in",
"data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 bg-black/10 duration-100 data-closed:animate-out data-open:animate-in supports-backdrop-filter:backdrop-blur-xs",
className,
)}
data-slot="alert-dialog-overlay"
@ -43,16 +45,20 @@ function AlertDialogOverlay({
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm";
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:max-w-lg",
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 outline-none ring-1 ring-foreground/10 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-closed:animate-out data-open:animate-in data-[size=default]:sm:max-w-sm",
className,
)}
data-size={size}
data-slot="alert-dialog-content"
{...props}
/>
@ -66,7 +72,10 @@ function AlertDialogHeader({
}: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className,
)}
data-slot="alert-dialog-header"
{...props}
/>
@ -80,7 +89,7 @@ function AlertDialogFooter({
return (
<div
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className,
)}
data-slot="alert-dialog-footer"
@ -89,13 +98,32 @@ function AlertDialogFooter({
);
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className,
)}
data-slot="alert-dialog-media"
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
className={cn("font-semibold text-lg", className)}
className={cn(
"font-medium text-base sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className,
)}
data-slot="alert-dialog-title"
{...props}
/>
@ -108,7 +136,10 @@ function AlertDialogDescription({
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
className={cn("text-muted-foreground text-sm", className)}
className={cn(
"text-balance text-muted-foreground text-sm md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
data-slot="alert-dialog-description"
{...props}
/>
@ -117,38 +148,51 @@ function AlertDialogDescription({
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button asChild size={size} variant={variant}>
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
className={cn(className)}
data-slot="alert-dialog-action"
{...props}
/>
</Button>
);
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button asChild size={size} variant={variant}>
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
className={cn(className)}
data-slot="alert-dialog-cancel"
{...props}
/>
</Button>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};

View file

@ -267,7 +267,7 @@ export const AnnuncioEditForm = ({ data }: { data: AnnunciWithMedia }) => {
</Button>
</Link>
<Link
href={`/area-riservata/admin/scheda-annuncio-stampa/${data.id}`}
href={`/area-riservata/scheda-annuncio-stampa/${data.id}`}
target="_blank"
>
<Button size="sm" type="button" variant="outline">

View file

@ -0,0 +1,354 @@
import { format } from "date-fns";
import { it } from "date-fns/locale";
import { CalendarIcon, Trash2 } from "lucide-react";
import toast from "react-hot-toast";
import { z } from "zod/v4";
import { Confirm } from "~/components/confirm";
import {
CredenzaBody,
CredenzaClose,
CredenzaFooter,
} from "~/components/custom_ui/credenza";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/custom_ui/form";
import { MultiSelect } from "~/components/custom_ui/multiselect";
import { Button } from "~/components/ui/button";
import { Calendar } from "~/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Switch } from "~/components/ui/switch";
import { PAYMENT_TYPES, type PaymentType } from "~/i18n/stripe";
import { cn, formatCurrency } from "~/lib/utils";
import { useZodForm } from "~/lib/zodForm";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import type { Ordini } from "~/schemas/public/Ordini";
import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum";
import type { Prezziario } from "~/schemas/public/Prezziario";
import { zPrezziarioId } from "~/server/utils/zod_types";
import { api } from "~/utils/api";
type FormProps = {
prezziario: Prezziario[];
data: Ordini;
close: () => void;
};
export const FormEditOrder = ({ prezziario, data, close }: FormProps) => {
const schema = z.object({
packid: zPrezziarioId,
isActive: z.boolean(),
type: z.enum(OrderTypeEnum),
created_at: z.date(),
amount_cent: z.number(),
sconto: z.number().min(0).max(100),
paid_at: z.date().nullable(),
paymentmethod: z.enum(PAYMENT_TYPES).nullable(),
paymentstatus: z.enum(PaymentStatusEnum).nullable(),
});
type FormValues = z.infer<typeof schema>;
z.config(z.locales.it());
const form = useZodForm(schema, {
defaultValues: {
...data,
paymentmethod: (data.paymentmethod as PaymentType) || null,
},
});
const utils = api.useUtils();
const { mutate: updateOrder } = api.servizio.updateOrder.useMutation({
onError: (error) => {
toast.error(`Errore durante la modifica: ${error.message}`);
},
onSuccess: async () => {
toast.success("Ordine aggiornato con successo!");
await utils.servizio.invalidate();
},
});
const { mutate: remove } = api.servizio.removeOrder.useMutation({
onError: (error) => {
toast.error(`Errore durante la rimozione ordine: ${error.message}`);
},
onSuccess: async () => {
toast.success("Ordine rimosso con successo!");
await utils.servizio.invalidate();
close();
},
});
const { watch } = form;
const scontoWch = watch(["sconto", "amount_cent"]);
function onSubmit(fields: FormValues) {
updateOrder({
ordineId: data.ordine_id,
data: fields,
});
}
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<CredenzaBody className="max-h-[80vh] w-full pb-5">
<div className="space-y-8 px-0.5">
<FormField
control={form.control}
name="packid"
render={({ field }) => {
const pack = prezziario.find(
(p) => p.idprezziario === field.value,
);
return (
<FormItem className="w-full">
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="select-pack">Servizio</FormLabel>
<FormMessage />
</div>
<FormControl>
<MultiSelect
defaultValue={
field.value
? {
label: pack?.nome_it || "",
value: field.value,
}
: undefined
}
elementId="select-pack"
elementName="packid"
isMulti={false}
onValueChange={(value) => {
const selected = zPrezziarioId.safeParse(
value.value,
);
if (selected.success) {
field.onChange(selected.data);
const selectedPrezzo = prezziario.find(
(p) => p.idprezziario === selected.data,
);
if (selectedPrezzo) {
form.setValue(
"amount_cent",
selectedPrezzo.prezzo_cent,
);
}
} else {
console.error(
"Invalid packid selected",
value.value,
);
}
}}
options={prezziario.map((p) => ({
label: `${p.idprezziario} - ${p.nome_it} (${formatCurrency(p.prezzo_cent / 100)})`,
value: p.idprezziario,
}))}
placeholder="Seleziona..."
/>
</FormControl>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="titolo">Tipologia</FormLabel>
<FormMessage />
</div>
<FormControl>
<Select
defaultValue={field.value || ""}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Seleziona tipologia" />
</SelectTrigger>
</FormControl>
<SelectContent id="select-color">
{Object.values(OrderTypeEnum).map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="created_at"
render={({ field }) => (
<FormItem className="flex w-full flex-col">
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="created_at">Data Ordine</FormLabel>
<FormMessage />
</div>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
className={cn(
"h-10.5 w-full pl-3 text-left font-medium",
!field.value && "text-muted-foreground",
"rounded-lg border border-neutral-300 shadow-xs outline-hidden file:border-0 file:bg-transparent file:font-medium file:text-sm disabled:cursor-not-allowed disabled:opacity-50 dark:focus:border-transparent",
)}
id="created_at"
variant={"outline"}
>
{field.value ? (
format(field.value, "PPP", {
locale: it,
})
) : (
<span>Scegli data</span>
)}
<CalendarIcon className="ml-auto size-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-0">
<Calendar
autoFocus
captionLayout="dropdown"
defaultMonth={new Date()}
endMonth={new Date(2050, 12)}
locale={it}
mode="single"
onSelect={field.onChange}
selected={field.value || undefined}
/>
</PopoverContent>
</Popover>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<FormItem className="w-full">
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="isActive">Pagato</FormLabel>
<FormControl>
<Switch
className="data-[state=checked]:bg-neutral-700"
defaultChecked={field.value}
id="isActive"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</div>
<FormDescription>
Imposta se il servizio è pagato
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="sconto"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="sconto">Sconto</FormLabel>
<FormControl>
<Select
defaultValue={String(field.value)}
name="sconto"
onValueChange={(value) => {
field.onChange(Number.parseInt(value));
}}
>
<SelectTrigger className="w-45">
<SelectValue placeholder="Sconto" />
</SelectTrigger>
<SelectContent>
{[
0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60,
65, 70, 75, 80, 85, 90, 95, 100,
].map((v) => {
return (
<SelectItem key={v} value={String(v)}>
{v}%
</SelectItem>
);
})}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</div>
</FormItem>
)}
/>
{scontoWch && scontoWch[0] !== 0 ? (
<span>
Prezzo scontato:{" "}
{formatCurrency(
((1 - scontoWch[0] / 100) * scontoWch[1]) / 100,
)}
</span>
) : (
<span>Prezzo: {formatCurrency(scontoWch[1] / 100)}</span>
)}
</div>
</CredenzaBody>
<CredenzaFooter className="flex w-full sm:justify-between">
<Confirm
description="Sei sicuro di voler eliminare l'ordine?"
onConfirm={() => {
remove({ ordineId: data.ordine_id });
}}
title="Elimina ordine"
>
<Button aria-label="Elimina Ordine" variant="destructive">
<Trash2 className="size-6" />
Elimina Ordine
</Button>
</Confirm>
<div className="flex items-center gap-2">
<Button type="submit" variant="success">
Salva
</Button>
<CredenzaClose asChild>
<Button aria-label="Close Credenza" variant="outline">
Chiudi
</Button>
</CredenzaClose>
</div>
</CredenzaFooter>
</form>
</Form>
</>
);
};

View file

@ -35,6 +35,7 @@ const Schema = z.object({
skipPayment: z.boolean(),
skipControlloDoc: z.boolean(),
skipDocMotivazione: z.boolean(),
onboardOk: z.boolean(),
});
export type FormValues = z.infer<typeof Schema>;
@ -64,6 +65,7 @@ export const FormEditServizio = ({
skipPayment: false,
skipControlloDoc: false,
skipDocMotivazione: false,
onboardOk: false,
};
}
const { locale, t } = useTranslation();
@ -193,6 +195,28 @@ export const FormEditServizio = ({
</div>
</div>
<div className="flex w-full flex-col items-start justify-between gap-4 sm:flex-row">
<FormField
control={form.control}
name="onboardOk"
render={({ field }) => (
<FormItem className="w-full">
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="onboardOk">Onboard Fatta</FormLabel>
<FormControl>
<Switch
className="data-[state=checked]:bg-neutral-700"
defaultChecked={field.value}
id="onboardOk"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</div>
<FormDescription>Il servizio è interrotto</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isOkAcconto"

View file

@ -58,15 +58,21 @@ export const FormNewOrder = ({
const schema = z.object({
packid: zPrezziarioId,
isActive: z.boolean(),
type: z.custom<OrderTypeEnum>(),
type: z.enum(OrderTypeEnum),
created_at: z.date(),
amount_cent: z.number(),
sconto: z.number().min(0).max(100),
});
type FormValues = z.infer<typeof schema>;
z.config(z.locales.it());
const form = useZodForm(schema, {
defaultValues: {},
defaultValues: {
sconto: 0,
amount_cent: 0,
isActive: false,
},
});
const utils = api.useUtils();
const { mutate: createOrder } = api.servizio.createOrdine.useMutation({
@ -75,20 +81,17 @@ export const FormNewOrder = ({
},
onSuccess: async () => {
toast.success("Ordine creato con successo");
await utils.servizio.getOrdini.invalidate({
userId,
});
await utils.servizio.invalidate();
setOpen(false);
form.reset();
},
});
const { watch } = form;
const scontoWch = watch(["sconto", "amount_cent"]);
function onSubmit(fields: FormValues) {
createOrder({
data: {
...fields,
servizio_id: servizioId,
userid: userId,
},
data: { ...fields, servizio_id: servizioId, userid: userId },
});
}
@ -117,6 +120,15 @@ export const FormNewOrder = ({
const selected = zPrezziarioId.safeParse(value.value);
if (selected.success) {
field.onChange(selected.data);
const selectedPrezzo = prezziario.find(
(p) => p.idprezziario === selected.data,
);
if (selectedPrezzo) {
form.setValue(
"amount_cent",
selectedPrezzo.prezzo_cent,
);
}
} else {
console.error(
"Invalid packid selected",
@ -220,7 +232,7 @@ export const FormNewOrder = ({
render={({ field }) => (
<FormItem className="w-full">
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="isActive">Attivo</FormLabel>
<FormLabel htmlFor="isActive">Pagato</FormLabel>
<FormControl>
<Switch
className="data-[state=checked]:bg-neutral-700"
@ -233,11 +245,59 @@ export const FormNewOrder = ({
</div>
<FormDescription>
Imposta se il servizio è attivo
Imposta se il servizio è pagato
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="sconto"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="sconto">Sconto</FormLabel>
<FormControl>
<Select
defaultValue={String(field.value)}
name="sconto"
onValueChange={(value) => {
field.onChange(Number.parseInt(value));
}}
>
<SelectTrigger className="w-45">
<SelectValue placeholder="Sconto" />
</SelectTrigger>
<SelectContent>
{[
0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60,
65, 70, 75, 80, 85, 90, 95, 100,
].map((v) => {
return (
<SelectItem key={v} value={String(v)}>
{v}%
</SelectItem>
);
})}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</div>
</FormItem>
)}
/>
{scontoWch && scontoWch[0] !== 0 ? (
<span>
Prezzo scontato:{" "}
{formatCurrency(
((1 - scontoWch[0] / 100) * scontoWch[1]) / 100,
)}
</span>
) : (
<span>Prezzo: {formatCurrency(scontoWch[1] / 100)}</span>
)}
</div>
</CredenzaBody>

View file

@ -114,14 +114,15 @@ type FormNewServizioValues = z.infer<typeof FormNewServizioSchema>;
export const FormNewServizioAcquisto = ({
initialData,
userId,
isAdmin = false,
isAdmin,
}: {
initialData: InitialData;
userId: UsersId;
isAdmin?: boolean;
isAdmin: boolean;
}) => {
const router = useRouter();
const { comuni, nazioni } = useCatasto();
console.log(isAdmin);
const schema = FormNewServizioSchema.superRefine(
(
{
@ -226,14 +227,7 @@ export const FormNewServizioAcquisto = ({
path: ["n_adulti"],
});
}
if (!permanenza && tipologia === TipologiaPosizioneEnum.Transitorio) {
refinementContext.issues.push({
code: "custom",
input: "",
message: "Inserisci permanenza",
path: ["permanenza"],
});
}
if (tipologia === TipologiaPosizioneEnum.Transitorio) {
if (
motivazione_transitorio === "" ||
@ -254,7 +248,7 @@ export const FormNewServizioAcquisto = ({
path: ["scadenza_motivazione_transitoria"],
});
}
if (!permanenza) {
if (permanenza == null) {
refinementContext.issues.push({
code: "custom",
input: "",
@ -301,7 +295,7 @@ export const FormNewServizioAcquisto = ({
defaultValues: defaultValues,
});
const DatePickerLocale = locale === "it" ? it : enUS;
const { mutate } = api.servizio.submitOnboardForPurchase.useMutation({
const { mutate } = api.servizio.processServizioOnboard.useMutation({
onError: (error) => {
toast.error(`Errore invio dati: ${error.message}`, {
id: "salvataggio",
@ -312,25 +306,19 @@ export const FormNewServizioAcquisto = ({
id: "salvataggio",
});
},
onSuccess: async (data) => {
onSuccess: async () => {
toast.success("Servizio impostato con successo", {
id: "salvataggio",
});
if (isAdmin) {
if (data.ordineIdForPayment) {
await router.push(`/area-riservata/admin/user-view/ordini/${userId}`);
} else {
await router.push(
`/area-riservata/admin/user-view/servizio/${userId}/${initialData.servizio.servizio_id}`,
);
}
} else {
if (data.ordineIdForPayment) {
await router.push(`/servizio/pagamento/${data.ordineIdForPayment}`);
} else {
await router.push("/area-riservata/dashboard");
}
await router.push(
`/area-riservata/servizio/${initialData.servizio.servizio_id}`,
);
}
},
});
@ -1245,33 +1233,6 @@ export const FormNewServizioAcquisto = ({
</CardContent>
</Card>
<div className="flex w-full flex-wrap items-center justify-center gap-2 pb-8 sm:justify-end">
{isAdmin && (
<FormField
control={form.control}
name="skipPayment"
render={({ field }) => (
<FormItem className="py-2">
<div className="flex items-center justify-start gap-x-4">
<FormLabel htmlFor="skipPayment">
Vuoi saltare la creazione dell&apos;ordine e impostare il
servizio come attivo?
</FormLabel>
<FormControl>
<Switch
className="data-[state=checked]:bg-neutral-700"
defaultChecked={field.value}
id="skipPayment"
onCheckedChange={(v) => {
field.onChange(v);
}}
/>
</FormControl>
<FormMessage />
</div>
</FormItem>
)}
/>
)}
<Button className="w-full sm:w-auto" type="submit">
{texts.salva}
</Button>

View file

@ -1,54 +1,58 @@
export type PaymentType =
| "manual"
| "acss_debit"
| "affirm"
| "afterpay_clearpay"
| "alipay"
| "alma"
| "amazon_pay"
| "au_becs_debit"
| "bacs_debit"
| "bancontact"
| "billie"
| "blik"
| "boleto"
| "card"
| "card_present"
| "cashapp"
| "customer_balance"
| "eps"
| "fpx"
| "giropay"
| "grabpay"
| "ideal"
| "interac_present"
| "kakao_pay"
| "klarna"
| "konbini"
| "kr_card"
| "link"
| "mobilepay"
| "multibanco"
| "naver_pay"
| "nz_bank_account"
| "oxxo"
| "p24"
| "pay_by_bank"
| "payco"
| "paynow"
| "paypal"
| "pix"
| "promptpay"
| "revolut_pay"
| "samsung_pay"
| "satispay"
| "sepa_debit"
| "sofort"
| "swish"
| "twint"
| "us_bank_account"
| "wechat_pay"
| "zip";
export const PAYMENT_TYPES = [
"manual",
"acss_debit",
"affirm",
"afterpay_clearpay",
"alipay",
"alma",
"amazon_pay",
"au_becs_debit",
"bacs_debit",
"bancontact",
"billie",
"blik",
"boleto",
"card",
"card_present",
"cashapp",
"customer_balance",
"eps",
"fpx",
"giropay",
"grabpay",
"ideal",
"interac_present",
"kakao_pay",
"klarna",
"konbini",
"kr_card",
"link",
"mobilepay",
"multibanco",
"naver_pay",
"nz_bank_account",
"oxxo",
"p24",
"pay_by_bank",
"payco",
"paynow",
"paypal",
"pix",
"promptpay",
"revolut_pay",
"samsung_pay",
"satispay",
"sepa_debit",
"sofort",
"swish",
"twint",
"us_bank_account",
"wechat_pay",
"zip",
] as const;
export type PaymentType = (typeof PAYMENT_TYPES)[number];
export const PaymentMethodToString = (type: PaymentType) => {
switch (type) {
case "manual":

View file

@ -184,7 +184,7 @@ const AnnuncioView = ({ cod, flag }: Omit<AnnuncioProps, "meta">) => {
</Link>
<Link
className="w-full"
href={`/area-riservata/admin/scheda-annuncio-stampa/${data.id}`}
href={`/area-riservata/scheda-annuncio-stampa/${data.id}`}
target="_blank"
>
<Button

View file

@ -2,9 +2,9 @@ import type { NextApiRequest, NextApiResponse } from "next";
import type Stripe from "stripe";
import { env } from "~/env";
import stripe from "~/lib/stripe";
import type { PaymentsId } from "~/schemas/public/Payments";
import { appRouter } from "~/server/api/root";
import { addApiAuthToContext, createTRPCContext, t } from "~/server/api/trpc";
import { zOrdineId } from "~/server/utils/zod_types";
const handler = async (
req: NextApiRequest,
@ -43,19 +43,16 @@ const handler = async (
const { id, metadata } = paymentIntent;
if (!metadata) {
res.status(400).send("No metadata in payment intent");
return;
throw new Error("No metadata in payment intent");
}
if (
!metadata.paymentId ||
!(typeof metadata.paymentId === "string")
) {
res.status(400).send("No orderId or userId in metadata");
return;
const metadataOrdineId = metadata.ordineId;
const ordineId = zOrdineId.safeParse(metadataOrdineId);
if (!metadata.ordineId || !ordineId.success) {
throw new Error("No ordineId or userId in metadata");
}
await caller.stripe.whIntentCreated({
paymentId: metadata.paymentId as PaymentsId,
ordineId: ordineId.data,
pIntentId: id,
});
@ -66,18 +63,15 @@ const handler = async (
const { id, metadata } = paymentIntent;
if (!metadata) {
res.status(400).send("No metadata in payment intent");
return;
throw new Error("No metadata in payment intent");
}
if (
!metadata.paymentId ||
!(typeof metadata.paymentId === "string")
) {
res.status(400).send("No orderId or userId in metadata");
return;
const metadataOrdineId = metadata.ordineId;
const ordineId = zOrdineId.safeParse(metadataOrdineId);
if (!metadata.ordineId || !ordineId.success) {
throw new Error("No ordineId or userId in metadata");
}
await caller.stripe.whIntentSucceeded({
paymentId: metadata.paymentId as PaymentsId,
ordineId: ordineId.data,
pIntentId: id,
pm_id:
typeof paymentIntent.payment_method === "string"

View file

@ -26,7 +26,7 @@ const Ordini: NextPageWithLayout = () => {
<h3 className="font-bold text-2xl">Ordini</h3>
</div>
</div>
<OrdiniTable data={data} isAdmin />
<OrdiniTable data={data} filter={null} isAdmin />
</div>
</>
);

View file

@ -38,7 +38,7 @@ const OnboardUser: NextPageWithLayout<OnboardUserProps> = ({
<CatastoProvider>
<FormNewServizioAcquisto
initialData={data}
isAdmin
isAdmin={true}
userId={data.userData.id}
/>
</CatastoProvider>

View file

@ -1,26 +1,24 @@
import type { GetServerSideProps } from "next";
import Link from "next/link";
import { OrdineManualeDialog } from "~/components/area-riservata/ordine_manuale";
import Head from "next/head";
import { AreaRiservataLayoutUserView } from "~/components/Layout";
import { LoadingPage } from "~/components/loading";
import { Status500 } from "~/components/status-page";
import { OrdiniTable } from "~/components/tables/orders-table";
import { Button } from "~/components/ui/button";
import type { NextPageWithLayout } from "~/pages/_app";
import type { ServizioServizioId } from "~/schemas/public/Servizio";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import type { UsersId } from "~/schemas/public/Users";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { zServizioId, zUserId } from "~/server/utils/zod_types";
import { zUserId } from "~/server/utils/zod_types";
import { api } from "~/utils/api";
type OrdiniUserProps = {
userId: UsersId;
servizioId: ServizioServizioId | null;
ordineId: OrdiniOrdineId | null;
};
const OrdiniUser: NextPageWithLayout<OrdiniUserProps> = ({
userId,
servizioId,
ordineId,
}: OrdiniUserProps) => {
const { data, isLoading } = api.servizio.getOrdini.useQuery({
userId,
@ -29,39 +27,24 @@ const OrdiniUser: NextPageWithLayout<OrdiniUserProps> = ({
if (isLoading) return <LoadingPage />;
if (!data) return <Status500 />;
return (
<>
<Head>
<title>Ordini - Infoalloggi.it</title>
</Head>
<main className="flex flex-1 flex-col items-start gap-4">
<div className="flex w-full flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 font-bold text-2xl">
Ordini{" "}
<span className="text-sm">
{servizioId ? `- Servizio ${servizioId}` : ""}
</span>
</h3>
{servizioId && (
<div className="flex items-center gap-2">
<OrdineManualeDialog servizioId={servizioId} userId={userId} />
<Link href={`/area-riservata/admin/user-view/ordini/${userId}`}>
<Button size="sm" variant="destructive">
Rimuovi filtro
</Button>
</Link>
<h3 className="flex items-center gap-2 font-bold text-2xl">Ordini</h3>
</div>
)}
</div>
<OrdiniTable
data={data.filter((ordine) =>
servizioId ? ordine.servizio_id === servizioId : true,
)}
isAdmin
userId={userId}
/>
<OrdiniTable data={data} filter={ordineId} isAdmin userId={userId} />
</main>
</>
);
};
export default OrdiniUser;
export const getServerSideProps = (async (context) => {
const userId = context.params?.userId;
if (typeof userId !== "string") {
return {
notFound: true,
@ -75,6 +58,13 @@ export const getServerSideProps = (async (context) => {
notFound: true,
};
}
const ordineId = context.query?.ordineId;
if (ordineId && typeof ordineId !== "string") {
return {
notFound: true,
};
}
const access_token = context.req.cookies.access_token;
const helpers = await TrpcAuthedFetchingIstance({ access_token });
@ -83,18 +73,11 @@ export const getServerSideProps = (async (context) => {
userId: parsed.data,
});
let servizioId: ServizioServizioId | null = null;
const parsedServizioId = zServizioId.safeParse(context.query?.servizioId);
if (parsedServizioId.success) {
servizioId = parsedServizioId.data;
}
return {
props: {
ordineId: ordineId ? (ordineId as OrdiniOrdineId) : null,
userId: parsed.data,
trpcState: helpers.trpc.dehydrate(),
servizioId,
},
};
}

View file

@ -6,18 +6,42 @@ import { Status500 } from "~/components/status-page";
import { OrdiniTable } from "~/components/tables/orders-table";
import type { NextPageWithLayout } from "~/pages/_app";
import { useEnforcedSession } from "~/providers/SessionProvider";
import type { UsersId } from "~/schemas/public/Users";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { api } from "~/utils/api";
const Ordini: NextPageWithLayout = () => {
type OrdiniProps = {
ordineId: OrdiniOrdineId | null;
};
const Ordini: NextPageWithLayout<OrdiniProps> = ({ ordineId }: OrdiniProps) => {
const session = useEnforcedSession();
const { data, isLoading } = api.servizio.getOrdini.useQuery({
userId: session.user.id,
});
if (isLoading) return <LoadingPage />;
if (!data) return <Status500 />;
return (
<>
<Head>
<title>Ordini - Infoalloggi.it</title>
</Head>
<SessionWrapper userId={session.user.id} />
<main className="mx-1 mt-2 flex flex-1 flex-col items-start gap-4 p-2 md:gap-6">
<div className="grid w-full auto-rows-max items-start gap-2 md:gap-4">
<div className="overflow-auto border-none">
<h3 className="font-bold text-2xl">Ordini</h3>
<div className="w-full py-4">
<OrdiniTable
data={data}
filter={ordineId}
isAdmin={false}
userId={session.user.id}
/>
</div>
</div>
</div>
</main>
</>
);
};
@ -25,12 +49,19 @@ export default Ordini;
export const getServerSideProps = (async (context) => {
const access_token = context.req.cookies.access_token;
const ordineId = context.query?.ordineId;
if (ordineId && typeof ordineId !== "string") {
return {
notFound: true,
};
}
const helpers = await TrpcAuthedFetchingIstance({ access_token });
if (helpers) {
await helpers.trpc.servizio.getOrdini.prefetch({});
return {
props: {
ordineId: ordineId ? (ordineId as OrdiniOrdineId) : null,
trpcState: helpers.trpc.dehydrate(),
},
};
@ -40,29 +71,6 @@ export const getServerSideProps = (async (context) => {
};
}) satisfies GetServerSideProps;
const SessionWrapper = ({ userId }: { userId: UsersId }) => {
const { data, isLoading } = api.servizio.getOrdini.useQuery({
userId,
});
if (isLoading) return <LoadingPage />;
if (!data) return <Status500 />;
return (
<main className="mx-1 mt-2 flex flex-1 flex-col items-start gap-4 p-2 md:gap-6">
<div className="grid w-full auto-rows-max items-start gap-2 md:gap-4">
<div className="overflow-auto border-none">
<h3 className="font-bold text-2xl">Ordini</h3>
<div className="w-full py-4">
<OrdiniTable data={data} isAdmin={false} userId={userId} />
</div>
</div>
</div>
</main>
);
};
Ordini.getLayout = function getLayout(page) {
return <AreaRiservataLayout>{page}</AreaRiservataLayout>;
};

View file

@ -1,15 +1,10 @@
import { ArrowRight } from "lucide-react";
import type { GetServerSideProps } from "next";
import Link from "next/link";
import { UserDashboardFaq } from "~/components/area-riservata/dashboard";
import { AreaRiservataLayout } from "~/components/Layout";
import { LoadingPage } from "~/components/loading";
import { Servizio } from "~/components/servizio/servizio";
import { Status500 } from "~/components/status-page";
import { Button } from "~/components/ui/button";
import { Separator } from "~/components/ui/separator";
import type { NextPageWithLayout } from "~/pages/_app";
import { useTranslation } from "~/providers/I18nProvider";
import { useEnforcedSession } from "~/providers/SessionProvider";
import type { ServizioServizioId } from "~/schemas/public/Servizio";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
@ -23,7 +18,6 @@ const ServizioPage: NextPageWithLayout<ServizioPageProps> = ({
servizioId,
}: ServizioPageProps) => {
const { user } = useEnforcedSession();
const { t } = useTranslation();
const { data, isLoading } = api.servizio.getServizioData.useQuery({
servizioId,
});
@ -36,7 +30,7 @@ const ServizioPage: NextPageWithLayout<ServizioPageProps> = ({
}
return (
<div className="flex w-full flex-1 flex-col items-start justify-center gap-3 overflow-auto p-2 md:gap-6">
<div className="flex w-full flex-col gap-2">
{/* <div className=" w-full flex-col gap-2 flex">
<div className="flex w-full items-center justify-between">
<h3 className="font-semibold text-xl">
{t.servizio.dettaglio_servizio}
@ -50,7 +44,7 @@ const ServizioPage: NextPageWithLayout<ServizioPageProps> = ({
</Link>
</div>
<Separator />
</div>
</div> */}
<div className="flex w-full flex-1 grow flex-col space-y-5">
<Servizio
isAdmin={false}

View file

@ -89,7 +89,10 @@ const FAQSection = () => {
{item.qas.map((f, i) => (
<AccordionItem
className="border-b dark:border-muted-foreground/50"
key={`accordion-item-${item.id}-${i}`}
key={`accordion-item-${item.id}-${
// biome-ignore lint/suspicious/noArrayIndexKey: <ok>
i
}`}
value={i.toString()}
>
<AccordionTrigger className="cursor-pointer text-left">

View file

@ -0,0 +1,124 @@
import { ArrowRight } from "lucide-react";
import type { GetServerSideProps } from "next";
import Link from "next/link";
import { AreaRiservataLayout } from "~/components/Layout";
import { Button } from "~/components/ui/button";
import { formatCurrency } from "~/lib/utils";
import type { NextPageWithLayout } from "~/pages/_app";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { zOrdineId } from "~/server/utils/zod_types";
type BonificoManualeProps = {
titolo: string;
prezzo: number;
sconto: number;
descrizione: string;
};
/**
* /servizio/bonifico-checkout/[ordineId] \
* Pagina di checkout per il pagamento di un ordine di servizio tramite bonifico
*/
const BonificoManualePage: NextPageWithLayout<BonificoManualeProps> = ({
titolo,
prezzo,
sconto,
descrizione,
}: BonificoManualeProps) => {
return (
<div className="mx-auto my-5 w-full max-w-lg space-y-8">
<h1 className="font-semibold text-3xl">Pagamento tramite bonifico</h1>
<div className="space-y-4 rounded border p-4">
<p className="font-bold text-xl">{titolo}</p>
<p className="text-lg">{descrizione}</p>
<p className="font-semibold text-lg">
Importo: {formatCurrency(prezzo / 100)}{" "}
{sconto > 0 && (
<span className="text-green-500">(Sconto: {sconto}%)</span>
)}
</p>
<div className="space-y-2 rounded bg-gray-100 p-4">
<p className="font-semibold">Istruzioni per il bonifico:</p>
<ul className="list-disc pl-5">
<li>
Intestazione: <b>Arcenia Srl</b>
</li>
<li>
IBAN: <b>IT33F0326860160052727620300</b> BANCA SELLA (Codice
Swift) SELBIT2BXXX per bonifici esteri
</li>
<li>
Causale: <b>{titolo}</b>
</li>
</ul>
</div>
<p className="">
Inoltraci la contabile del bonifico per velocizzare la verifica del
pagamento.
</p>
<span>Grazie!</span>
</div>
<Link href="/area-riservata/dashboard">
<Button className="w-full" variant="outline">
<span>Torna ai servizi</span>
<ArrowRight className="size-4" />
</Button>
</Link>
</div>
);
};
BonificoManualePage.getLayout = function getLayout(page) {
return <AreaRiservataLayout noSidebar>{page}</AreaRiservataLayout>;
};
export default BonificoManualePage;
export const getServerSideProps = (async (context) => {
const id = context.params?.ordineId;
if (typeof id !== "string") {
console.error("Error: ordineId is not a string");
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}
const parsed = zOrdineId.safeParse(id);
if (!parsed.success) {
console.error("Error parsing ordineId", parsed.error);
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}
const access_token = context.req.cookies.access_token;
const helper = await TrpcAuthedFetchingIstance({ access_token });
if (helper) {
const paymentData =
await helper.trpc.pagamenti.getPagamentoDataForCheckout.fetch({
ordineId: parsed.data,
});
return {
props: {
titolo: paymentData.nome_it,
prezzo: paymentData.amount_cent,
sconto: paymentData.sconto,
descrizione: paymentData.desc_it,
},
};
}
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}) satisfies GetServerSideProps<BonificoManualeProps>;

View file

@ -95,6 +95,7 @@ const ConfirmSection = ({
},
onSuccess: async (data, _variables, context) => {
await utils.storage.retrieveUserFileData.invalidate();
await utils.servizio.invalidate();
toast.success("Confermato", {
icon: "👍",
@ -105,7 +106,7 @@ const ConfirmSection = ({
`/area-riservata/admin/user-view/ricerca/${data.userId}`,
);
} else {
await router.push("/area-riservata/dashboard");
await router.push(`/area-riservata/servizio/${servizioId}`);
}
},
});

View file

@ -15,7 +15,13 @@ import { api } from "~/utils/api";
type OnboardServizioProps = {
servizioId: ServizioServizioId;
};
/**
* /servizio/onboard/[servizioId] \
* Pagina di onboarding per un nuovo servizio. Mostra un tutorial e un form per completare l'acquisto del servizio, con i dati precompilati se disponibili.
* Accessibile solo se l'onboarding è disponibile per l'utente e il servizio specificato.
* Se l'onboarding non è disponibile, reindirizza alla dashboard dell'area riservata.
* Se non è possibile recuperare i dati necessari, mostra una pagina di errore.
*/
const OnboardServizio: NextPageWithLayout<OnboardServizioProps> = ({
servizioId,
}: OnboardServizioProps) => {
@ -38,7 +44,11 @@ const OnboardServizio: NextPageWithLayout<OnboardServizioProps> = ({
</div>
<OnboardTutorial />
<CatastoProvider>
<FormNewServizioAcquisto initialData={data} userId={data.userData.id} />
<FormNewServizioAcquisto
initialData={data}
isAdmin={false}
userId={data.userData.id}
/>
</CatastoProvider>
</div>
);

View file

@ -4,7 +4,7 @@ import { AreaRiservataLayout } from "~/components/Layout";
import stripe from "~/lib/stripe";
import type { NextPageWithLayout } from "~/pages/_app";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { zPagamentoId } from "~/server/utils/zod_types";
import { zOrdineId } from "~/server/utils/zod_types";
type PagamentoPageProps = {
clientSecret: string | null;
@ -13,6 +13,10 @@ type PagamentoPageProps = {
descrizione: string;
};
/**
* /servizio/pagamento-checkout/[ordineId] \
* Pagina di checkout per il pagamento di un ordine di servizio. Riceve l'ordineId, recupera i dati necessari e crea un PaymentIntent su Stripe, quindi passa il clientSecret al componente di checkout.
*/
const PagamentoPage: NextPageWithLayout<PagamentoPageProps> = ({
clientSecret,
titolo,
@ -41,9 +45,9 @@ PagamentoPage.getLayout = function getLayout(page) {
export default PagamentoPage;
export const getServerSideProps = (async (context) => {
const id = context.params?.paymentId;
const id = context.params?.ordineId;
if (typeof id !== "string") {
console.error("Error: paymentId is not a string");
console.error("Error: ordineId is not a string");
return {
redirect: {
destination: "/500",
@ -52,9 +56,9 @@ export const getServerSideProps = (async (context) => {
};
}
const parsed = zPagamentoId.safeParse(id);
const parsed = zOrdineId.safeParse(id);
if (!parsed.success) {
console.error("Error parsing paymentId", parsed.error);
console.error("Error parsing ordineId", parsed.error);
return {
redirect: {
destination: "/500",
@ -69,7 +73,15 @@ export const getServerSideProps = (async (context) => {
if (helper) {
const paymentData =
await helper.trpc.pagamenti.getPagamentoDataForCheckout.fetch({
paymentId: parsed.data,
ordineId: parsed.data,
});
const customer = await stripe.customers.create({
email: paymentData.email,
name: paymentData.username,
metadata: {
userId: paymentData.userid,
},
});
const { client_secret: clientSecret } = await stripe.paymentIntents.create({
@ -79,12 +91,15 @@ export const getServerSideProps = (async (context) => {
enabled: true,
},
currency: "eur",
customer: customer.id,
description: paymentData.nome_it,
metadata: {
paymentId: paymentData.id,
packId: paymentData.packid,
ordineId: paymentData.ordine_id,
servizioId: paymentData.servizio_id,
userId: paymentData.userid,
},
receipt_email: paymentData.email,
});

View file

@ -9,6 +9,7 @@ import stripe from "~/lib/stripe";
import { cn } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum";
import type { ServizioServizioId } from "~/schemas/public/Servizio";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
enum AcquistoStatusEnum {
@ -20,6 +21,7 @@ enum AcquistoStatusEnum {
}
type AcquistoProcessingProps = {
servizioId: ServizioServizioId | null;
status: AcquistoStatusEnum;
};
@ -38,6 +40,7 @@ export const getServerSideProps = (async (context) => {
if (!paymentIntent) {
return {
props: {
servizioId: null,
status: AcquistoStatusEnum.internal_error,
},
};
@ -54,6 +57,7 @@ export const getServerSideProps = (async (context) => {
if (!payment) {
return {
props: {
servizioId: null,
status: AcquistoStatusEnum.not_found,
},
};
@ -75,6 +79,7 @@ export const getServerSideProps = (async (context) => {
}
return {
props: {
servizioId: payment.servizio_id,
status: resultStatus,
},
};
@ -89,6 +94,7 @@ export const getServerSideProps = (async (context) => {
}) satisfies GetServerSideProps<AcquistoProcessingProps>;
export default function AcquistoProcessing({
servizioId,
status,
}: AcquistoProcessingProps) {
const { t } = useTranslation();
@ -108,7 +114,7 @@ export default function AcquistoProcessing({
title={t.acquisto_elaborazione.success_title}
/>
<ProgressRedirect href="/area-riservata/dashboard" />
<ProgressRedirect href={`/area-riservata/servizio/${servizioId}`} />
</>
)}
{status === AcquistoStatusEnum.processing && (

View file

@ -1,173 +0,0 @@
import { ArrowRight, ExternalLink } from "lucide-react";
import type { GetServerSideProps } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import { AreaRiservataLayout } from "~/components/Layout";
import { LoadingPage } from "~/components/loading";
import { Status500 } from "~/components/status-page";
import { Button } from "~/components/ui/button";
import { formatCurrency } from "~/lib/utils";
import type { NextPageWithLayout } from "~/pages/_app";
import { useTranslation } from "~/providers/I18nProvider";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import type { PrezziarioIdprezziario } from "~/schemas/public/Prezziario";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { zOrdineId } from "~/server/utils/zod_types";
import { api } from "~/utils/api";
type ServizioPurchaseProps = {
ordineId: OrdiniOrdineId;
};
const ServicePurchasePage: NextPageWithLayout<ServizioPurchaseProps> = ({
ordineId,
}: ServizioPurchaseProps) => {
const router = useRouter();
const { data, isLoading } = api.servizio.getServizioPaymentData.useQuery({
ordineId,
});
if (isLoading) {
return <LoadingPage />;
}
if (!data) {
return <Status500 />;
}
return (
<div className="mx-auto w-full max-w-6xl grow space-y-4 p-2 sm:px-8 sm:py-4">
<PricePreparation
onProcedi={async () =>
await router.push(`/servizio/pagamento-checkout/${data.payment.id}`)
}
packId={data.ordine.packid}
/>
</div>
);
};
const PricePreparation = ({
packId,
onProcedi,
}: {
packId: PrezziarioIdprezziario;
onProcedi: () => void;
}) => {
const { data: prezziario, isLoading } =
api.prezziario.getPrezzoPerServizio.useQuery({
idprezziario: packId,
});
const { t } = useTranslation();
return (
<div className="mx-auto mt-4 max-w-2xl p-2 sm:mt-10">
{isLoading || !prezziario ? (
<>
<h3 className="font-semibold text-2xl">{t.acquisto.preparazione}</h3>
<LoadingPage />
</>
) : (
<div className="space-y-8">
<div className="space-y-6">
<p className="text-3xl">{prezziario.nome_it}</p>
<p className="text-lg">
Cod: &ldquo;{prezziario.idprezziario}&rdquo;
</p>
<p className="font-bold text-4xl">
{formatCurrency(prezziario.prezzo_cent / 100)}
</p>
<p>{prezziario.desc_it}</p>
</div>
{prezziario.testo_condizioni && (
<Link
aria-label="Leggi le condizioni"
className="block w-fit"
href={`/servizio/condizioni/${prezziario.testo_condizioni}`}
target="_blank"
>
<Button className="w-full text-sm">
<span>{t.acquisto.leggi_condizioni}</span>
<ExternalLink />
</Button>
</Link>
)}
<Button
className="w-full text-lg"
disabled={isLoading || !prezziario}
onClick={onProcedi}
size="xl"
variant="info"
>
<span>{t.acquisto.procedi_pagamento}</span> <ArrowRight />
</Button>
</div>
)}
</div>
);
};
ServicePurchasePage.getLayout = function getLayout(page) {
return <AreaRiservataLayout noSidebar>{page}</AreaRiservataLayout>;
};
export default ServicePurchasePage;
export const getServerSideProps = (async (context) => {
const id = context.params?.ordineId;
if (typeof id !== "string") {
console.error("Error: ordineId is not a string");
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}
const parsed = zOrdineId.safeParse(id);
if (!parsed.success) {
console.error("Error parsing ordineId", parsed.error);
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}
const access_token = context.req.cookies.access_token;
const helper = await TrpcAuthedFetchingIstance({ access_token });
if (helper) {
const isAwaitingPayment =
await helper.trpc.servizio.isOrdineAwaitingPayment.fetch({
ordineId: parsed.data,
});
if (!isAwaitingPayment) {
return {
redirect: {
destination: "/area-riservata/dashboard",
permanent: false,
},
};
}
await helper.trpc.servizio.getServizioPaymentData.prefetch({
ordineId: parsed.data,
});
return {
props: {
ordineId: parsed.data,
trpcState: helper.trpc.dehydrate(),
},
};
}
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}) satisfies GetServerSideProps<ServizioPurchaseProps>;

View file

@ -0,0 +1,151 @@
import { ArrowRight, ExternalLink } from "lucide-react";
import type { GetServerSideProps } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import z from "zod";
import { AreaRiservataLayout } from "~/components/Layout";
import { Button } from "~/components/ui/button";
import { formatCurrency } from "~/lib/utils";
import type { NextPageWithLayout } from "~/pages/_app";
import { useTranslation } from "~/providers/I18nProvider";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import type { PreparedPaymentData } from "~/server/controllers/pagamenti.controller";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { zServizioId } from "~/server/utils/zod_types";
type PreviewPurchaseProps = {
stripeDisabled: boolean;
data: PreparedPaymentData;
};
/**
* /servizio/pagamento/[servizioId]?type=<OrderTypeEnum> \
* Pagina di preparazione al pagamento.
*/
const PreviewPurchasePage: NextPageWithLayout<PreviewPurchaseProps> = ({
stripeDisabled,
data,
}: PreviewPurchaseProps) => {
const router = useRouter();
const { t } = useTranslation();
return (
<div className="mx-auto w-full max-w-6xl grow space-y-4 p-2 sm:px-8 sm:py-4">
<div className="mx-auto mt-4 max-w-2xl p-2 sm:mt-10">
<div className="space-y-8">
<div className="space-y-6">
<p className="text-3xl">{data.packName}</p>
<p className="text-lg">Cod: &ldquo;{data.packid}&rdquo;</p>
<p className="font-bold text-4xl">
{formatCurrency(data.amount_cent / 100)} {data.sconto > 0 && <span className="text-green-500">(Sconto: {data.sconto}%)</span>}
</p>
<p>{data.packDesc}</p>
</div>
<Link
aria-label="Leggi le condizioni"
className="block w-fit"
href={`/servizio/condizioni/${data.condizioni}`}
target="_blank"
>
<Button className="w-full text-sm">
<span>{t.acquisto.leggi_condizioni}</span>
<ExternalLink />
</Button>
</Link>
<Button
className="w-full text-lg"
onClick={async () =>
await router.push(
`/servizio/${stripeDisabled ? "bonifico-checkout" : "pagamento-checkout"}/${data.ordine_id}`,
)
}
size="xl"
variant="info"
>
<span>{t.acquisto.procedi_pagamento}</span> <ArrowRight />
</Button>
</div>
</div>
</div>
);
};
PreviewPurchasePage.getLayout = function getLayout(page) {
return <AreaRiservataLayout noSidebar>{page}</AreaRiservataLayout>;
};
export default PreviewPurchasePage;
export const getServerSideProps = (async (context) => {
const id = context.params?.servizioId;
if (typeof id !== "string") {
console.error("Error: servizioId is not a string");
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}
const parsedServizioId = zServizioId.safeParse(id);
if (!parsedServizioId.success) {
console.error("Error parsing servizioId", parsedServizioId.error);
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}
const payment_type = context.query?.type;
if (typeof payment_type !== "string") {
console.error("Error: payment type is not a string");
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}
const parsedType = z.enum(OrderTypeEnum).safeParse(payment_type);
if (!parsedType.success) {
console.error("Error parsing payment type", parsedType.error);
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}
const access_token = context.req.cookies.access_token;
const helper = await TrpcAuthedFetchingIstance({ access_token });
if (helper) {
const data = await helper.trpc.pagamenti.preparePayment.fetch({
type: parsedType.data,
servizioId: parsedServizioId.data,
});
const flag = await helper.trpc.flags.GetFlagValue.fetch({
id: "STRIPE_DISABLED",
});
return {
props: {
stripeDisabled: flag || false,
data,
},
};
}
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}) satisfies GetServerSideProps<PreviewPurchaseProps>;

View file

@ -45,7 +45,6 @@ export const useServizio = () => {
type ServizioAnnuncioProviderProps = {
data: ServizioData["annunci"][number];
servizioId: ServizioServizioId;
userId: UsersId;
children: React.ReactNode;
};
@ -60,7 +59,6 @@ const ServizioAnnuncioContext =
export const ServizioAnnuncioProvider = ({
data,
servizioId,
userId,
children,
}: ServizioAnnuncioProviderProps) => {
const utils = api.useUtils();
@ -70,10 +68,7 @@ export const ServizioAnnuncioProvider = ({
toast.error(error.message);
},
onSuccess: async () => {
await utils.servizio.getAllServizioAnnunci.invalidate({
userId: userId,
});
await utils.servizio.getServizioData.invalidate({ servizioId });
await utils.servizio.invalidate();
toast.success("Dati salvati con successo");
},

View file

@ -10,7 +10,7 @@ export const authProxy: ProxyFn = async (req: NextRequest) => {
const path = req.nextUrl.pathname;
const isOverridableRoute =
path.startsWith("/area-riservata/admin/scheda-annuncio-stampa/") ||
path.startsWith("/area-riservata/scheda-annuncio-stampa/") ||
path.startsWith("/servizio/condizioni/") ||
path.startsWith("/servizio/ricevuta-cortesia/");
// permette a puppeteer di accedere alla pagina di generazione PDF bypassando l'autenticazione normale

View file

@ -5,6 +5,7 @@ import type { UsersId } from './Users';
import type { PrezziarioIdprezziario } from './Prezziario';
import type { ServizioServizioId } from './Servizio';
import type { default as OrderTypeEnum } from './OrderTypeEnum';
import type { default as PaymentStatusEnum } from './PaymentStatusEnum';
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.ordini */
@ -25,6 +26,18 @@ export default interface OrdiniTable {
type: ColumnType<OrderTypeEnum, OrderTypeEnum, OrderTypeEnum>;
isActive: ColumnType<boolean, boolean | undefined, boolean>;
amount_cent: ColumnType<number, number, number>;
paymentmethod: ColumnType<string | null, string | null, string | null>;
paid_at: ColumnType<Date | null, Date | string | null, Date | string | null>;
intent_id: ColumnType<string | null, string | null, string | null>;
paymentstatus: ColumnType<PaymentStatusEnum | null, PaymentStatusEnum | null, PaymentStatusEnum | null>;
sconto: ColumnType<number, number | undefined, number>;
}
export type Ordini = Selectable<OrdiniTable>;

View file

@ -70,6 +70,8 @@ export default interface ServizioTable {
skipControlloDoc: ColumnType<boolean, boolean | undefined, boolean>;
skipDocMotivazione: ColumnType<boolean, boolean | undefined, boolean>;
onboardOk: ColumnType<boolean, boolean | undefined, boolean>;
}
export type Servizio = Selectable<ServizioTable>;

View file

@ -1,9 +1,15 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import type OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { getPagamentoDataForCheckout } from "~/server/controllers/servizio.controller";
import {
getPagamentoDataForCheckout,
preparePayment,
} from "~/server/controllers/pagamenti.controller";
import { db } from "~/server/db";
import { zPagamentoId } from "~/server/utils/zod_types";
import { genPdfRicevutaCortesia } from "~/server/services/puppeteer.service";
import { zOrdineId, zServizioId } from "~/server/utils/zod_types";
// Create a new ratelimiter, that allows 10 requests per 10 seconds
/*
const ratelimit = new RateLimiterHandler({
@ -18,24 +24,45 @@ export const pagamentiRouter = createTRPCRouter({
.input(z.object({ pIntentId: z.string() }))
.query(async ({ input }) => {
try {
return (
(await db
.selectFrom("payments")
.selectAll("payments")
.where("payments.intent_id", "=", input.pIntentId)
.executeTakeFirst()) || null
);
const ordine = await db
.selectFrom("ordini")
.selectAll("ordini")
.where("ordini.intent_id", "=", input.pIntentId)
.executeTakeFirst();
return ordine || null;
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error retriving payment: ${(error as Error).message}`,
message: `Errore durante il recupero del pagamento: ${(error as Error).message}`,
});
}
}),
preparePayment: protectedProcedure
.input(
z.object({
servizioId: zServizioId,
type: z.custom<OrderTypeEnum>(),
}),
)
.query(async ({ input }) => {
return await preparePayment(input.servizioId, input.type);
}),
getPagamentoDataForCheckout: protectedProcedure
.input(z.object({ paymentId: zPagamentoId }))
.input(z.object({ ordineId: zOrdineId }))
.query(async ({ input }) => {
return await getPagamentoDataForCheckout(input.paymentId);
return await getPagamentoDataForCheckout(input.ordineId);
}),
getRicevutaCortesiaPdf: protectedProcedure
.input(z.object({ ordineId: zOrdineId }))
.mutation(async ({ input }) => {
try {
return await genPdfRicevutaCortesia(input.ordineId);
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore nella generazione della ricevuta di cortesia: ${(e as Error).message}`,
});
}
}),
});

View file

@ -84,7 +84,7 @@ export const prezziarioRouter = createTRPCRouter({
getPrezzoPerServizio: publicProcedure
.input(z.object({ idprezziario: zPrezziarioId }))
.query(async ({ input }) => {
return await getPrezziarioByIdHandler({ id: input.idprezziario });
return await getPrezziarioByIdHandler(input.idprezziario);
}),
updatePrezziario: adminProcedure
.input(

View file

@ -1,6 +1,5 @@
import { z } from "zod/v4";
import z from "zod";
import type { NewOrdini, OrdiniUpdate } from "~/schemas/public/Ordini";
import type { PaymentsUpdate } from "~/schemas/public/Payments";
import type { NewServizio, ServizioUpdate } from "~/schemas/public/Servizio";
import type { ServizioAnnunciUpdate } from "~/schemas/public/ServizioAnnunci";
import type { Users } from "~/schemas/public/Users";
@ -18,34 +17,27 @@ import {
removeOrder,
updateOrder,
} from "~/server/controllers/ordini.controller";
import { getPacksPerServizio } from "~/server/controllers/pagamenti.controller";
import {
addServizio,
addServizioAnnunci,
adminUpdateConfermaStatus,
attivaServizio_SaltaPagamentoAcconto,
deleteServizio,
deleteServizioAnnuncio,
getAllPagamenti,
getAllServizioAnnunci,
getAnnunciAvailableToAdd,
getCompatibileAnnunci,
getDataPerAcquisto,
getOrdineRicevutaData,
getPacksPerServizio,
getServiziByUserId,
getServizioAnnuncio,
getServizioById,
getServizioDataById,
getServizioPagamentoData,
getUserPagamenti,
InteractionLogicTipologia,
interruzioneServizio,
isOrdineAwaitingPayment,
postAcquistoDataHandler,
removePagamento,
processServizioOnboard,
SbloccaContatti,
setupSaldoConsulenzaServizio,
setupSaldoServizio,
updatePagamento,
updateServizio,
updateServizioAnnuncio,
userAccettaConferma,
@ -54,7 +46,6 @@ import {
import {
zAnnuncioId,
zOrdineId,
zPagamentoId,
zServizioId,
zUserId,
} from "~/server/utils/zod_types";
@ -116,9 +107,7 @@ export const servizioRouter = createTRPCRouter({
.query(async ({ input }) => {
return await getDataPerAcquisto(input.servizioId);
}),
getAllPagamenti: adminProcedure.query(async () => {
return await getAllPagamenti();
}),
getAllServizioAnnunci: protectedProcedure
.input(
z.object({
@ -185,6 +174,7 @@ export const servizioRouter = createTRPCRouter({
.query(async ({ input }) => {
return await getOrdini(input.userId);
}),
isOnboardAvailable: protectedProcedure
.input(
z.object({
@ -229,24 +219,6 @@ export const servizioRouter = createTRPCRouter({
servizioId: input.servizioId,
});
}),
getServizioPaymentData: protectedProcedure
.input(
z.object({
ordineId: zOrdineId,
}),
)
.query(async ({ input }) => {
return await getServizioPagamentoData(input.ordineId);
}),
getUserPagamenti: protectedProcedure
.input(
z.object({
userId: zUserId,
}),
)
.query(async ({ input }) => {
return await getUserPagamenti(input.userId);
}),
getUserServizi: adminProcedure
.input(
@ -266,17 +238,8 @@ export const servizioRouter = createTRPCRouter({
.mutation(async ({ input }) => {
return await interruzioneServizio(input.servizioId);
}),
isOrdineAwaitingPayment: protectedProcedure
.input(
z.object({
ordineId: zOrdineId,
}),
)
.query(async ({ input }) => {
return await isOrdineAwaitingPayment(input.ordineId);
}),
submitOnboardForPurchase: protectedProcedure
processServizioOnboard: protectedProcedure
.input(
z.object({
anagrafica: z.custom<UsersAnagraficaUpdate>(),
@ -289,10 +252,20 @@ export const servizioRouter = createTRPCRouter({
}),
)
.mutation(async ({ input }) => {
return await postAcquistoDataHandler({
return await processServizioOnboard({
...input,
});
}),
attivazioneSenzaPagamento: protectedProcedure
.input(
z.object({
ordineId: zOrdineId,
}),
)
.mutation(async ({ input }) => {
return await attivaServizio_SaltaPagamentoAcconto(input.ordineId);
}),
removeAnnuncioServizio: protectedProcedure
.input(
z.object({
@ -312,42 +285,7 @@ export const servizioRouter = createTRPCRouter({
.mutation(async ({ input }) => {
return await removeOrder(input.ordineId);
}),
removePagamento: adminProcedure
.input(
z.object({
pagamentoId: zPagamentoId,
}),
)
.mutation(async ({ input }) => {
return await removePagamento(input.pagamentoId);
}),
setupSaldoConsulenzaServizio: protectedProcedure
.input(
z.object({
annuncioId: zAnnuncioId,
servizioId: zServizioId,
}),
)
.mutation(async ({ input }) => {
return await setupSaldoConsulenzaServizio({
annuncioId: input.annuncioId,
servizioId: input.servizioId,
});
}),
setupSaldoServizio: protectedProcedure
.input(
z.object({
annuncioId: zAnnuncioId,
servizioId: zServizioId,
}),
)
.mutation(async ({ input }) => {
return await setupSaldoServizio({
annuncioId: input.annuncioId,
servizioId: input.servizioId,
});
}),
getPacksSolution: protectedProcedure
.input(
z.object({
@ -392,19 +330,7 @@ export const servizioRouter = createTRPCRouter({
ordineId: input.ordineId,
});
}),
updatePagamento: adminProcedure
.input(
z.object({
data: z.custom<PaymentsUpdate>(),
pagamentoId: zPagamentoId,
}),
)
.mutation(async ({ input }) => {
return await updatePagamento({
data: input.data,
paymentId: input.pagamentoId,
});
}),
updateServizio: protectedProcedure
.input(
z.object({

View file

@ -3,42 +3,30 @@ import {
adminProcedure,
createTRPCRouter,
protectedApiProcedure,
protectedProcedure,
} from "~/server/api/trpc";
import {
createIntentHandler,
stripeHealthCheck,
whIntentCreatedHandler,
whIntentFailedHandler,
whIntentSucceededHandler,
} from "~/server/controllers/stripe.controller";
import { zPagamentoId } from "~/server/utils/zod_types";
import { zOrdineId } from "~/server/utils/zod_types";
export const stripeRouter = createTRPCRouter({
health: adminProcedure.query(async () => {
return await stripeHealthCheck();
}),
createIntent: protectedProcedure
.input(
z.object({
paymentId: zPagamentoId,
}),
)
.mutation(async ({ input }) => {
return await createIntentHandler({
paymentId: input.paymentId,
});
}),
whIntentCreated: protectedApiProcedure
.input(
z.object({
paymentId: zPagamentoId,
ordineId: zOrdineId,
pIntentId: z.string(),
}),
)
.mutation(async ({ input }) => {
return await whIntentCreatedHandler({
paymentId: input.paymentId,
ordineId: input.ordineId,
pIntentId: input.pIntentId,
});
}),
@ -53,14 +41,14 @@ export const stripeRouter = createTRPCRouter({
whIntentSucceeded: protectedApiProcedure
.input(
z.object({
paymentId: zPagamentoId,
ordineId: zOrdineId,
pIntentId: z.string(),
pm_id: z.string(),
}),
)
.mutation(async ({ input }) => {
return await whIntentSucceededHandler({
paymentId: input.paymentId,
ordineId: input.ordineId,
pIntentId: input.pIntentId,
pm_id: input.pm_id,
});

View file

@ -1,31 +1,23 @@
import { TRPCError } from "@trpc/server";
import { jsonArrayFrom } from "kysely/helpers/postgres";
import type {
NewOrdini,
Ordini,
OrdiniOrdineId,
OrdiniUpdate,
} from "~/schemas/public/Ordini";
import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum";
import type { UsersId } from "~/schemas/public/Users";
import { db, type Querier } from "~/server/db";
import type { Users, UsersId } from "~/schemas/public/Users";
import { db } from "~/server/db";
export type OrdiniWPagamenti = Awaited<ReturnType<typeof getOrdini>>[number];
export const getOrdini = async (userId: UsersId | undefined) => {
export type Ordini_w_Utente = Ordini & Pick<Users, "username">;
export const getOrdini = async (
userId: UsersId | undefined,
): Promise<Ordini_w_Utente[]> => {
try {
let qry = db
.selectFrom("ordini")
.selectAll("ordini")
.innerJoin("users", "users.id", "ordini.userid")
.select("users.username")
.select((eb) => [
jsonArrayFrom(
eb
.selectFrom("payments")
.selectAll("payments")
.whereRef("payments.ordine_id", "=", "ordini.ordine_id")
.orderBy("payments.created_at", "desc"),
).as("pagamenti"),
]);
.select("users.username");
if (userId) {
qry = qry.where("ordini.userid", "=", userId);
@ -46,15 +38,6 @@ export const getOrdineById = async (ordineId: OrdiniOrdineId) => {
.selectAll("ordini")
.innerJoin("users", "users.id", "ordini.userid")
.select("users.username")
.select((eb) => [
jsonArrayFrom(
eb
.selectFrom("payments")
.selectAll("payments")
.whereRef("payments.ordine_id", "=", "ordini.ordine_id")
.orderBy("payments.created_at", "desc"),
).as("pagamenti"),
])
.where("ordini.ordine_id", "=", ordineId)
.orderBy("created_at", "desc")
.executeTakeFirstOrThrow(
@ -70,38 +53,11 @@ export const getOrdineById = async (ordineId: OrdiniOrdineId) => {
export const createOrdine = async (data: NewOrdini) => {
try {
return await db.transaction().execute(async (tx) => {
const prezzo = await tx
.selectFrom("prezziario")
.select(["prezzo_cent", "nome_it"])
.where("prezziario.idprezziario", "=", data.packid)
.executeTakeFirstOrThrow(
() => new Error(`Prezziario not found - packid: ${data.packid}`),
);
const order = await tx
return await db
.insertInto("ordini")
.values(data)
.returningAll()
.executeTakeFirstOrThrow(() => new Error("Failed to create ordine"));
await tx
.insertInto("payments")
.values({
ordine_id: order.ordine_id,
servizio_id: order.servizio_id,
userid: order.userid,
amount_cent: prezzo.prezzo_cent,
paymentname: prezzo.nome_it,
paymentmethod: "manual",
paymentstatus: data.isActive
? PaymentStatusEnum.success
: PaymentStatusEnum.processing,
paid_at: data.isActive ? new Date() : null,
})
.execute();
return order;
});
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
@ -146,29 +102,3 @@ export const updateOrder = async ({
});
}
};
export const cleanupUnusedOrders = async ({ db }: { db: Querier }) => {
try {
const orders = await db
.selectFrom("ordini")
.select("ordini.ordine_id")
.leftJoin("payments", "payments.ordine_id", "ordini.ordine_id")
.where("payments.paymentstatus", "is", null)
.where("ordini.isActive", "=", false)
.distinct()
.execute();
if (orders.length === 0) {
return "ok";
}
const ordineIds = orders.map((o) => o.ordine_id);
await db.deleteFrom("ordini").where("ordine_id", "in", ordineIds).execute();
return "ok";
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error cleaning up old orders: ${(e as Error).message}`,
});
}
};

View file

@ -0,0 +1,345 @@
import { TRPCError } from "@trpc/server";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import type {
Prezziario,
PrezziarioIdprezziario,
} from "~/schemas/public/Prezziario";
import type { Servizio, ServizioServizioId } from "~/schemas/public/Servizio";
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import { db } from "../db";
import { getPrezziarioByIdHandler } from "../services/prezziario.service";
import { createOrdine } from "./ordini.controller";
import { getServizioById } from "./servizio.controller";
export const ACCONTO_TRANSITORIO = "ACCONTO_BD" as PrezziarioIdprezziario;
export const ACCONTO_STABILE = "ACCONTO_CA" as PrezziarioIdprezziario;
export type PreparedPaymentData = {
ordine_id: OrdiniOrdineId;
packid: PrezziarioIdprezziario;
amount_cent: number;
sconto: number;
packName: string;
packDesc: string;
condizioni: string;
};
export const preparePayment = async (
servizioId: ServizioServizioId,
type: OrderTypeEnum,
): Promise<PreparedPaymentData> => {
try {
const servizio = await getServizioById(servizioId);
const inactiveOrders = await db
.selectFrom("ordini")
.selectAll()
.where("servizio_id", "=", servizioId)
.where("isActive", "=", false)
.where("paymentstatus", "is", null)
.execute();
switch (type) {
case OrderTypeEnum.Acconto: {
if (!servizio.isOkAcconto) {
const acconto = await AccontoSolver(servizio);
if (!acconto.success) {
throw new Error(
`Acconto non trovato per il servizio: ${acconto.message}`,
);
}
if (!acconto.pack.testo_condizioni) {
throw new Error("Condizioni non trovate per acconto");
}
// servizio sta aspettando attivazione acconto
const preExistingAcconto = inactiveOrders.find(
(ordine) => ordine.type === OrderTypeEnum.Acconto,
);
if (preExistingAcconto) {
// acconto già creato, procedere con questo
return {
ordine_id: preExistingAcconto.ordine_id,
packid: preExistingAcconto.packid,
amount_cent: preExistingAcconto.amount_cent,
sconto: preExistingAcconto.sconto,
packName: acconto.pack.nome_it,
packDesc: acconto.pack.desc_it,
condizioni: acconto.pack.testo_condizioni,
};
}
// nessun acconto preesistente, crearne uno nuovo
const ordine = await createOrdine({
servizio_id: servizioId,
amount_cent: acconto.pack.prezzo_cent,
packid: acconto.pack.idprezziario,
type: OrderTypeEnum.Acconto,
userid: servizio.user_id,
});
return {
ordine_id: ordine.ordine_id,
packid: acconto.pack.idprezziario,
amount_cent: acconto.pack.prezzo_cent,
sconto: 0,
packName: acconto.pack.nome_it,
packDesc: acconto.pack.desc_it,
condizioni: acconto.pack.testo_condizioni,
};
}
throw new Error(
"Il servizio ha già un acconto attivo, non è possibile preparare un nuovo pagamento",
);
}
case OrderTypeEnum.Saldo: {
if (!servizio.isOkAcconto) {
throw new Error(
"Non è possibile procedere con il pagamento del saldo prima di aver attivato l'acconto",
);
}
if (!servizio.isOkSaldo) {
//find saldo pack based on servizio tipologia and permanenza/budget, if tipologia posizione
const saldo = await SaldoSolver(servizio);
if (!saldo.success) {
throw new Error(
`Saldo non trovato per il servizio: ${saldo.message}`,
);
}
if (!saldo.pack.testo_condizioni) {
throw new Error("Condizioni non trovate per saldo");
}
const preExistingSaldo = inactiveOrders.find(
(ordine) => ordine.type === OrderTypeEnum.Saldo,
);
if (preExistingSaldo) {
// saldo già creato, procedere con questo
return {
ordine_id: preExistingSaldo.ordine_id,
packid: preExistingSaldo.packid,
amount_cent: preExistingSaldo.amount_cent,
sconto: preExistingSaldo.sconto,
packName: saldo.pack.nome_it,
packDesc: saldo.pack.desc_it,
condizioni: saldo.pack.testo_condizioni,
};
}
// nessun saldo preesistente, crearne uno nuovo
const ordine = await createOrdine({
servizio_id: servizioId,
amount_cent: saldo.pack.prezzo_cent,
packid: saldo.pack.idprezziario,
type: OrderTypeEnum.Saldo,
userid: servizio.user_id,
});
return {
ordine_id: ordine.ordine_id,
packid: saldo.pack.idprezziario,
amount_cent: saldo.pack.prezzo_cent,
sconto: 0,
packName: saldo.pack.nome_it,
packDesc: saldo.pack.desc_it,
condizioni: saldo.pack.testo_condizioni,
};
}
throw new Error(
"Il servizio ha già un saldo attivo, non è possibile preparare un nuovo pagamento",
);
}
case OrderTypeEnum.Consulenza: {
if (!servizio.isOkAcconto || !servizio.isOkSaldo) {
throw new Error(
"Non è possibile procedere con il pagamento della consulenza prima di aver attivato acconto e saldo",
);
}
if (!servizio.isOkConsulenza) {
const existingConsulenza = inactiveOrders.find(
(ordine) => ordine.type === OrderTypeEnum.Consulenza,
);
if (existingConsulenza) {
// consulenza già creato, procedere con questo
const consulenzaPack = await getPrezziarioByIdHandler(
existingConsulenza.packid,
);
if (!consulenzaPack) {
throw new Error("Pack consulenza non trovato");
}
if (!consulenzaPack.testo_condizioni) {
throw new Error("Condizioni non trovate per consulenza");
}
return {
ordine_id: existingConsulenza.ordine_id,
packid: existingConsulenza.packid,
amount_cent: existingConsulenza.amount_cent,
sconto: existingConsulenza.sconto,
packName: consulenzaPack.nome_it,
packDesc: consulenzaPack.desc_it,
condizioni: consulenzaPack.testo_condizioni,
};
}
//todo vedere se oltre a emettere un errore si vuole anche passare un messaggio alla pagina di errore
throw new Error(
"Il servizio non ha un pacchetto di consulenza, è necessario inserirlo prima di procedere",
);
}
throw new Error(
"Il servizio ha già una consulenza attiva, non è possibile preparare un nuovo pagamento",
);
}
default:
throw new Error("Tipo di ordine non valido");
}
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore durante la preparazione del pagamento: ${(e as Error).message}`,
});
}
};
export const getPagamentoDataForCheckout = async (ordineId: OrdiniOrdineId) => {
try {
return await db
.selectFrom("ordini")
.select([
"ordini.ordine_id",
"ordini.packid",
"ordini.amount_cent",
"ordini.servizio_id",
"ordini.userid",
"ordini.sconto",
])
.innerJoin("users", "users.id", "ordini.userid")
.select(["users.username", "users.email"])
.innerJoin("prezziario", "prezziario.idprezziario", "ordini.packid")
.select(["prezziario.nome_it", "prezziario.desc_it"])
.where("ordini.ordine_id", "=", ordineId)
.executeTakeFirstOrThrow(
() => new Error(`Payment not found - ordineId: ${ordineId}`),
);
} catch (e) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Payment not found: ${(e as Error).message}`,
});
}
};
const SaldoTransitorioMapping: Record<number, PrezziarioIdprezziario> = {
0: "SALDOBD_1MESE" as PrezziarioIdprezziario,
1: "SALDOBD_3MESI" as PrezziarioIdprezziario,
2: "SALDOBD_6MESI" as PrezziarioIdprezziario,
3: "SALDOBD_9MESI" as PrezziarioIdprezziario,
4: "SALDOBD_12MESI" as PrezziarioIdprezziario,
};
// const ConsulenzaPriceMapping = {
// "4 + 4": "CONS_4+4" as PrezziarioIdprezziario,
// "30 Giorni": "CONS_30GG" as PrezziarioIdprezziario,
// Agevolato: "CONS_3+2" as PrezziarioIdprezziario,
// Commerciale: "CONS_COMM" as PrezziarioIdprezziario,
// Foresteria: "CONS_COMM" as PrezziarioIdprezziario,
// "Transi.Age": "CONS_3+2" as PrezziarioIdprezziario,
// Transitorio: "CONS_TRAN" as PrezziarioIdprezziario,
// };
type GetPackError = { success: false; message: string };
type GetPackResult = { success: true; pack: Prezziario };
type ServizioPacks = {
acconto: GetPackResult | GetPackError;
saldo: GetPackResult | GetPackError;
};
const AccontoSolver = async (
servizio: Servizio,
): Promise<GetPackResult | GetPackError> => {
switch (servizio.tipologia) {
case TipologiaPosizioneEnum.Transitorio: {
const pack = await getPrezziarioByIdHandler(ACCONTO_TRANSITORIO);
if (!pack) {
return { success: false, message: "Pack non trovato" };
}
return {
success: true,
pack,
};
}
case TipologiaPosizioneEnum.Stabile: {
const pack = await getPrezziarioByIdHandler(ACCONTO_STABILE);
if (!pack) {
return { success: false, message: "Pack non trovato" };
}
return {
success: true,
pack,
};
}
default:
return { success: false, message: "Seleziona tipologia" };
}
};
export const SaldoSolver = async (
servizio: Servizio,
): Promise<GetPackResult | GetPackError> => {
switch (servizio.tipologia) {
case TipologiaPosizioneEnum.Transitorio: {
const mpp =
servizio.permanenza && SaldoTransitorioMapping[servizio.permanenza];
if (!servizio.permanenza || !mpp) {
return {
success: false,
message: "Permanenza non selezionata",
};
}
const pack = await getPrezziarioByIdHandler(mpp);
if (!pack) {
return { success: false, message: "Pack non trovato" };
}
return { success: true, pack };
}
case TipologiaPosizioneEnum.Stabile: {
const pack = await getPrezziarioByIdHandler(
(servizio.budget > 700
? "SALDOCA700+"
: servizio.budget > 500
? "SALDOCA500+"
: "SALDOCA500") as PrezziarioIdprezziario,
);
if (!pack) {
return { success: false, message: "Pack non trovato" };
}
return {
success: true,
pack,
};
}
default:
return { success: false, message: "Seleziona tipologia" };
}
};
export const getPacksPerServizio = async (
servizioId: ServizioServizioId,
): Promise<ServizioPacks> => {
const servizio = await getServizioById(servizioId);
if (!servizio) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Servizio non trovato",
});
}
return {
acconto: await AccontoSolver(servizio),
saldo: await SaldoSolver(servizio),
};
};

View file

@ -2,8 +2,8 @@ import { TRPCError } from "@trpc/server";
import { env } from "~/env";
import stripe from "~/lib/stripe";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import type { Ordini, OrdiniOrdineId } from "~/schemas/public/Ordini";
import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum";
import type { Payments, PaymentsId } from "~/schemas/public/Payments";
import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import { db } from "~/server/db";
@ -31,71 +31,22 @@ export const stripeHealthCheck = async () => {
}
};
export const createIntentHandler = async ({
paymentId,
}: {
paymentId: PaymentsId;
}) => {
try {
const payment = await db
.selectFrom("payments")
.selectAll("payments")
.innerJoin("users", "users.id", "payments.userid")
.select("users.email")
.innerJoin("ordini", "ordini.ordine_id", "payments.ordine_id")
.innerJoin("prezziario", "prezziario.idprezziario", "ordini.packid")
.select(["prezziario.nome_it", "prezziario.desc_it"])
.where("payments.id", "=", paymentId)
.executeTakeFirstOrThrow(
() => new Error(`Payment not found - paymentId: ${paymentId}`),
);
const paymentIntent = await stripe.paymentIntents.create({
amount: payment.amount_cent,
//payment_method_configuration: "pmc_1P4NfjILe4KoQRqXYChFCTFj",
automatic_payment_methods: {
enabled: true,
},
currency: "eur",
description: payment.nome_it,
metadata: {
paymentId,
servizioId: payment.servizio_id,
userId: payment.userid,
},
receipt_email: payment.email,
});
return {
clientSecret: paymentIntent.client_secret,
descrizione: payment.desc_it,
prezzo: payment.amount_cent,
titolo: payment.nome_it,
};
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error creating payment intent: ${(error as Error).message}`,
});
}
};
export const whIntentCreatedHandler = async ({
pIntentId,
paymentId,
ordineId,
}: {
pIntentId: Payments["intent_id"];
paymentId: PaymentsId;
pIntentId: Ordini["intent_id"];
ordineId: OrdiniOrdineId;
}) => {
try {
return await db
.updateTable("payments")
.updateTable("ordini")
.set({
intent_id: pIntentId,
paymentstatus: PaymentStatusEnum.processing,
})
.where("id", "=", paymentId)
.returning("id")
.where("ordine_id", "=", ordineId)
.returning("ordine_id")
.execute();
} catch (error) {
throw new TRPCError({
@ -107,56 +58,41 @@ export const whIntentCreatedHandler = async ({
export const whIntentSucceededHandler = async ({
pIntentId,
paymentId,
ordineId,
pm_id,
}: {
pIntentId: Payments["intent_id"];
paymentId: PaymentsId;
pIntentId: Ordini["intent_id"];
ordineId: OrdiniOrdineId;
pm_id: string;
}) => {
try {
const paymentMethod = await stripe.paymentMethods.retrieve(pm_id);
await db.transaction().execute(async (trx) => {
const payment = await trx
.updateTable("payments")
const ordine = await trx
.updateTable("ordini")
.set({
paid_at: new Date(),
paymentmethod: paymentMethod.type,
paymentstatus: PaymentStatusEnum.success,
isActive: true,
})
.where("intent_id", "=", pIntentId)
.where("payments.id", "=", paymentId)
.returning(["servizio_id", "ordine_id"])
.where("ordine_id", "=", ordineId)
.returning(["servizio_id", "ordine_id", "userid", "type", "created_at"])
.executeTakeFirstOrThrow(() => {
throw new Error(
`Payment not found - intent_id: ${pIntentId}, paymentId: ${paymentId}`,
`Payment not found - intent_id: ${pIntentId}, ordineId: ${ordineId}`,
);
});
const ordine = await trx
.selectFrom("ordini")
.selectAll("ordini")
.where("ordine_id", "=", payment.ordine_id)
.executeTakeFirstOrThrow(() => {
throw new Error(`Ordine not found - ordine_id: ${payment.ordine_id}`);
});
await trx
.updateTable("ordini")
.where("ordine_id", "=", payment.ordine_id)
.set({
isActive: true,
})
.execute();
const servizio = await trx
.selectFrom("servizio")
.select("servizio.tipologia")
.where("servizio_id", "=", payment.servizio_id)
.select(["servizio.tipologia", "servizio.servizio_id"])
.where("servizio_id", "=", ordine.servizio_id)
.executeTakeFirstOrThrow(() => {
throw new Error(
`Servizio not found - servizio_id: ${payment.servizio_id}`,
`Servizio not found - servizio_id: ${ordine.servizio_id}`,
);
});
@ -170,7 +106,7 @@ export const whIntentSucceededHandler = async ({
case OrderTypeEnum.Acconto:
await trx
.updateTable("servizio")
.where("servizio_id", "=", payment.servizio_id)
.where("servizio_id", "=", servizio.servizio_id)
.set({
decorrenza: new Date(),
isOkAcconto: true,
@ -223,7 +159,7 @@ export const whIntentSucceededHandler = async ({
case OrderTypeEnum.Saldo:
await trx
.updateTable("servizio")
.where("servizio_id", "=", payment.servizio_id)
.where("servizio_id", "=", servizio.servizio_id)
.set({
isOkConsulenza:
servizio.tipologia === TipologiaPosizioneEnum.Transitorio,
@ -263,7 +199,7 @@ export const whIntentSucceededHandler = async ({
case OrderTypeEnum.Consulenza:
await trx
.updateTable("servizio")
.where("servizio_id", "=", payment.servizio_id)
.where("servizio_id", "=", servizio.servizio_id)
.set({
isOkConsulenza: true,
})
@ -336,7 +272,7 @@ export const whIntentSucceededHandler = async ({
// Enhanced error logging
console.error("whIntentSucceededHandler error:", {
pIntentId,
paymentId,
ordineId,
pm_id,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
@ -352,10 +288,10 @@ export const whIntentSucceededHandler = async ({
export const whIntentFailedHandler = async ({
pIntentId,
}: {
pIntentId: Payments["intent_id"];
pIntentId: Ordini["intent_id"];
}) => {
const userId = await db
.updateTable("payments")
.updateTable("ordini")
.set({
paymentstatus: PaymentStatusEnum.failed,
})

View file

@ -25,11 +25,7 @@ export const getPrezziario = async () => {
}
};
export const getPrezziarioByIdHandler = async ({
id,
}: {
id: PrezziarioIdprezziario;
}) => {
export const getPrezziarioByIdHandler = async (id: PrezziarioIdprezziario) => {
try {
return await db
.selectFrom("prezziario")

View file

@ -83,7 +83,7 @@ export const genPdfSchedaAnnuncioBase64 = async (annuncioId: AnnunciId) => {
export const genPdfSchedaAnnuncio = async (annuncioId: AnnunciId) => {
const keybd = getKeydbClient();
const url = `/area-riservata/admin/scheda-annuncio-stampa/${annuncioId}?raw=true`;
const url = `/area-riservata/scheda-annuncio-stampa/${annuncioId}?raw=true`;
const options: PDFOptions = { printBackground: true };
if (keybd) {
const cacheKey = `${SCHEDA_ANNUNCIO_CACHE_PREFIX}${annuncioId}`;
@ -141,11 +141,15 @@ const genPdfCondizioni = async (condizioniId: TestiEStringheStingaId) => {
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 url = `/servizio/ricevuta-cortesia/${ordineId}?raw=true`;
const pdf = await puppeteerGenPdf({ url });
const pdf = await genPdfRicevutaCortesia(ordineId);
const pdfBase64 = Buffer.from(pdf).toString("base64");
const base64Content = pdfBase64.includes("base64,")
? pdfBase64.split("base64,")[1]

View file

@ -7,7 +7,6 @@ import type { EtichetteIdEtichetta } from "~/schemas/public/Etichette";
import type { FlagsId } from "~/schemas/public/Flags";
import type { MessagesMessageid } from "~/schemas/public/Messages";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import type { PaymentsId } from "~/schemas/public/Payments";
import type { PrezziarioIdprezziario } from "~/schemas/public/Prezziario";
import type { ServizioServizioId } from "~/schemas/public/Servizio";
import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
@ -77,11 +76,6 @@ export const zServizioId = z.custom<ServizioServizioId>(
"falied to validate ServizioId",
);
export const zPagamentoId = z.custom<PaymentsId>(
(val) => typeof val === "string",
"falied to validate PagamentoId",
);
// export const zComuneId = z.custom<ComuniId>(
// (val) => typeof val === "number",
// "falied to validate ComuneId",