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": { "assist": {
"actions": { "actions": {
"source": { "source": {

View file

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

View file

@ -80,7 +80,6 @@
"react-phone-number-input": "^3.4.14", "react-phone-number-input": "^3.4.14",
"react-reverse-portal": "^2.3.0", "react-reverse-portal": "^2.3.0",
"react-select": "^5.10.2", "react-select": "^5.10.2",
"react-to-print": "^3.2.0",
"react-use": "^17.6.0", "react-use": "^17.6.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"stripe": "^20.4.0", "stripe": "^20.4.0",
@ -96,11 +95,9 @@
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.2", "@biomejs/biome": "^2.4.6",
"@hookform/devtools": "^4.4.0", "@hookform/devtools": "^4.4.0",
"@react-email/preview-server": "^5.2.8", "@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", "@total-typescript/ts-reset": "^0.6.1",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/node": "^24.2.0", "@types/node": "^24.2.0",
@ -542,9 +539,9 @@
} }
}, },
"node_modules/@biomejs/biome": { "node_modules/@biomejs/biome": {
"version": "2.4.2", "version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.2.tgz", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.6.tgz",
"integrity": "sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w==", "integrity": "sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ==",
"dev": true, "dev": true,
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"bin": { "bin": {
@ -558,20 +555,20 @@
"url": "https://opencollective.com/biome" "url": "https://opencollective.com/biome"
}, },
"optionalDependencies": { "optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.4.2", "@biomejs/cli-darwin-arm64": "2.4.6",
"@biomejs/cli-darwin-x64": "2.4.2", "@biomejs/cli-darwin-x64": "2.4.6",
"@biomejs/cli-linux-arm64": "2.4.2", "@biomejs/cli-linux-arm64": "2.4.6",
"@biomejs/cli-linux-arm64-musl": "2.4.2", "@biomejs/cli-linux-arm64-musl": "2.4.6",
"@biomejs/cli-linux-x64": "2.4.2", "@biomejs/cli-linux-x64": "2.4.6",
"@biomejs/cli-linux-x64-musl": "2.4.2", "@biomejs/cli-linux-x64-musl": "2.4.6",
"@biomejs/cli-win32-arm64": "2.4.2", "@biomejs/cli-win32-arm64": "2.4.6",
"@biomejs/cli-win32-x64": "2.4.2" "@biomejs/cli-win32-x64": "2.4.6"
} }
}, },
"node_modules/@biomejs/cli-darwin-arm64": { "node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.4.2", "version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.2.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.6.tgz",
"integrity": "sha512-3pEcKCP/1POKyaZZhXcxFl3+d9njmeAihZ17k8lL/1vk+6e0Cbf0yPzKItFiT+5Yh6TQA4uKvnlqe0oVZwRxCA==", "integrity": "sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -586,9 +583,9 @@
} }
}, },
"node_modules/@biomejs/cli-darwin-x64": { "node_modules/@biomejs/cli-darwin-x64": {
"version": "2.4.2", "version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.2.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.6.tgz",
"integrity": "sha512-P7hK1jLVny+0R9UwyGcECxO6sjETxfPyBm/1dmFjnDOHgdDPjPqozByunrwh4xPKld8sxOr5eAsSqal5uKgeBg==", "integrity": "sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -603,9 +600,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-arm64": { "node_modules/@biomejs/cli-linux-arm64": {
"version": "2.4.2", "version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.2.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.6.tgz",
"integrity": "sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==", "integrity": "sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -620,9 +617,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-arm64-musl": { "node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.4.2", "version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.2.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.6.tgz",
"integrity": "sha512-/x04YK9+7erw6tYEcJv9WXoBHcULI/wMOvNdAyE9S3JStZZ9yJyV67sWAI+90UHuDo/BDhq0d96LDqGlSVv7WA==", "integrity": "sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -637,9 +634,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-x64": { "node_modules/@biomejs/cli-linux-x64": {
"version": "2.4.2", "version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.2.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.6.tgz",
"integrity": "sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==", "integrity": "sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -654,9 +651,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-x64-musl": { "node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.4.2", "version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.2.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.6.tgz",
"integrity": "sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==", "integrity": "sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -671,9 +668,9 @@
} }
}, },
"node_modules/@biomejs/cli-win32-arm64": { "node_modules/@biomejs/cli-win32-arm64": {
"version": "2.4.2", "version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.2.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.6.tgz",
"integrity": "sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==", "integrity": "sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -688,9 +685,9 @@
} }
}, },
"node_modules/@biomejs/cli-win32-x64": { "node_modules/@biomejs/cli-win32-x64": {
"version": "2.4.2", "version": "2.4.6",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.2.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.6.tgz",
"integrity": "sha512-9ma7C4g8Sq3cBlRJD2yrsHXB1mnnEBdpy7PhvFrylQWQb4PoyCmPucdX7frvsSBQuFtIiKCrolPl/8tCZrKvgQ==", "integrity": "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -5879,55 +5876,6 @@
"url": "https://github.com/sponsors/tannerlinsley" "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": { "node_modules/@tiptap/core": {
"version": "3.20.0", "version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.0.tgz",
@ -6461,13 +6409,6 @@
"tslib": "^2.4.0" "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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -6969,16 +6910,6 @@
"node": ">=10" "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": { "node_modules/array-buffer-byte-length": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", "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", "license": "BSD-3-Clause",
"peer": true "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": { "node_modules/dom-helpers": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", "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" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "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" "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": { "node_modules/prism-react-renderer": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", "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": { "node_modules/react-transition-group": {
"version": "4.4.5", "version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "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-phone-number-input": "^3.4.14",
"react-reverse-portal": "^2.3.0", "react-reverse-portal": "^2.3.0",
"react-select": "^5.10.2", "react-select": "^5.10.2",
"react-to-print": "^3.2.0",
"react-use": "^17.6.0", "react-use": "^17.6.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"stripe": "^20.4.0", "stripe": "^20.4.0",
@ -109,11 +108,9 @@
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.2", "@biomejs/biome": "^2.4.6",
"@hookform/devtools": "^4.4.0", "@hookform/devtools": "^4.4.0",
"@react-email/preview-server": "^5.2.8", "@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", "@total-typescript/ts-reset": "^0.6.1",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/node": "^24.2.0", "@types/node": "^24.2.0",

View file

@ -3,12 +3,13 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { it } from "date-fns/locale"; import { it } from "date-fns/locale";
import Image from "next/image"; import Image from "next/image";
import { useRef } from "react"; import toast from "react-hot-toast";
import { useReactToPrint } from "react-to-print";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card"; import { Card } from "~/components/ui/card";
import { PaymentMethodToString, type PaymentType } from "~/i18n/stripe"; import { PaymentMethodToString, type PaymentType } from "~/i18n/stripe";
import { formatCurrency } from "~/lib/utils"; import { formatCurrency } from "~/lib/utils";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import { api } from "~/utils/api";
export function ReceiptGenerator({ export function ReceiptGenerator({
data, data,
@ -17,11 +18,36 @@ export function ReceiptGenerator({
data: PurchaseData; data: PurchaseData;
raw: boolean; raw: boolean;
}) { }) {
const contentRef = useRef<HTMLDivElement>(null); const { mutate: generatePdf } =
const reactToPrintFn = useReactToPrint({ api.pagamenti.getRicevutaCortesiaPdf.useMutation({
contentRef, onMutate: () => {
//print: async (target) => {console.log("Printing...", target.contentDocument);}, 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) { if (raw) {
return <Receipt data={data} />; return <Receipt data={data} />;
} }
@ -31,12 +57,12 @@ export function ReceiptGenerator({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="font-bold text-2xl">Ricevuta di cortesia</h1> <h1 className="font-bold text-2xl">Ricevuta di cortesia</h1>
<Button className="print:hidden" onClick={() => reactToPrintFn()}> <Button onClick={() => generatePdf({ ordineId: data.ordineId })}>
Stampa Stampa
</Button> </Button>
</div> </div>
<div ref={contentRef}> <div>
<Card className="p-4 print:border-none print:shadow-none"> <Card className="p-4">
<Receipt data={data} /> <Receipt data={data} />
</Card> </Card>
</div> </div>
@ -52,7 +78,7 @@ interface Item {
} }
export interface PurchaseData { export interface PurchaseData {
paymentId: string; ordineId: OrdiniOrdineId;
date: Date; date: Date;
clienteNome: string; clienteNome: string;
@ -102,8 +128,8 @@ function Receipt({ data }: ReceiptProps) {
</h2> </h2>
<div className="mt-2 flex justify-between"> <div className="mt-2 flex justify-between">
<div> <div>
<p className="text-muted-foreground text-sm">ID Pagamento</p> <p className="text-muted-foreground text-sm">ID Ordine</p>
<p className="text-sm">{data.paymentId}</p> <p className="text-sm">{data.ordineId}</p>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-muted-foreground text-sm">Data</p> <p className="text-muted-foreground text-sm">Data</p>

View file

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

View file

@ -492,7 +492,10 @@ export const CarouselAnnuncio = ({
media: "image", media: "image",
}, },
})} })}
key={`videoplayer-${idx}-${openModal}`} key={`videoplayer-${
// biome-ignore lint/suspicious/noArrayIndexKey: <ok>
idx
}-${openModal}`}
videoSrc={getStorageUrl({ videoSrc={getStorageUrl({
storageId: video.video, storageId: video.video,
params: { 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 Link from "next/link";
import toast from "react-hot-toast"; import type { Dispatch, SetStateAction } from "react";
import { Confirm } from "~/components/confirm";
import { import {
Credenza, Credenza,
CredenzaBody, CredenzaBody,
@ -12,87 +11,93 @@ import {
CredenzaHeader, CredenzaHeader,
CredenzaTitle, CredenzaTitle,
} from "~/components/custom_ui/credenza"; } from "~/components/custom_ui/credenza";
import { PaymentsTable } from "~/components/tables/payments-table";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import { FormEditOrder } from "~/forms/FormEditOrdine";
DropdownMenu, import { formatCurrency } from "~/lib/utils";
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { cn } from "~/lib/utils";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini"; import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum"; 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"; import { api } from "~/utils/api";
export const OrdiniModal = ({ export const AdminWrapperOrdiniModal = ({
isAdmin,
open,
setModalOpen,
ordineId, ordineId,
userId, setSelected,
}: { }: {
isAdmin: boolean; ordineId: OrdiniOrdineId | null;
open: boolean; setSelected: Dispatch<SetStateAction<OrdiniOrdineId | null>>;
setModalOpen: (open: boolean) => void;
ordineId: OrdiniOrdineId;
userId: UsersId | undefined;
}) => { }) => {
const utils = api.useUtils(); const { data: prezzi, isLoading } = api.prezziario.getPrezziario.useQuery();
const { mutate: remove } = api.servizio.removeOrder.useMutation({ if (isLoading || !ordineId || !prezzi) return null;
onError: (error) => { return (
toast.error(`Errore durante la rimozione ordine: ${error.message}`); <AdminOrdiniModal
}, ordineId={ordineId}
onSuccess: async () => { prezzi={prezzi}
toast.success("Ordine rimosso con successo!"); setSelected={setSelected}
await utils.servizio.getOrdini.invalidate({ />
userId, );
}); };
setModalOpen(false); const AdminOrdiniModal = ({
}, prezzi,
}); setSelected,
const { mutate: update } = api.servizio.updateOrder.useMutation({ ordineId,
onError: (error) => { }: {
toast.error(`Errore durante la modifica: ${error.message}`); prezzi: Prezziario[];
}, setSelected: Dispatch<SetStateAction<OrdiniOrdineId | null>>;
onSuccess: async (data) => { ordineId: OrdiniOrdineId;
if (data.type === OrderTypeEnum.Acconto && data.isActive) { }) => {
updateServizio({ const { data } = api.servizio.getOrdineById.useQuery({
data: { ordineId,
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({
ordineId,
});
},
}); });
const { mutate: updateServizio } = api.servizio.updateServizio.useMutation({ if (!data) return null;
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,
});
},
});
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({ const { data } = api.servizio.getOrdineById.useQuery({
ordineId, ordineId,
}); });
@ -101,9 +106,9 @@ export const OrdiniModal = ({
return ( return (
<Credenza <Credenza
onOpenChange={(b) => { 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"> <CredenzaContent className="max-h-[90vh] max-w-2xl sm:max-w-4xl">
<CredenzaHeader> <CredenzaHeader>
@ -115,12 +120,7 @@ export const OrdiniModal = ({
<CredenzaBody className="max-h-[80vh] space-y-2 overflow-auto pb-5"> <CredenzaBody className="max-h-[80vh] space-y-2 overflow-auto pb-5">
<p>Id Ordine: {data.ordine_id}</p> <p>Id Ordine: {data.ordine_id}</p>
<p> <p>
Data Ordine: Data Ordine: {new Date(data.created_at).toLocaleDateString("it-IT")}
{new Date(data.created_at).toLocaleDateString("it-IT", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
</p> </p>
<p>Tipologia: {data.type.toString()}</p> <p>Tipologia: {data.type.toString()}</p>
<p>Pack ID: {data.packid}</p> <p>Pack ID: {data.packid}</p>
@ -138,11 +138,12 @@ export const OrdiniModal = ({
</span> </span>
)} )}
</p> </p>
<p>
Importo: {formatCurrency(data.amount_cent / 100)}{" "}
{data.sconto > 0 && <span>({data.sconto}%)</span>}
</p>
{data.isActive && {data.isActive &&
data.pagamenti.find( data.paymentstatus === PaymentStatusEnum.success && (
(p) => p.paymentstatus === PaymentStatusEnum.success,
) && (
<div> <div>
<Link <Link
href={`/servizio/ricevuta-cortesia/${data.ordine_id}`} href={`/servizio/ricevuta-cortesia/${data.ordine_id}`}
@ -154,59 +155,8 @@ export const OrdiniModal = ({
</Link> </Link>
</div> </div>
)} )}
<PaymentsTable data={data.pagamenti} isAdmin={isAdmin} isUserView />
</CredenzaBody> </CredenzaBody>
<CredenzaFooter <CredenzaFooter className="flex w-full sm:justify-end">
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>
)}
<CredenzaClose asChild> <CredenzaClose asChild>
<Button variant="ghost">Chiudi</Button> <Button variant="ghost">Chiudi</Button>
</CredenzaClose> </CredenzaClose>

View file

@ -93,7 +93,10 @@ export const ChatAttachments = ({
return ( return (
<li <li
className="flex flex-row items-center justify-between border-b py-1" 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 <Link
aria-label="Visualizza Allegato" aria-label="Visualizza Allegato"

View file

@ -1,3 +1,4 @@
import { TriangleAlertIcon } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { import {
AlertDialog, AlertDialog,
@ -10,8 +11,6 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "~/components/ui/alert-dialog"; } from "~/components/ui/alert-dialog";
import { buttonVariants } from "~/components/ui/button";
import { cn } from "~/lib/utils";
export const Confirm = ({ export const Confirm = ({
children, children,
title, title,
@ -42,18 +41,20 @@ export const Confirm = ({
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger> <AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
)} )}
<AlertDialogContent> <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> <AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription> <AlertDialogDescription className="text-center">
{description}
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel onClick={() => onCancel?.()}> <AlertDialogCancel onClick={() => onCancel?.()}>
{cancelText || "Annulla"} {cancelText || "Annulla"}
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction onClick={() => onConfirm()} variant="destructive">
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={() => onConfirm()}
>
{confirmText || "Conferma"} {confirmText || "Conferma"}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -102,7 +102,7 @@ export const AnnuncioDisplay = ({
return ( return (
<div <div
className={cn( 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, className,
)} )}
> >

View file

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

View file

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

View file

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

View file

@ -1,12 +1,14 @@
import { add } from "date-fns"; import { add } from "date-fns";
import { import {
ArrowRight, ArrowRight,
BadgeCheck,
CalendarClock, CalendarClock,
CircleCheck, CircleCheck,
CircleCheckBig, CircleCheckBig,
CircleMinus, CircleMinus,
Clock, Clock,
ClockFading, ClockFading,
ExternalLink,
type LucideIcon, type LucideIcon,
PackageCheck, PackageCheck,
Trash2, Trash2,
@ -20,11 +22,23 @@ import {
ServizioProvider, ServizioProvider,
useServizio, useServizio,
} from "~/providers/ServizioProvider"; } from "~/providers/ServizioProvider";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import type { UsersId } from "~/schemas/public/Users"; import type { UsersId } from "~/schemas/public/Users";
import type { ServizioData } from "~/server/controllers/servizio.controller"; import type { ServizioData } from "~/server/controllers/servizio.controller";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { Confirm } from "../confirm"; import { Confirm } from "../confirm";
import { AlarmClockSVG } from "../svgs"; import { AlarmClockSVG } from "../svgs";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "../ui/alert-dialog";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { import {
Card, Card,
@ -36,11 +50,7 @@ import {
import { SaldoButton } from "./annuncio_actions"; import { SaldoButton } from "./annuncio_actions";
import { AnnuncioCard, AnnuncioDisplay } from "./annuncio_card"; import { AnnuncioCard, AnnuncioDisplay } from "./annuncio_card";
import { AnnunciCompatibili } from "./compatibili_dialog"; import { AnnunciCompatibili } from "./compatibili_dialog";
import { import { ServizioInfos } from "./servizio_actions";
AdminActions,
ServizioBasicActions,
ServizioPacksInfos,
} from "./servizio_actions";
export const ServizioList = ({ export const ServizioList = ({
servizi, servizi,
@ -189,18 +199,18 @@ const Content = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const dec_txt = servizio.decorrenza const dec_txt = servizio.decorrenza
? new Date(servizio.decorrenza).toLocaleDateString("it-IT") ? new Date(servizio.decorrenza).toLocaleDateString("it-IT")
: "N/A"; : "";
const end_txt = servizio.decorrenza const end_txt = servizio.decorrenza
? new Date(add(servizio.decorrenza, { days: 60 })).toLocaleDateString( ? new Date(add(servizio.decorrenza, { days: 60 })).toLocaleDateString(
"it-IT", "it-IT",
) )
: "N/A"; : "";
return ( return (
<div className="w-full space-y-4"> <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 flex-wrap items-start justify-between gap-4 sm:flex-row">
<div className="flex flex-col gap-1"> <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} {t.servizio.servizio_titolo} {servizio.tipologia}
</h3> </h3>
@ -217,51 +227,32 @@ const Content = () => {
<span>{t.servizio.durata_servizio}</span> <span>{t.servizio.durata_servizio}</span>
</div> </div>
<span> <span>
{dec_txt} - {end_txt} {servizio.decorrenza !== null
? `${dec_txt} - ${end_txt}`
: "In attesa di attivazione"}
</span> </span>
</div> </div>
</div> </div>
<div className="flex flex-col gap-4"> <ServizioInfos />
<AdminActions />
<ServizioBasicActions className="w-full sm:justify-end" />
</div>
</div> </div>
<ServizioPacksInfos />
<Main /> <Main />
</div> </div>
); );
}; };
const Main = () => { const Main = () => {
const { servizio, isAdmin, userId, servizioId } = useServizio(); const { servizio, isAdmin } = useServizio();
const { t } = useTranslation(); const { t } = useTranslation();
const utils = api.useUtils(); const utils = api.useUtils();
const { mutate: updateServizio } = api.servizio.updateServizio.useMutation({ const { mutate: attivazioneSenzaPagamento } =
onError: (error) => { api.servizio.attivazioneSenzaPagamento.useMutation({
toast.error(error.message); 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 () => { onSuccess: async () => {
await utils.servizio.getAllServizioAnnunci.invalidate({ userId }); toast.success("Servizio modificato con successo");
await utils.servizio.getServizioData.invalidate({ servizioId }); await utils.servizio.invalidate();
await utils.servizio.getCompatibileAnnunci.invalidate({
servizioId,
});
toast.success("Annuncio rimosso");
}, },
}); });
@ -273,93 +264,120 @@ const Main = () => {
</> </>
); );
} }
if (!servizio.onboardOk) {
if (!servizio.isOkAcconto || servizio.decorrenza === null) {
return ( 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"> <div className="flex w-full max-w-md flex-col items-center justify-center gap-4 text-center">
<AlarmClockSVG className="size-46" /> <AlarmClockSVG className="size-46" />
<h1 className="text-xl">{t.servizio.non_attivo}</h1> <h1 className="text-xl">{t.servizio.non_attivo}</h1>
<Link <Link
aria-label="Attiva Servizio" aria-label="Attiva Servizio"
className="w-full" 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"> <Button className="w-full text-lg" variant="success">
<span>{t.servizio.attiva_cta}</span> <span>{t.servizio.attiva_cta}</span>
<ArrowRight /> <ArrowRight />
</Button> </Button>
</Link> </Link>
</div>
{isAdmin && ( <AnnunciAnteprima />
<Button </div>
);
}
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"
>
<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" className="w-full"
onClick={() => href={`/servizio/pagamento/${servizio.servizio_id}?type=${OrderTypeEnum.Acconto}`}
updateServizio({
data: {
decorrenza: new Date(),
isOkAcconto: true,
},
servizioId: servizio.servizio_id,
})
}
variant="success"
> >
Attiva Manualmente (Azione Admin) <Button className="w-full text-lg" variant="info">
</Button> <span>Procedi al pagamento</span>
<ArrowRight />
</Button>
</Link>
)} )}
</div> </div>
{servizio.annunci.length > 0 && ( <AnnunciAnteprima />
<div className="w-full space-y-2">
<p>
{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) => (
<div className="group relative" key={data.id}>
<AnnuncioDisplay
className="bg-[#e6e9ec]/50 dark:bg-card dark:outline dark:outline-foreground/10"
data={{
...data,
modificato_il: data.modificato_il
? new Date(data.modificato_il)
: null,
media_updated_at: data.media_updated_at
? new Date(data.media_updated_at)
: null,
}}
/>
{isAdmin && (
<Confirm
description="Sei sicuro di voler rimuovere questo annuncio?"
onConfirm={() => {
removeAnnuncioServizio({
annuncioId: data.annunci_id,
servizioId: servizio.servizio_id,
});
}}
title="Rimuovi Annuncio"
>
<Button
aria-label="Rimuovi"
className="absolute top-0 right-0 hidden group-hover:flex"
size="sm"
variant="destructive"
>
<Trash2 />
</Button>
</Confirm>
)}
</div>
))}
{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>
)}
</div> </div>
); );
} }
@ -390,7 +408,6 @@ const Main = () => {
data={data} data={data}
key={data.id} key={data.id}
servizioId={servizio.servizio_id} servizioId={servizio.servizio_id}
userId={userId}
> >
<AnnuncioCard className="max-w-full" /> <AnnuncioCard className="max-w-full" />
</ServizioAnnuncioProvider> </ServizioAnnuncioProvider>
@ -434,26 +451,98 @@ const Main = () => {
))} ))}
</div> </div>
)} )}
<div className="grid grid-flow-row grid-cols-1 gap-4 sm:grid-cols-2"> <div className="@container grid grid-flow-row @sm:grid-cols-2 grid-cols-1 gap-4">
{servizio.annunci.map((data) => { {servizio.annunci.map((data) => {
return ( return (
<ServizioAnnuncioProvider <ServizioAnnuncioProvider
data={data} data={data}
key={data.id} key={data.id}
servizioId={servizio.servizio_id} servizioId={servizio.servizio_id}
userId={userId}
> >
<AnnuncioCard /> <AnnuncioCard />
</ServizioAnnuncioProvider> </ServizioAnnuncioProvider>
); );
})} })}
{servizio.annunci.length % 2 === 1 && ( {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 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>
<div className="flex items-center justify-center"> <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 /> <AnnunciCompatibili />
</div> </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}
</p>
<div className="grid gap-4 sm:grid-cols-2">
{servizio.annunci.map((data) => (
<div className="group relative" key={data.id}>
<AnnuncioDisplay
className="bg-[#e6e9ec]/50 dark:bg-card dark:outline dark:outline-foreground/10"
data={{
...data,
modificato_il: data.modificato_il
? new Date(data.modificato_il)
: null,
media_updated_at: data.media_updated_at
? new Date(data.media_updated_at)
: null,
}}
/>
{isAdmin && (
<Confirm
description="Sei sicuro di voler rimuovere questo annuncio?"
onConfirm={() => {
removeAnnuncioServizio({
annuncioId: data.annunci_id,
servizioId: servizio.servizio_id,
});
}}
title="Rimuovi Annuncio"
>
<Button
aria-label="Rimuovi"
className="absolute top-0 right-0 hidden group-hover:flex"
size="sm"
variant="destructive"
>
<Trash2 />
</Button>
</Confirm>
)}
</div>
))}
{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>
);
}
return null;
};

View file

@ -6,12 +6,14 @@ import {
Cog, Cog,
Euro, Euro,
ExternalLink, ExternalLink,
Logs,
Plus, Plus,
Search, Search,
SlidersHorizontal, SlidersHorizontal,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { import {
@ -55,6 +57,7 @@ import {
type FormValues as EditFormValues, type FormValues as EditFormValues,
FormEditServizio, FormEditServizio,
} from "~/forms/FormEditServizioAdmin"; } from "~/forms/FormEditServizioAdmin";
import { FormNewOrder } from "~/forms/FormNewOrdine";
import { FormNewServizio, type FormValues } from "~/forms/FormNewServizio"; import { FormNewServizio, type FormValues } from "~/forms/FormNewServizio";
import { cn, formatCurrency } from "~/lib/utils"; import { cn, formatCurrency } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider"; import { useTranslation } from "~/providers/I18nProvider";
@ -65,27 +68,76 @@ import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import type { UsersId } from "~/schemas/public/Users"; import type { UsersId } from "~/schemas/public/Users";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { Confirm } from "../confirm"; 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(); const { servizio, isAdmin, userId } = useServizio();
if (!isAdmin) return null; if (!isAdmin) return null;
return ( return (
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<AddAnnuncio servizioId={servizio.servizio_id} userId={userId} /> <AddAnnuncio servizioId={servizio.servizio_id} userId={userId} />
<EditServizioAdmin /> <EditServizioAdmin />
<Link
href={`/area-riservata/admin/user-view/ordini/${userId}?servizioId=${servizio.servizio_id}`}
>
<Button variant="warning">
<Search />
<span>Ordini</span>
</Button>
</Link>
</div> </div>
); );
}; };
export const ServizioBasicActions = ({ className }: { className?: string }) => { const UserActions = ({ className }: { className?: string }) => {
const { servizio, isAdmin } = useServizio(); const { servizio, isAdmin } = useServizio();
const canUserEdit = servizio.isOkAcconto && servizio.decorrenza !== null; const canUserEdit = servizio.isOkAcconto && servizio.decorrenza !== null;
@ -106,6 +158,229 @@ export const ServizioBasicActions = ({ className }: { className?: string }) => {
return null; 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 = () => { export const ServizioPacksInfos = () => {
const { servizio } = useServizio(); const { servizio } = useServizio();
const { t } = useTranslation(); const { t } = useTranslation();
@ -203,7 +478,6 @@ export const ServizioPacksInfos = () => {
const AddAnnuncio = ({ const AddAnnuncio = ({
servizioId, servizioId,
userId,
}: { }: {
servizioId: ServizioServizioId; servizioId: ServizioServizioId;
userId: UsersId; userId: UsersId;
@ -226,15 +500,7 @@ const AddAnnuncio = ({
toast.error("Errore durante l'aggiunta dell'annuncio"); toast.error("Errore durante l'aggiunta dell'annuncio");
}, },
onSuccess: async () => { onSuccess: async () => {
await utils.servizio.getAllServizioAnnunci.invalidate({ userId }); await utils.servizio.invalidate();
await utils.servizio.getServizioData.invalidate({ servizioId });
await utils.servizio.getAnnunciAvailableToAdd.invalidate({
servizioId,
});
await utils.servizio.getCompatibileAnnunci.invalidate({
servizioId,
});
await utils.servizio.getUserServizi.invalidate({ userId });
toast.success("Annuncio aggiunto"); toast.success("Annuncio aggiunto");
setOpenModal(false); setOpenModal(false);
setSelectedAnnunci([]); setSelectedAnnunci([]);
@ -319,7 +585,7 @@ const AddAnnuncio = ({
}; };
const InterruzioneServizio = () => { const InterruzioneServizio = () => {
const { servizio, userId, isAdmin } = useServizio(); const { servizio, isAdmin } = useServizio();
const { t } = useTranslation(); const { t } = useTranslation();
const beforeInterruzioneLimit = const beforeInterruzioneLimit =
servizio.decorrenza != null && servizio.decorrenza != null &&
@ -335,10 +601,7 @@ const InterruzioneServizio = () => {
toast.error("Errore durante la richiesta di interruzione"); toast.error("Errore durante la richiesta di interruzione");
}, },
onSuccess: async () => { onSuccess: async () => {
await utils.servizio.getAllServizioAnnunci.invalidate({ userId }); await utils.servizio.invalidate();
await utils.servizio.getServizioData.invalidate({
servizioId: servizio.servizio_id,
});
toast.success("Interruzione richiesta"); toast.success("Interruzione richiesta");
}, },
}); });
@ -381,7 +644,7 @@ const InterruzioneServizio = () => {
}; };
const EditParametri = () => { const EditParametri = () => {
const { servizioId, userId, isAdmin, servizio } = useServizio(); const { servizioId, isAdmin, servizio } = useServizio();
const { t } = useTranslation(); const { t } = useTranslation();
const [openEditParametri, setOpenEditParametri] = useState(false); const [openEditParametri, setOpenEditParametri] = useState(false);
@ -393,13 +656,8 @@ const EditParametri = () => {
}, },
onSuccess: async () => { onSuccess: async () => {
toast.success("Servizio modificato con successo"); toast.success("Servizio modificato con successo");
await utils.servizio.getAllServizioAnnunci.invalidate({ userId }); await utils.servizio.invalidate();
await utils.servizio.getServizioData.invalidate({ servizioId });
await utils.servizio.getCompatibileAnnunci.invalidate({
servizioId,
});
await utils.servizio.getPacksSolution.invalidate({ servizioId });
setOpenEditParametri(false); setOpenEditParametri(false);
}, },
}); });
@ -452,22 +710,15 @@ const EditParametri = () => {
const EditServizioAdmin = () => { const EditServizioAdmin = () => {
const { servizioId, userId, servizio } = useServizio(); const { servizioId, userId, servizio } = useServizio();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const utils = api.useUtils(); const utils = api.useUtils();
const router = useRouter();
const { mutate: update } = api.servizio.updateServizio.useMutation({ const { mutate: update } = api.servizio.updateServizio.useMutation({
onError: (error) => { onError: (error) => {
toast.error(error.message); toast.error(error.message);
}, },
onSuccess: async () => { onSuccess: async () => {
toast.success("Servizio modificato con successo"); toast.success("Servizio modificato con successo");
await utils.servizio.getAllServizioAnnunci.invalidate({ userId }); await utils.servizio.invalidate();
await utils.servizio.getServizioData.invalidate({ servizioId });
await utils.servizio.getCompatibileAnnunci.invalidate({
servizioId,
});
await utils.servizio.getPacksSolution.invalidate({ servizioId });
setOpen(false); setOpen(false);
}, },
}); });
@ -475,10 +726,14 @@ const EditServizioAdmin = () => {
onError: (error) => { onError: (error) => {
toast.error(error.message); toast.error(error.message);
}, },
onMutate: async () => {
await router.push(`/area-riservata/admin/user-view/ricerca/${userId}`);
},
onSuccess: async () => { onSuccess: async () => {
toast.success("Servizio rimosso con successo"); 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 router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
return ( 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"> <div className="mx-auto max-w-lg space-y-3 text-center">
<ShieldExclamationIcon className="mx-auto size-20 text-red-600" /> <ShieldExclamationIcon className="mx-auto size-20 text-red-600" />
<h3 className="font-semibold text-4xl text-accent-foreground sm:text-5xl"> <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 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 Link from "next/link";
import { useState } from "react"; 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 { DataTable } from "~/components/custom_ui/data-table";
import { DataTableColumnHeader } from "~/components/custom_ui/dataTable-header"; import { DataTableColumnHeader } from "~/components/custom_ui/dataTable-header";
import type { import type {
@ -10,28 +13,47 @@ import type {
SearchFiltro, SearchFiltro,
} from "~/components/custom_ui/dataTable-toolbar"; } from "~/components/custom_ui/dataTable-toolbar";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { formatCurrency } from "~/lib/utils";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum"; import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini"; import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum"; import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum";
import type { UsersId } from "~/schemas/public/Users"; 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 = { type PaymentsTableProps = {
data: OrdiniWPagamenti[]; data: Ordini_w_Utente[];
isAdmin: boolean; isAdmin: boolean;
userId?: UsersId; 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) => { const tabledata = data.map((row) => {
return { return {
actions: "Azioni", actions: "Azioni",
created_at: row.created_at, created_at: row.created_at,
id: row.ordine_id, id: row.ordine_id,
isActive: row.isActive, status: row.isActive,
isPaid: row.pagamenti.some( payment_status: row.paymentstatus,
(p) => p.paymentstatus === PaymentStatusEnum.success, importo: row.amount_cent,
),
packid: row.packid, packid: row.packid,
tipo: row.type, tipo: row.type,
userId: row.userid, userId: row.userid,
@ -40,16 +62,19 @@ export const OrdiniTable = ({ data, isAdmin, userId }: PaymentsTableProps) => {
}); });
const columns_titles = { const columns_titles = {
id: "ID Ordine",
actions: "Azioni", actions: "Azioni",
created_at: "Creato il", created_at: "Creato il",
isPaid: "Pagato", importo: "Importo",
status: "Status",
payment_status: "Pagamento",
packid: "ID Pacchetto", packid: "ID Pacchetto",
tipo: "Tipo", tipo: "Tipo",
username: "Utente", username: "Utente",
}; };
const isPaidOptions = [ const statusOptions = [
{ label: "Pagato", value: "true" }, { label: "Attivo", value: "true" },
{ label: "Non pagato", value: "false" }, { label: "Non attivo", value: "false" },
]; ];
const type_options = [ const type_options = [
@ -59,7 +84,6 @@ export const OrdiniTable = ({ data, isAdmin, userId }: PaymentsTableProps) => {
{ label: "Altro", value: OrderTypeEnum.Altro }, { label: "Altro", value: OrderTypeEnum.Altro },
]; ];
const [modalOpen, setModalOpen] = useState(false);
const [selectedOrdine, setSelectedOrdine] = useState<OrdiniOrdineId | null>( const [selectedOrdine, setSelectedOrdine] = useState<OrdiniOrdineId | null>(
null, null,
); );
@ -67,6 +91,13 @@ export const OrdiniTable = ({ data, isAdmin, userId }: PaymentsTableProps) => {
type Column = (typeof tabledata)[number]; type Column = (typeof tabledata)[number];
const columns: ColumnDef<Column>[] = [ const columns: ColumnDef<Column>[] = [
{
accessorKey: "id",
cell: () => null,
enableHiding: false,
header: () => null,
},
{ {
accessorKey: "username", accessorKey: "username",
cell: ({ row }) => { cell: ({ row }) => {
@ -125,30 +156,35 @@ export const OrdiniTable = ({ data, isAdmin, userId }: PaymentsTableProps) => {
), ),
}, },
{ {
accessorKey: "isPaid", accessorKey: "importo",
cell: ({ row }) => { cell: ({ row }) => {
const isPaid = row.original.isPaid; return <span>{formatCurrency(row.original.importo / 100)}</span>;
const isActive = row.original.isActive; },
if (isActive) { 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 ( return (
<span className="inline-flex items-center gap-1 text-green-500"> <span className="inline-flex items-center gap-1">
<CircleCheck className="size-5" /> <Circle className="size-5" />
{isPaid ? "Pagato" : "Attivato da Admin"} Non attivo
</span>
);
}
if (isPaid) {
return (
<span className="inline-flex items-center gap-1 text-green-500">
<CircleCheck className="size-5" />
Pagato
</span> </span>
); );
} }
return ( 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" /> <CircleCheck className="size-5" />
Non pagato Attivo
</span> </span>
); );
}, },
@ -157,10 +193,25 @@ export const OrdiniTable = ({ data, isAdmin, userId }: PaymentsTableProps) => {
return value.includes(row.getValue(id)); return value.includes(row.getValue(id));
}, },
header: ({ column }) => ( 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 }) => { cell: ({ row }) => {
return ( return (
@ -168,13 +219,11 @@ export const OrdiniTable = ({ data, isAdmin, userId }: PaymentsTableProps) => {
aria-label="Apri Dettagli Ordine" aria-label="Apri Dettagli Ordine"
onClick={() => { onClick={() => {
setSelectedOrdine(row.original.id); setSelectedOrdine(row.original.id);
setModalOpen(true);
}} }}
size="sm" size="sm"
variant="outline" variant="outline"
> >
<ExternalLink className="size-6" /> <ExternalLink className="size-5" />
Apri Dettagli
</Button> </Button>
); );
}, },
@ -183,23 +232,17 @@ export const OrdiniTable = ({ data, isAdmin, userId }: PaymentsTableProps) => {
}, },
]; ];
const searchFiltro: SearchFiltro = const searchFiltro: SearchFiltro = {
isAdmin && !userId columnName: "id",
? { placeholder: "Cerca ordine...",
columnName: "username", };
placeholder: "Cerca utente...",
}
: {
columnName: "packid",
placeholder: "Cerca pacchetto...",
};
const pinnedFiltri: PinnedFiltro[] = [ const pinnedFiltri: PinnedFiltro[] = [
{ {
columnName: "isPaid", columnName: "status",
options: isPaidOptions, options: statusOptions,
title: "Stato Pagamento", title: "Stato Ordine",
}, },
{ {
columnName: "tipo", columnName: "tipo",
@ -223,18 +266,25 @@ export const OrdiniTable = ({ data, isAdmin, userId }: PaymentsTableProps) => {
tipo: true, tipo: true,
username: isAdmin && !userId, username: isAdmin && !userId,
}} }}
defaultFilterState={
filter ? [{ id: "id", value: filter.toString() }] : []
}
defaultSort={[{ desc: true, id: "created_at" }]} defaultSort={[{ desc: true, id: "created_at" }]}
pinnedFiltri={pinnedFiltri} pinnedFiltri={pinnedFiltri}
searchColumn={searchFiltro} searchColumn={searchFiltro}
/> />
{selectedOrdine && ( {isAdmin ? (
<OrdiniModal <AdminWrapperOrdiniModal
isAdmin={isAdmin}
open={modalOpen}
ordineId={selectedOrdine} ordineId={selectedOrdine}
setModalOpen={setModalOpen} setSelected={setSelectedOrdine}
userId={userId}
/> />
) : (
selectedOrdine && (
<UserOrdiniModal
ordineId={selectedOrdine}
setSelected={setSelectedOrdine}
/>
)
)} )}
</div> </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 { AlertDialog as AlertDialogPrimitive } from "radix-ui";
import type * as React from "react"; import type * as React from "react";
import { buttonVariants } from "~/components/ui/button";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Button } from "./button";
function AlertDialog({ function AlertDialog({
...props ...props
@ -32,7 +34,7 @@ function AlertDialogOverlay({
return ( return (
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
className={cn( 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, className,
)} )}
data-slot="alert-dialog-overlay" data-slot="alert-dialog-overlay"
@ -43,16 +45,20 @@ function AlertDialogOverlay({
function AlertDialogContent({ function AlertDialogContent({
className, className,
size = "default",
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm";
}) {
return ( return (
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay /> <AlertDialogOverlay />
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
className={cn( 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, className,
)} )}
data-size={size}
data-slot="alert-dialog-content" data-slot="alert-dialog-content"
{...props} {...props}
/> />
@ -66,7 +72,10 @@ function AlertDialogHeader({
}: React.ComponentProps<"div">) { }: React.ComponentProps<"div">) {
return ( return (
<div <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" data-slot="alert-dialog-header"
{...props} {...props}
/> />
@ -80,7 +89,7 @@ function AlertDialogFooter({
return ( return (
<div <div
className={cn( 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, className,
)} )}
data-slot="alert-dialog-footer" 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({ function AlertDialogTitle({
className, className,
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return ( return (
<AlertDialogPrimitive.Title <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" data-slot="alert-dialog-title"
{...props} {...props}
/> />
@ -108,7 +136,10 @@ function AlertDialogDescription({
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return ( return (
<AlertDialogPrimitive.Description <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" data-slot="alert-dialog-description"
{...props} {...props}
/> />
@ -117,38 +148,51 @@ function AlertDialogDescription({
function AlertDialogAction({ function AlertDialogAction({
className, className,
variant = "default",
size = "default",
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return ( return (
<AlertDialogPrimitive.Action <Button asChild size={size} variant={variant}>
className={cn(buttonVariants(), className)} <AlertDialogPrimitive.Action
{...props} className={cn(className)}
/> data-slot="alert-dialog-action"
{...props}
/>
</Button>
); );
} }
function AlertDialogCancel({ function AlertDialogCancel({
className, className,
variant = "outline",
size = "default",
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return ( return (
<AlertDialogPrimitive.Cancel <Button asChild size={size} variant={variant}>
className={cn(buttonVariants({ variant: "outline" }), className)} <AlertDialogPrimitive.Cancel
{...props} className={cn(className)}
/> data-slot="alert-dialog-cancel"
{...props}
/>
</Button>
); );
} }
export { export {
AlertDialog, AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}; };

View file

@ -267,7 +267,7 @@ export const AnnuncioEditForm = ({ data }: { data: AnnunciWithMedia }) => {
</Button> </Button>
</Link> </Link>
<Link <Link
href={`/area-riservata/admin/scheda-annuncio-stampa/${data.id}`} href={`/area-riservata/scheda-annuncio-stampa/${data.id}`}
target="_blank" target="_blank"
> >
<Button size="sm" type="button" variant="outline"> <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(), skipPayment: z.boolean(),
skipControlloDoc: z.boolean(), skipControlloDoc: z.boolean(),
skipDocMotivazione: z.boolean(), skipDocMotivazione: z.boolean(),
onboardOk: z.boolean(),
}); });
export type FormValues = z.infer<typeof Schema>; export type FormValues = z.infer<typeof Schema>;
@ -64,6 +65,7 @@ export const FormEditServizio = ({
skipPayment: false, skipPayment: false,
skipControlloDoc: false, skipControlloDoc: false,
skipDocMotivazione: false, skipDocMotivazione: false,
onboardOk: false,
}; };
} }
const { locale, t } = useTranslation(); const { locale, t } = useTranslation();
@ -193,6 +195,28 @@ export const FormEditServizio = ({
</div> </div>
</div> </div>
<div className="flex w-full flex-col items-start justify-between gap-4 sm:flex-row"> <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 <FormField
control={form.control} control={form.control}
name="isOkAcconto" name="isOkAcconto"

View file

@ -58,15 +58,21 @@ export const FormNewOrder = ({
const schema = z.object({ const schema = z.object({
packid: zPrezziarioId, packid: zPrezziarioId,
isActive: z.boolean(), isActive: z.boolean(),
type: z.custom<OrderTypeEnum>(), type: z.enum(OrderTypeEnum),
created_at: z.date(), created_at: z.date(),
amount_cent: z.number(),
sconto: z.number().min(0).max(100),
}); });
type FormValues = z.infer<typeof schema>; type FormValues = z.infer<typeof schema>;
z.config(z.locales.it()); z.config(z.locales.it());
const form = useZodForm(schema, { const form = useZodForm(schema, {
defaultValues: {}, defaultValues: {
sconto: 0,
amount_cent: 0,
isActive: false,
},
}); });
const utils = api.useUtils(); const utils = api.useUtils();
const { mutate: createOrder } = api.servizio.createOrdine.useMutation({ const { mutate: createOrder } = api.servizio.createOrdine.useMutation({
@ -75,20 +81,17 @@ export const FormNewOrder = ({
}, },
onSuccess: async () => { onSuccess: async () => {
toast.success("Ordine creato con successo"); toast.success("Ordine creato con successo");
await utils.servizio.getOrdini.invalidate({ await utils.servizio.invalidate();
userId,
});
setOpen(false); setOpen(false);
form.reset(); form.reset();
}, },
}); });
const { watch } = form;
const scontoWch = watch(["sconto", "amount_cent"]);
function onSubmit(fields: FormValues) { function onSubmit(fields: FormValues) {
createOrder({ createOrder({
data: { data: { ...fields, servizio_id: servizioId, userid: userId },
...fields,
servizio_id: servizioId,
userid: userId,
},
}); });
} }
@ -117,6 +120,15 @@ export const FormNewOrder = ({
const selected = zPrezziarioId.safeParse(value.value); const selected = zPrezziarioId.safeParse(value.value);
if (selected.success) { if (selected.success) {
field.onChange(selected.data); field.onChange(selected.data);
const selectedPrezzo = prezziario.find(
(p) => p.idprezziario === selected.data,
);
if (selectedPrezzo) {
form.setValue(
"amount_cent",
selectedPrezzo.prezzo_cent,
);
}
} else { } else {
console.error( console.error(
"Invalid packid selected", "Invalid packid selected",
@ -220,7 +232,7 @@ export const FormNewOrder = ({
render={({ field }) => ( render={({ field }) => (
<FormItem className="w-full"> <FormItem className="w-full">
<div className="flex flex-wrap items-center gap-x-2"> <div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="isActive">Attivo</FormLabel> <FormLabel htmlFor="isActive">Pagato</FormLabel>
<FormControl> <FormControl>
<Switch <Switch
className="data-[state=checked]:bg-neutral-700" className="data-[state=checked]:bg-neutral-700"
@ -233,11 +245,59 @@ export const FormNewOrder = ({
</div> </div>
<FormDescription> <FormDescription>
Imposta se il servizio è attivo Imposta se il servizio è pagato
</FormDescription> </FormDescription>
</FormItem> </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> </div>
</CredenzaBody> </CredenzaBody>

View file

@ -114,14 +114,15 @@ type FormNewServizioValues = z.infer<typeof FormNewServizioSchema>;
export const FormNewServizioAcquisto = ({ export const FormNewServizioAcquisto = ({
initialData, initialData,
userId, userId,
isAdmin = false, isAdmin,
}: { }: {
initialData: InitialData; initialData: InitialData;
userId: UsersId; userId: UsersId;
isAdmin?: boolean; isAdmin: boolean;
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { comuni, nazioni } = useCatasto(); const { comuni, nazioni } = useCatasto();
console.log(isAdmin);
const schema = FormNewServizioSchema.superRefine( const schema = FormNewServizioSchema.superRefine(
( (
{ {
@ -226,14 +227,7 @@ export const FormNewServizioAcquisto = ({
path: ["n_adulti"], path: ["n_adulti"],
}); });
} }
if (!permanenza && tipologia === TipologiaPosizioneEnum.Transitorio) {
refinementContext.issues.push({
code: "custom",
input: "",
message: "Inserisci permanenza",
path: ["permanenza"],
});
}
if (tipologia === TipologiaPosizioneEnum.Transitorio) { if (tipologia === TipologiaPosizioneEnum.Transitorio) {
if ( if (
motivazione_transitorio === "" || motivazione_transitorio === "" ||
@ -254,7 +248,7 @@ export const FormNewServizioAcquisto = ({
path: ["scadenza_motivazione_transitoria"], path: ["scadenza_motivazione_transitoria"],
}); });
} }
if (!permanenza) { if (permanenza == null) {
refinementContext.issues.push({ refinementContext.issues.push({
code: "custom", code: "custom",
input: "", input: "",
@ -301,7 +295,7 @@ export const FormNewServizioAcquisto = ({
defaultValues: defaultValues, defaultValues: defaultValues,
}); });
const DatePickerLocale = locale === "it" ? it : enUS; const DatePickerLocale = locale === "it" ? it : enUS;
const { mutate } = api.servizio.submitOnboardForPurchase.useMutation({ const { mutate } = api.servizio.processServizioOnboard.useMutation({
onError: (error) => { onError: (error) => {
toast.error(`Errore invio dati: ${error.message}`, { toast.error(`Errore invio dati: ${error.message}`, {
id: "salvataggio", id: "salvataggio",
@ -312,25 +306,19 @@ export const FormNewServizioAcquisto = ({
id: "salvataggio", id: "salvataggio",
}); });
}, },
onSuccess: async (data) => { onSuccess: async () => {
toast.success("Servizio impostato con successo", { toast.success("Servizio impostato con successo", {
id: "salvataggio", id: "salvataggio",
}); });
if (isAdmin) { if (isAdmin) {
if (data.ordineIdForPayment) { await router.push(
await router.push(`/area-riservata/admin/user-view/ordini/${userId}`); `/area-riservata/admin/user-view/servizio/${userId}/${initialData.servizio.servizio_id}`,
} else { );
await router.push(
`/area-riservata/admin/user-view/servizio/${userId}/${initialData.servizio.servizio_id}`,
);
}
} else { } else {
if (data.ordineIdForPayment) { await router.push(
await router.push(`/servizio/pagamento/${data.ordineIdForPayment}`); `/area-riservata/servizio/${initialData.servizio.servizio_id}`,
} else { );
await router.push("/area-riservata/dashboard");
}
} }
}, },
}); });
@ -1245,33 +1233,6 @@ export const FormNewServizioAcquisto = ({
</CardContent> </CardContent>
</Card> </Card>
<div className="flex w-full flex-wrap items-center justify-center gap-2 pb-8 sm:justify-end"> <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"> <Button className="w-full sm:w-auto" type="submit">
{texts.salva} {texts.salva}
</Button> </Button>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -89,7 +89,10 @@ const FAQSection = () => {
{item.qas.map((f, i) => ( {item.qas.map((f, i) => (
<AccordionItem <AccordionItem
className="border-b dark:border-muted-foreground/50" 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()} value={i.toString()}
> >
<AccordionTrigger className="cursor-pointer text-left"> <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) => { onSuccess: async (data, _variables, context) => {
await utils.storage.retrieveUserFileData.invalidate(); await utils.storage.retrieveUserFileData.invalidate();
await utils.servizio.invalidate();
toast.success("Confermato", { toast.success("Confermato", {
icon: "👍", icon: "👍",
@ -105,7 +106,7 @@ const ConfirmSection = ({
`/area-riservata/admin/user-view/ricerca/${data.userId}`, `/area-riservata/admin/user-view/ricerca/${data.userId}`,
); );
} else { } 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 = { type OnboardServizioProps = {
servizioId: ServizioServizioId; 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> = ({ const OnboardServizio: NextPageWithLayout<OnboardServizioProps> = ({
servizioId, servizioId,
}: OnboardServizioProps) => { }: OnboardServizioProps) => {
@ -38,7 +44,11 @@ const OnboardServizio: NextPageWithLayout<OnboardServizioProps> = ({
</div> </div>
<OnboardTutorial /> <OnboardTutorial />
<CatastoProvider> <CatastoProvider>
<FormNewServizioAcquisto initialData={data} userId={data.userData.id} /> <FormNewServizioAcquisto
initialData={data}
isAdmin={false}
userId={data.userData.id}
/>
</CatastoProvider> </CatastoProvider>
</div> </div>
); );

View file

@ -4,7 +4,7 @@ import { AreaRiservataLayout } from "~/components/Layout";
import stripe from "~/lib/stripe"; import stripe from "~/lib/stripe";
import type { NextPageWithLayout } from "~/pages/_app"; import type { NextPageWithLayout } from "~/pages/_app";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper"; import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { zPagamentoId } from "~/server/utils/zod_types"; import { zOrdineId } from "~/server/utils/zod_types";
type PagamentoPageProps = { type PagamentoPageProps = {
clientSecret: string | null; clientSecret: string | null;
@ -13,6 +13,10 @@ type PagamentoPageProps = {
descrizione: string; 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> = ({ const PagamentoPage: NextPageWithLayout<PagamentoPageProps> = ({
clientSecret, clientSecret,
titolo, titolo,
@ -41,9 +45,9 @@ PagamentoPage.getLayout = function getLayout(page) {
export default PagamentoPage; export default PagamentoPage;
export const getServerSideProps = (async (context) => { export const getServerSideProps = (async (context) => {
const id = context.params?.paymentId; const id = context.params?.ordineId;
if (typeof id !== "string") { if (typeof id !== "string") {
console.error("Error: paymentId is not a string"); console.error("Error: ordineId is not a string");
return { return {
redirect: { redirect: {
destination: "/500", destination: "/500",
@ -52,9 +56,9 @@ export const getServerSideProps = (async (context) => {
}; };
} }
const parsed = zPagamentoId.safeParse(id); const parsed = zOrdineId.safeParse(id);
if (!parsed.success) { if (!parsed.success) {
console.error("Error parsing paymentId", parsed.error); console.error("Error parsing ordineId", parsed.error);
return { return {
redirect: { redirect: {
destination: "/500", destination: "/500",
@ -69,9 +73,17 @@ export const getServerSideProps = (async (context) => {
if (helper) { if (helper) {
const paymentData = const paymentData =
await helper.trpc.pagamenti.getPagamentoDataForCheckout.fetch({ 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({ const { client_secret: clientSecret } = await stripe.paymentIntents.create({
amount: paymentData.amount_cent, amount: paymentData.amount_cent,
@ -79,12 +91,15 @@ export const getServerSideProps = (async (context) => {
enabled: true, enabled: true,
}, },
currency: "eur", currency: "eur",
customer: customer.id,
description: paymentData.nome_it, description: paymentData.nome_it,
metadata: { metadata: {
paymentId: paymentData.id, packId: paymentData.packid,
ordineId: paymentData.ordine_id,
servizioId: paymentData.servizio_id, servizioId: paymentData.servizio_id,
userId: paymentData.userid, userId: paymentData.userid,
}, },
receipt_email: paymentData.email, receipt_email: paymentData.email,
}); });

View file

@ -9,6 +9,7 @@ import stripe from "~/lib/stripe";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider"; import { useTranslation } from "~/providers/I18nProvider";
import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum"; import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum";
import type { ServizioServizioId } from "~/schemas/public/Servizio";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper"; import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
enum AcquistoStatusEnum { enum AcquistoStatusEnum {
@ -20,6 +21,7 @@ enum AcquistoStatusEnum {
} }
type AcquistoProcessingProps = { type AcquistoProcessingProps = {
servizioId: ServizioServizioId | null;
status: AcquistoStatusEnum; status: AcquistoStatusEnum;
}; };
@ -38,6 +40,7 @@ export const getServerSideProps = (async (context) => {
if (!paymentIntent) { if (!paymentIntent) {
return { return {
props: { props: {
servizioId: null,
status: AcquistoStatusEnum.internal_error, status: AcquistoStatusEnum.internal_error,
}, },
}; };
@ -54,6 +57,7 @@ export const getServerSideProps = (async (context) => {
if (!payment) { if (!payment) {
return { return {
props: { props: {
servizioId: null,
status: AcquistoStatusEnum.not_found, status: AcquistoStatusEnum.not_found,
}, },
}; };
@ -75,6 +79,7 @@ export const getServerSideProps = (async (context) => {
} }
return { return {
props: { props: {
servizioId: payment.servizio_id,
status: resultStatus, status: resultStatus,
}, },
}; };
@ -89,6 +94,7 @@ export const getServerSideProps = (async (context) => {
}) satisfies GetServerSideProps<AcquistoProcessingProps>; }) satisfies GetServerSideProps<AcquistoProcessingProps>;
export default function AcquistoProcessing({ export default function AcquistoProcessing({
servizioId,
status, status,
}: AcquistoProcessingProps) { }: AcquistoProcessingProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -108,7 +114,7 @@ export default function AcquistoProcessing({
title={t.acquisto_elaborazione.success_title} title={t.acquisto_elaborazione.success_title}
/> />
<ProgressRedirect href="/area-riservata/dashboard" /> <ProgressRedirect href={`/area-riservata/servizio/${servizioId}`} />
</> </>
)} )}
{status === AcquistoStatusEnum.processing && ( {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 = { type ServizioAnnuncioProviderProps = {
data: ServizioData["annunci"][number]; data: ServizioData["annunci"][number];
servizioId: ServizioServizioId; servizioId: ServizioServizioId;
userId: UsersId;
children: React.ReactNode; children: React.ReactNode;
}; };
@ -60,7 +59,6 @@ const ServizioAnnuncioContext =
export const ServizioAnnuncioProvider = ({ export const ServizioAnnuncioProvider = ({
data, data,
servizioId, servizioId,
userId,
children, children,
}: ServizioAnnuncioProviderProps) => { }: ServizioAnnuncioProviderProps) => {
const utils = api.useUtils(); const utils = api.useUtils();
@ -70,10 +68,7 @@ export const ServizioAnnuncioProvider = ({
toast.error(error.message); toast.error(error.message);
}, },
onSuccess: async () => { onSuccess: async () => {
await utils.servizio.getAllServizioAnnunci.invalidate({ await utils.servizio.invalidate();
userId: userId,
});
await utils.servizio.getServizioData.invalidate({ servizioId });
toast.success("Dati salvati con successo"); 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 path = req.nextUrl.pathname;
const isOverridableRoute = const isOverridableRoute =
path.startsWith("/area-riservata/admin/scheda-annuncio-stampa/") || path.startsWith("/area-riservata/scheda-annuncio-stampa/") ||
path.startsWith("/servizio/condizioni/") || path.startsWith("/servizio/condizioni/") ||
path.startsWith("/servizio/ricevuta-cortesia/"); path.startsWith("/servizio/ricevuta-cortesia/");
// permette a puppeteer di accedere alla pagina di generazione PDF bypassando l'autenticazione normale // 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 { PrezziarioIdprezziario } from './Prezziario';
import type { ServizioServizioId } from './Servizio'; import type { ServizioServizioId } from './Servizio';
import type { default as OrderTypeEnum } from './OrderTypeEnum'; import type { default as OrderTypeEnum } from './OrderTypeEnum';
import type { default as PaymentStatusEnum } from './PaymentStatusEnum';
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.ordini */ /** Identifier type for public.ordini */
@ -25,6 +26,18 @@ export default interface OrdiniTable {
type: ColumnType<OrderTypeEnum, OrderTypeEnum, OrderTypeEnum>; type: ColumnType<OrderTypeEnum, OrderTypeEnum, OrderTypeEnum>;
isActive: ColumnType<boolean, boolean | undefined, boolean>; 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>; export type Ordini = Selectable<OrdiniTable>;

View file

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

View file

@ -1,9 +1,15 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod/v4"; import { z } from "zod/v4";
import type OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; 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 { 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 // Create a new ratelimiter, that allows 10 requests per 10 seconds
/* /*
const ratelimit = new RateLimiterHandler({ const ratelimit = new RateLimiterHandler({
@ -18,24 +24,45 @@ export const pagamentiRouter = createTRPCRouter({
.input(z.object({ pIntentId: z.string() })) .input(z.object({ pIntentId: z.string() }))
.query(async ({ input }) => { .query(async ({ input }) => {
try { try {
return ( const ordine = await db
(await db .selectFrom("ordini")
.selectFrom("payments") .selectAll("ordini")
.selectAll("payments") .where("ordini.intent_id", "=", input.pIntentId)
.where("payments.intent_id", "=", input.pIntentId) .executeTakeFirst();
.executeTakeFirst()) || null return ordine || null;
);
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", 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 getPagamentoDataForCheckout: protectedProcedure
.input(z.object({ paymentId: zPagamentoId })) .input(z.object({ ordineId: zOrdineId }))
.query(async ({ input }) => { .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 getPrezzoPerServizio: publicProcedure
.input(z.object({ idprezziario: zPrezziarioId })) .input(z.object({ idprezziario: zPrezziarioId }))
.query(async ({ input }) => { .query(async ({ input }) => {
return await getPrezziarioByIdHandler({ id: input.idprezziario }); return await getPrezziarioByIdHandler(input.idprezziario);
}), }),
updatePrezziario: adminProcedure updatePrezziario: adminProcedure
.input( .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 { NewOrdini, OrdiniUpdate } from "~/schemas/public/Ordini";
import type { PaymentsUpdate } from "~/schemas/public/Payments";
import type { NewServizio, ServizioUpdate } from "~/schemas/public/Servizio"; import type { NewServizio, ServizioUpdate } from "~/schemas/public/Servizio";
import type { ServizioAnnunciUpdate } from "~/schemas/public/ServizioAnnunci"; import type { ServizioAnnunciUpdate } from "~/schemas/public/ServizioAnnunci";
import type { Users } from "~/schemas/public/Users"; import type { Users } from "~/schemas/public/Users";
@ -18,34 +17,27 @@ import {
removeOrder, removeOrder,
updateOrder, updateOrder,
} from "~/server/controllers/ordini.controller"; } from "~/server/controllers/ordini.controller";
import { getPacksPerServizio } from "~/server/controllers/pagamenti.controller";
import { import {
addServizio, addServizio,
addServizioAnnunci, addServizioAnnunci,
adminUpdateConfermaStatus, adminUpdateConfermaStatus,
attivaServizio_SaltaPagamentoAcconto,
deleteServizio, deleteServizio,
deleteServizioAnnuncio, deleteServizioAnnuncio,
getAllPagamenti,
getAllServizioAnnunci, getAllServizioAnnunci,
getAnnunciAvailableToAdd, getAnnunciAvailableToAdd,
getCompatibileAnnunci, getCompatibileAnnunci,
getDataPerAcquisto, getDataPerAcquisto,
getOrdineRicevutaData, getOrdineRicevutaData,
getPacksPerServizio,
getServiziByUserId, getServiziByUserId,
getServizioAnnuncio, getServizioAnnuncio,
getServizioById, getServizioById,
getServizioDataById, getServizioDataById,
getServizioPagamentoData,
getUserPagamenti,
InteractionLogicTipologia, InteractionLogicTipologia,
interruzioneServizio, interruzioneServizio,
isOrdineAwaitingPayment, processServizioOnboard,
postAcquistoDataHandler,
removePagamento,
SbloccaContatti, SbloccaContatti,
setupSaldoConsulenzaServizio,
setupSaldoServizio,
updatePagamento,
updateServizio, updateServizio,
updateServizioAnnuncio, updateServizioAnnuncio,
userAccettaConferma, userAccettaConferma,
@ -54,7 +46,6 @@ import {
import { import {
zAnnuncioId, zAnnuncioId,
zOrdineId, zOrdineId,
zPagamentoId,
zServizioId, zServizioId,
zUserId, zUserId,
} from "~/server/utils/zod_types"; } from "~/server/utils/zod_types";
@ -116,9 +107,7 @@ export const servizioRouter = createTRPCRouter({
.query(async ({ input }) => { .query(async ({ input }) => {
return await getDataPerAcquisto(input.servizioId); return await getDataPerAcquisto(input.servizioId);
}), }),
getAllPagamenti: adminProcedure.query(async () => {
return await getAllPagamenti();
}),
getAllServizioAnnunci: protectedProcedure getAllServizioAnnunci: protectedProcedure
.input( .input(
z.object({ z.object({
@ -185,6 +174,7 @@ export const servizioRouter = createTRPCRouter({
.query(async ({ input }) => { .query(async ({ input }) => {
return await getOrdini(input.userId); return await getOrdini(input.userId);
}), }),
isOnboardAvailable: protectedProcedure isOnboardAvailable: protectedProcedure
.input( .input(
z.object({ z.object({
@ -229,24 +219,6 @@ export const servizioRouter = createTRPCRouter({
servizioId: input.servizioId, 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 getUserServizi: adminProcedure
.input( .input(
@ -266,17 +238,8 @@ export const servizioRouter = createTRPCRouter({
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return await interruzioneServizio(input.servizioId); 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( .input(
z.object({ z.object({
anagrafica: z.custom<UsersAnagraficaUpdate>(), anagrafica: z.custom<UsersAnagraficaUpdate>(),
@ -289,10 +252,20 @@ export const servizioRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return await postAcquistoDataHandler({ return await processServizioOnboard({
...input, ...input,
}); });
}), }),
attivazioneSenzaPagamento: protectedProcedure
.input(
z.object({
ordineId: zOrdineId,
}),
)
.mutation(async ({ input }) => {
return await attivaServizio_SaltaPagamentoAcconto(input.ordineId);
}),
removeAnnuncioServizio: protectedProcedure removeAnnuncioServizio: protectedProcedure
.input( .input(
z.object({ z.object({
@ -312,42 +285,7 @@ export const servizioRouter = createTRPCRouter({
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return await removeOrder(input.ordineId); 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 getPacksSolution: protectedProcedure
.input( .input(
z.object({ z.object({
@ -392,19 +330,7 @@ export const servizioRouter = createTRPCRouter({
ordineId: input.ordineId, 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 updateServizio: protectedProcedure
.input( .input(
z.object({ z.object({

View file

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

View file

@ -1,31 +1,23 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { jsonArrayFrom } from "kysely/helpers/postgres";
import type { import type {
NewOrdini, NewOrdini,
Ordini,
OrdiniOrdineId, OrdiniOrdineId,
OrdiniUpdate, OrdiniUpdate,
} from "~/schemas/public/Ordini"; } from "~/schemas/public/Ordini";
import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum"; import type { Users, UsersId } from "~/schemas/public/Users";
import type { UsersId } from "~/schemas/public/Users"; import { db } from "~/server/db";
import { db, type Querier } from "~/server/db";
export type OrdiniWPagamenti = Awaited<ReturnType<typeof getOrdini>>[number]; export type Ordini_w_Utente = Ordini & Pick<Users, "username">;
export const getOrdini = async (userId: UsersId | undefined) => { export const getOrdini = async (
userId: UsersId | undefined,
): Promise<Ordini_w_Utente[]> => {
try { try {
let qry = db let qry = db
.selectFrom("ordini") .selectFrom("ordini")
.selectAll("ordini") .selectAll("ordini")
.innerJoin("users", "users.id", "ordini.userid") .innerJoin("users", "users.id", "ordini.userid")
.select("users.username") .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"),
]);
if (userId) { if (userId) {
qry = qry.where("ordini.userid", "=", userId); qry = qry.where("ordini.userid", "=", userId);
@ -46,15 +38,6 @@ export const getOrdineById = async (ordineId: OrdiniOrdineId) => {
.selectAll("ordini") .selectAll("ordini")
.innerJoin("users", "users.id", "ordini.userid") .innerJoin("users", "users.id", "ordini.userid")
.select("users.username") .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) .where("ordini.ordine_id", "=", ordineId)
.orderBy("created_at", "desc") .orderBy("created_at", "desc")
.executeTakeFirstOrThrow( .executeTakeFirstOrThrow(
@ -70,38 +53,11 @@ export const getOrdineById = async (ordineId: OrdiniOrdineId) => {
export const createOrdine = async (data: NewOrdini) => { export const createOrdine = async (data: NewOrdini) => {
try { try {
return await db.transaction().execute(async (tx) => { return await db
const prezzo = await tx .insertInto("ordini")
.selectFrom("prezziario") .values(data)
.select(["prezzo_cent", "nome_it"]) .returningAll()
.where("prezziario.idprezziario", "=", data.packid) .executeTakeFirstOrThrow(() => new Error("Failed to create ordine"));
.executeTakeFirstOrThrow(
() => new Error(`Prezziario not found - packid: ${data.packid}`),
);
const order = await tx
.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) { } catch (e) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", 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 { env } from "~/env";
import stripe from "~/lib/stripe"; import stripe from "~/lib/stripe";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum"; import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import type { Ordini, OrdiniOrdineId } from "~/schemas/public/Ordini";
import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum"; import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum";
import type { Payments, PaymentsId } from "~/schemas/public/Payments";
import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe"; import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum"; import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import { db } from "~/server/db"; 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 ({ export const whIntentCreatedHandler = async ({
pIntentId, pIntentId,
paymentId, ordineId,
}: { }: {
pIntentId: Payments["intent_id"]; pIntentId: Ordini["intent_id"];
paymentId: PaymentsId; ordineId: OrdiniOrdineId;
}) => { }) => {
try { try {
return await db return await db
.updateTable("payments") .updateTable("ordini")
.set({ .set({
intent_id: pIntentId, intent_id: pIntentId,
paymentstatus: PaymentStatusEnum.processing, paymentstatus: PaymentStatusEnum.processing,
}) })
.where("id", "=", paymentId) .where("ordine_id", "=", ordineId)
.returning("id") .returning("ordine_id")
.execute(); .execute();
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
@ -107,56 +58,41 @@ export const whIntentCreatedHandler = async ({
export const whIntentSucceededHandler = async ({ export const whIntentSucceededHandler = async ({
pIntentId, pIntentId,
paymentId, ordineId,
pm_id, pm_id,
}: { }: {
pIntentId: Payments["intent_id"]; pIntentId: Ordini["intent_id"];
paymentId: PaymentsId; ordineId: OrdiniOrdineId;
pm_id: string; pm_id: string;
}) => { }) => {
try { try {
const paymentMethod = await stripe.paymentMethods.retrieve(pm_id); const paymentMethod = await stripe.paymentMethods.retrieve(pm_id);
await db.transaction().execute(async (trx) => { await db.transaction().execute(async (trx) => {
const payment = await trx const ordine = await trx
.updateTable("payments") .updateTable("ordini")
.set({ .set({
paid_at: new Date(), paid_at: new Date(),
paymentmethod: paymentMethod.type, paymentmethod: paymentMethod.type,
paymentstatus: PaymentStatusEnum.success, paymentstatus: PaymentStatusEnum.success,
isActive: true,
}) })
.where("intent_id", "=", pIntentId) .where("intent_id", "=", pIntentId)
.where("payments.id", "=", paymentId) .where("ordine_id", "=", ordineId)
.returning(["servizio_id", "ordine_id"]) .returning(["servizio_id", "ordine_id", "userid", "type", "created_at"])
.executeTakeFirstOrThrow(() => { .executeTakeFirstOrThrow(() => {
throw new Error( 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 const servizio = await trx
.selectFrom("servizio") .selectFrom("servizio")
.select("servizio.tipologia") .select(["servizio.tipologia", "servizio.servizio_id"])
.where("servizio_id", "=", payment.servizio_id) .where("servizio_id", "=", ordine.servizio_id)
.executeTakeFirstOrThrow(() => { .executeTakeFirstOrThrow(() => {
throw new Error( 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: case OrderTypeEnum.Acconto:
await trx await trx
.updateTable("servizio") .updateTable("servizio")
.where("servizio_id", "=", payment.servizio_id) .where("servizio_id", "=", servizio.servizio_id)
.set({ .set({
decorrenza: new Date(), decorrenza: new Date(),
isOkAcconto: true, isOkAcconto: true,
@ -223,7 +159,7 @@ export const whIntentSucceededHandler = async ({
case OrderTypeEnum.Saldo: case OrderTypeEnum.Saldo:
await trx await trx
.updateTable("servizio") .updateTable("servizio")
.where("servizio_id", "=", payment.servizio_id) .where("servizio_id", "=", servizio.servizio_id)
.set({ .set({
isOkConsulenza: isOkConsulenza:
servizio.tipologia === TipologiaPosizioneEnum.Transitorio, servizio.tipologia === TipologiaPosizioneEnum.Transitorio,
@ -263,7 +199,7 @@ export const whIntentSucceededHandler = async ({
case OrderTypeEnum.Consulenza: case OrderTypeEnum.Consulenza:
await trx await trx
.updateTable("servizio") .updateTable("servizio")
.where("servizio_id", "=", payment.servizio_id) .where("servizio_id", "=", servizio.servizio_id)
.set({ .set({
isOkConsulenza: true, isOkConsulenza: true,
}) })
@ -336,7 +272,7 @@ export const whIntentSucceededHandler = async ({
// Enhanced error logging // Enhanced error logging
console.error("whIntentSucceededHandler error:", { console.error("whIntentSucceededHandler error:", {
pIntentId, pIntentId,
paymentId, ordineId,
pm_id, pm_id,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined, stack: error instanceof Error ? error.stack : undefined,
@ -352,10 +288,10 @@ export const whIntentSucceededHandler = async ({
export const whIntentFailedHandler = async ({ export const whIntentFailedHandler = async ({
pIntentId, pIntentId,
}: { }: {
pIntentId: Payments["intent_id"]; pIntentId: Ordini["intent_id"];
}) => { }) => {
const userId = await db const userId = await db
.updateTable("payments") .updateTable("ordini")
.set({ .set({
paymentstatus: PaymentStatusEnum.failed, paymentstatus: PaymentStatusEnum.failed,
}) })

View file

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

View file

@ -83,7 +83,7 @@ export const genPdfSchedaAnnuncioBase64 = async (annuncioId: AnnunciId) => {
export const genPdfSchedaAnnuncio = async (annuncioId: AnnunciId) => { export const genPdfSchedaAnnuncio = async (annuncioId: AnnunciId) => {
const keybd = getKeydbClient(); 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 }; const options: PDFOptions = { printBackground: true };
if (keybd) { if (keybd) {
const cacheKey = `${SCHEDA_ANNUNCIO_CACHE_PREFIX}${annuncioId}`; const cacheKey = `${SCHEDA_ANNUNCIO_CACHE_PREFIX}${annuncioId}`;
@ -141,11 +141,15 @@ const genPdfCondizioni = async (condizioniId: TestiEStringheStingaId) => {
return await puppeteerGenPdf({ url }); 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 ( export const genPdfRicevutaCortesiaBase64 = async (
ordineId: OrdiniOrdineId, ordineId: OrdiniOrdineId,
) => { ) => {
const url = `/servizio/ricevuta-cortesia/${ordineId}?raw=true`; const pdf = await genPdfRicevutaCortesia(ordineId);
const pdf = await puppeteerGenPdf({ url });
const pdfBase64 = Buffer.from(pdf).toString("base64"); const pdfBase64 = Buffer.from(pdf).toString("base64");
const base64Content = pdfBase64.includes("base64,") const base64Content = pdfBase64.includes("base64,")
? pdfBase64.split("base64,")[1] ? 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 { FlagsId } from "~/schemas/public/Flags";
import type { MessagesMessageid } from "~/schemas/public/Messages"; import type { MessagesMessageid } from "~/schemas/public/Messages";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini"; import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import type { PaymentsId } from "~/schemas/public/Payments";
import type { PrezziarioIdprezziario } from "~/schemas/public/Prezziario"; import type { PrezziarioIdprezziario } from "~/schemas/public/Prezziario";
import type { ServizioServizioId } from "~/schemas/public/Servizio"; import type { ServizioServizioId } from "~/schemas/public/Servizio";
import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe"; import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
@ -77,11 +76,6 @@ export const zServizioId = z.custom<ServizioServizioId>(
"falied to validate ServizioId", "falied to validate ServizioId",
); );
export const zPagamentoId = z.custom<PaymentsId>(
(val) => typeof val === "string",
"falied to validate PagamentoId",
);
// export const zComuneId = z.custom<ComuniId>( // export const zComuneId = z.custom<ComuniId>(
// (val) => typeof val === "number", // (val) => typeof val === "number",
// "falied to validate ComuneId", // "falied to validate ComuneId",