feat: add service management features and update Italian translations

- Introduced new service status translations in Italian.
- Added service detail and management components for user and admin views.
- Implemented API endpoints for fetching service data and announcements.
- Enhanced service data handling with improved date parsing and formatting.
- Created new pages for service details accessible by both users and admins.
- Updated database queries to support new service functionalities.
This commit is contained in:
Marco Pedone 2026-02-25 15:30:53 +01:00
parent 6cd09cce1b
commit ac1859e88f
27 changed files with 1446 additions and 786 deletions

View file

@ -5,6 +5,7 @@ import { ContattoAnnuncio } from "~/components/annuncio-interactions/contatto_mo
import LoadingButton from "~/components/custom_ui/loading-button";
import { Button } from "~/components/ui/button";
import { useAnnuncio } from "~/providers/AnnuncioProvider";
import { useTranslation } from "~/providers/I18nProvider";
import { useServizio } from "~/providers/ServizioProvider";
import type { SessionContextType } from "~/providers/SessionProvider";
import type { AnnunciId } from "~/schemas/public/Annunci";
@ -19,11 +20,13 @@ export const AnnuncioInteractions = ({
disabled?: boolean;
}) => {
const { id, tipo } = useAnnuncio();
const { locale } = useTranslation();
if (disabled) {
return (
<div className="w-full rounded-md bg-red-500 p-2 text-center font-semibold text-base text-white">
Richieste Temporaneamente disattivate
{locale === "it"
? "Richieste Temporaneamente disattivate"
: "Requests Temporarily Disabled"}
</div>
);
}
@ -37,7 +40,7 @@ export const AnnuncioInteractions = ({
userId={session.user.id}
/>
) : (
<span>Errore</span>
<span>{locale === "it" ? "Errore" : "Error"}</span>
)
) : (
<div className="flex w-full flex-col gap-2">
@ -70,6 +73,7 @@ const ServizioInteraction = ({
tipologia: string;
annuncioId: AnnunciId;
}) => {
const { locale } = useTranslation();
const { data: servizio, isLoading } =
api.servizio.AnnuncioServizioTipologiaMatch.useQuery({
annuncioId,
@ -85,7 +89,11 @@ const ServizioInteraction = ({
) {
return (
<div>
<p>Non hai un servizio attivo</p>
<p>
{locale === "it"
? "Non hai un servizio attivo"
: "You do not have an active service"}
</p>
</div>
);
}
@ -94,14 +102,21 @@ const ServizioInteraction = ({
return (
<div className="flex w-full flex-col gap-2">
<div className="w-full text-center font-semibold text-green-500">
Annuncio già salvato!
{locale === "it" ? "Annuncio già salvato!" : "Listing already saved!"}
</div>
<Link
aria-label="Vai alla tua area riservata"
aria-label={
locale === "it"
? "Vai alla tua area riservata"
: "Go to your reserved area"
}
href={`/area-riservata/dashboard`}
>
<Button className="w-full" variant="success">
Vai alla tua area riservata <ArrowRight />
{locale === "it"
? "Vai alla tua area riservata"
: "Go to your reserved area"}{" "}
<ArrowRight />
</Button>
</Link>
</div>
@ -116,6 +131,7 @@ const InteressatoButtonUserOnly = ({
}: {
annuncioId: AnnunciId;
}) => {
const { locale } = useTranslation();
const { data: hasInterest, isLoading } =
api.intrests.hasUserInterest.useQuery({
annuncioId,
@ -126,7 +142,11 @@ const InteressatoButtonUserOnly = ({
await utils.intrests.hasUserInterest.invalidate({
annuncioId,
});
toast.success("Richiesta di interesse inviata con successo!");
toast.success(
locale === "it"
? "Richiesta di interesse inviata con successo!"
: "Interest request sent successfully!",
);
},
});
@ -134,7 +154,7 @@ const InteressatoButtonUserOnly = ({
return (
<Button className="w-full" disabled>
<BadgePlus className="size-6" />
Caricamento...
{locale === "it" ? "Caricamento..." : "Loading..."}
</Button>
);
}
@ -142,14 +162,16 @@ const InteressatoButtonUserOnly = ({
return (
<span className="flex w-full items-center justify-center gap-2 rounded-md bg-green-500 p-2 font-semibold text-sm text-white">
<BadgePlus className="inline size-5" />
Hai già mandato una richiesta
{locale === "it"
? "Hai già mandato una richiesta"
: "You have already sent a request"}
</span>
);
}
return (
<Button
aria-label="Sono interessato"
aria-label={locale === "it" ? "Sono interessato" : "I'm interested"}
className="w-full text-lg md:text-base lg:text-lg"
onClick={() => {
add({
@ -160,7 +182,7 @@ const InteressatoButtonUserOnly = ({
variant="success"
>
<BadgePlus className="size-6" />
Sono interessato
{locale === "it" ? "Sono interessato" : "I'm interested"}
</Button>
);
};
@ -171,6 +193,7 @@ export const InteressatoButtonServizio = ({
annuncioId: AnnunciId;
}) => {
const { isAdmin, servizioId, userId } = useServizio();
const { locale } = useTranslation();
const utils = api.useUtils();
const invalidateAll = async () => {
@ -227,7 +250,11 @@ export const InteressatoButtonServizio = ({
variant="info"
>
<BadgePlus className="size-6" />
{isAdmin ? "Aggiungi" : "Sono interessato"}
{isAdmin
? "Aggiungi"
: locale === "it"
? "Sono interessato"
: "I'm interested"}
</LoadingButton>
);
};

View file

@ -2,7 +2,6 @@ import { CircleHelp } from "lucide-react";
import dynamic from "next/dynamic";
import { AccordionComp } from "~/components/accordionComp";
import { LoadingPage } from "~/components/loading";
import { TabRicerca } from "~/components/tables/ricerca-table";
import {
Card,
CardContent,
@ -22,13 +21,14 @@ import {
CredenzaTitle,
CredenzaTrigger,
} from "../custom_ui/credenza";
import { ServizioList } from "../servizio/servizio";
import { Button } from "../ui/button";
const TimeserieBarChart = dynamic(
() => import("~/components/timeserieBarChart").then((mod) => mod.default),
{
ssr: false,
loading: () => <div className="h-[468px] animate-pulse bg-gray-100" />,
loading: () => <div className="h-117 animate-pulse bg-gray-100" />,
},
);
@ -37,7 +37,7 @@ const TimeserieBarChartStacked = dynamic(
import("~/components/timeserieBarChartStacked").then((mod) => mod.default),
{
ssr: false,
loading: () => <div className="h-[468px] animate-pulse bg-gray-100" />,
loading: () => <div className="h-117 animate-pulse bg-gray-100" />,
},
);
@ -94,12 +94,18 @@ export const AdminDashboard = () => {
export const UserDashboard = ({ userId }: { userId: UsersId }) => {
const { t } = useTranslation();
const { data, isLoading } = api.servizio.getAllServizioAnnunci.useQuery({
userId,
});
if (isLoading) return <LoadingPage />;
return (
<div className="mt-2 flex w-full flex-1 flex-col items-start justify-center gap-4 overflow-auto p-2 md:gap-6">
<h3 className="font-bold text-2xl">Le tue ricerche</h3>
<div className="flex w-full flex-1 grow flex-col space-y-5">
<TabRicerca isAdmin={false} userId={userId} />
<ServizioList isAdmin={false} servizi={data || []} userId={userId} />
</div>
<div className="mx-auto">
<Credenza>

View file

@ -4,6 +4,7 @@ import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
import { getStorageUrl } from "~/lib/storage_utils";
import { useTranslation } from "~/providers/I18nProvider";
import { useSession } from "~/providers/SessionProvider";
import type { UsersId } from "~/schemas/public/Users";
import type { UsersStorageUserStorageId } from "~/schemas/public/UsersStorage";
@ -29,6 +30,7 @@ export const DocumentiPersonali = ({
userData: CompleteUserData;
}) => {
const utils = api.useUtils();
const { locale } = useTranslation();
const { mutate, isPending } = api.users.editUserAnagrafica.useMutation({
onSuccess: async () => {
await utils.users.getClientProfilo.invalidate({
@ -56,12 +58,19 @@ export const DocumentiPersonali = ({
return (
<Card>
<CardHeader>
<CardTitle>Documenti personali</CardTitle>
<CardTitle>
{locale === "it" ? "Documenti personali" : "Personal Documents"}
</CardTitle>
<CardDescription>
<p>I tuoi documenti sono necessari per usufruire del servizio.</p>
<p>
Nel caso di un unico documento (es: scansione fronte e retro del
documento) caricarlo 2 volte.
{locale === "it"
? "I tuoi documenti sono necessari per usufruire del servizio."
: "Your documents are required to use the service."}
</p>
<p>
{locale === "it"
? "Nel caso di un unico documento (es: scansione fronte e retro del documento) caricarlo 2 volte."
: "In case of a single document (e.g., front and back scan of the document), upload it twice."}
</p>
</CardDescription>
</CardHeader>
@ -100,10 +109,11 @@ export const DocumentiPersonali = ({
</CardContent>
<CardFooter>
<p className="text-muted-foreground text-sm">
I tuoi dati saranno conservati solamente per la durata del servizio,
in conformità con la nostra{" "}
{locale === "it"
? "I tuoi dati saranno conservati solamente per la durata del servizio, in conformità con la nostra "
: "Your data will be stored only for the duration of the service, in accordance with our "}
<Link className="underline" href="/privacy-policy" target="_blank">
Informativa sulla Privacy
{locale === "it" ? "Informativa sulla Privacy" : "Privacy Policy"}
</Link>
</p>
</CardFooter>
@ -133,6 +143,7 @@ export const DocumentiUploadSection = ({
isPending: boolean;
caricatoNote?: boolean;
}) => {
const { locale } = useTranslation();
const [imageError, setImageError] = useState(false);
if (document?.storageId) {
return (
@ -141,7 +152,9 @@ export const DocumentiUploadSection = ({
{caricatoNote && (
<div className="flex items-center gap-2 text-green-500">
<CircleCheck className="size-5" />
<span>Documento caricato</span>
<span>
{locale === "it" ? "Documento caricato" : "Document uploaded"}
</span>
</div>
)}
<div className="flex items-center gap-4">
@ -165,7 +178,11 @@ export const DocumentiUploadSection = ({
src={getStorageUrl({ storageId: document.storageId })}
title="doc"
>
<p>Il tuo browser non supporta gli iframe.</p>
<p>
{locale === "it"
? "Il tuo browser non supporta gli iframe."
: "Your browser does not support iframes."}
</p>
</iframe>
</div>
)}
@ -175,23 +192,31 @@ export const DocumentiUploadSection = ({
target="_blank"
>
<Button size="sm" variant="outline">
<span>Visualizza</span>
<span>{locale === "it" ? "Visualizza" : "View"}</span>
<ExternalLink className="size-4" />
</Button>
</Link>
{onRemove && (
<Confirm
description="Sei sicuro di voler eliminare il documento?"
description={
locale === "it"
? "Sei sicuro di voler eliminare il documento?"
: "Are you sure you want to delete the document?"
}
onConfirm={onRemove}
title="Elimina Documento"
title={
locale === "it" ? "Elimina Documento" : "Delete Document"
}
>
<Button
aria-label="Elimina documento"
aria-label={
locale === "it" ? "Elimina documento" : "Delete document"
}
disabled={isPending}
size="sm"
variant="destructive"
>
<span>Rimuovi</span>
<span>{locale === "it" ? "Rimuovi" : "Remove"}</span>
<Trash2 className="size-4" />
</Button>
</Confirm>

View file

@ -94,7 +94,7 @@ export default function ChatBottombar({
const isMobile = useMediaQuery("(min-width: 426px)");
return (
<div className="flex min-h-[74px] w-full items-center justify-between gap-2 p-2">
<div className="flex min-h-18.5 w-full items-center justify-between gap-2 p-2">
<div className="flex">
<ChatAttachments chatUserData={chatUserData} userData={userData} />
</div>

View file

@ -144,7 +144,7 @@ export const ChatSidebar = ({ chatId }: { chatId: ChatsChatid | null }) => {
/>
</div>
<nav className="grid gap-1 rounded-lg group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
<nav className="grid gap-1 rounded-lg group-data-[collapsed=true]:justify-center group-data-[collapsed=true]:px-2">
{activeChats.map((chat) => (
<ContextMenu key={chat.userinfo.username}>
<ContextMenuTrigger asChild>

View file

@ -20,6 +20,7 @@ import {
TooltipTrigger,
} from "~/components/ui/tooltip";
import { cn } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import { useServizio, useServizioAnnuncio } from "~/providers/ServizioProvider";
import type { AnnunciId } from "~/schemas/public/Annunci";
import { api } from "~/utils/api";
@ -124,7 +125,7 @@ export const AdminActions = () => {
export const UserActions = () => {
const { servizioId } = useServizio();
const { data, annuncioId } = useServizioAnnuncio();
const { locale } = useTranslation();
const ContattiSbloccati = data.open_contatti_at !== null;
const ConfermaUtente = data.user_confirmed_at !== null;
const ConfermaLavorata = data.doc_conferma_added && data.hasConfermaAdmin;
@ -140,24 +141,30 @@ export const UserActions = () => {
{AccettatoConferma ? (
<Link
aria-label="Conferma immobile"
aria-label={
locale === "it" ? "Rivedi la conferma" : "Review the confirmation"
}
href={`/servizio/riapri-conferma/${servizioId}/${annuncioId}`}
>
<Button className="w-full sm:w-fit" variant="success">
<Pointer />
Rivedi la conferma
{locale === "it"
? "Rivedi la conferma"
: "Review the confirmation"}
</Button>
</Link>
) : (
ConfermaLavorata &&
(data.doc_conferma_ref ? (
<Link
aria-label="Conferma immobile"
aria-label={locale === "it" ? "Confermato" : "Confirmed"}
href={`/servizio/conferma-immobile/${servizioId}/${annuncioId}`}
>
<Button className="w-full sm:w-fit" variant="success">
<Pointer />
Confermato - Procedi ora
{locale === "it"
? "Confermato - Procedi ora"
: "Confirmed - Proceed now"}
</Button>
</Link>
) : (
@ -169,13 +176,21 @@ export const UserActions = () => {
variant="success"
>
<Pointer />
Confermato - Procedi ora
{locale === "it"
? "Confermato - Procedi ora"
: "Confirmed - Proceed now"}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Il documento non è più disponibile</p>
<p>
Se hai bisogno di una copia, contattaci via email o chat.
{locale === "it"
? "Il documento non è più disponibile"
: "The document is no longer available"}
</p>
<p>
{locale === "it"
? "Se hai bisogno di una copia, contattaci via email o chat."
: "If you need a copy, contact us via email or chat."}
</p>
</TooltipContent>
</Tooltip>
@ -192,15 +207,26 @@ export const UserActions = () => {
const PostConfermaSection = () => {
const { servizio, servizioId } = useServizio();
const { data, annuncioId } = useServizioAnnuncio();
const { locale } = useTranslation();
const saldoPaid = servizio.isOkSaldo;
const consulenzaPaid = servizio.isOkConsulenza;
const doc_non_disponibile =
locale === "it"
? "Il documento non è più disponibile"
: "The document is no longer available";
const contatta_supporto =
locale === "it"
? "Se hai bisogno di una copia, contattaci via email o chat."
: "If you need a copy, contact us via email or chat.";
if (!saldoPaid) {
return (
<div className="flex flex-col gap-1">
<h3 className="font-semibold">
Questo annuncio è stato confermato, procedi al saldo:
{locale === "it"
? "Questo annuncio è stato confermato, procedi al saldo:"
: "This listing has been confirmed, proceed to payment:"}
</h3>
<SaldoButton annuncioId={annuncioId} />
</div>
@ -210,10 +236,14 @@ const PostConfermaSection = () => {
return (
<div className="flex flex-col gap-1">
<h3 className="font-semibold text-green-500">
Ci siamo quasi! Stiamo preparando il contratto per te.
{locale === "it"
? "Ci siamo quasi! Stiamo preparando il contratto per te."
: "Almost there! We are preparing the contract for you."}
</h3>
<h3 className="font-semibold">
Una volta pronto, riceverai una notifica via email.
{locale === "it"
? "Una volta pronto, riceverai una notifica via email."
: "Once ready, you will receive a notification via email."}
</h3>
</div>
);
@ -223,19 +253,27 @@ const PostConfermaSection = () => {
<div className="flex flex-col gap-2">
<h3 className="font-semibold">
{consulenzaPaid
? "Il contratto è pronto, puoi visualizzarlo:"
: "Il contratto è pronto, puoi visualizzarlo e procedere al saldo:"}
? locale === "it"
? "Il contratto è pronto, puoi visualizzarlo:"
: "The contract is ready, you can view it:"
: locale === "it"
? "Il contratto è pronto, puoi visualizzarlo e procedere al saldo:"
: "The contract is ready, you can view it and proceed to payment:"}
</h3>
<div className="flex flex-wrap gap-2">
{data.doc_contratto_ref ? (
<Link
aria-label="Visualizza contratto"
aria-label={
locale === "it" ? "Visualizza contratto" : "View contract"
}
href={`/servizio/contratto/${servizioId}/${annuncioId}`}
target="_blank"
>
<Button className="w-full sm:w-fit">
<FileText />
Visualizza il contratto
{locale === "it"
? "Visualizza il contratto"
: "View the contract"}
</Button>
</Link>
) : (
@ -244,12 +282,14 @@ const PostConfermaSection = () => {
<TooltipTrigger asChild>
<Button className="w-fit cursor-not-allowed opacity-50">
<FileText />
Visualizza il contratto
{locale === "it"
? "Visualizza il contratto"
: "View the contract"}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Il documento non è più disponibile</p>
<p>Se hai bisogno di una copia, contattaci via email o chat.</p>
<p>{doc_non_disponibile}</p>
<p>{contatta_supporto}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -260,13 +300,17 @@ const PostConfermaSection = () => {
{data.doc_registrazione_added &&
(data.doc_registrazione_ref ? (
<Link
aria-label="Visualizza registrazione"
aria-label={
locale === "it" ? "Visualizza registrazione" : "View recording"
}
href={`/servizio/contratto-registrazione/${servizioId}/${annuncioId}`}
target="_blank"
>
<Button className="w-fit">
<FileBadge />
Visualizza la registrazione
{locale === "it"
? "Visualizza la registrazione"
: "View the recording"}
</Button>
</Link>
) : (
@ -275,14 +319,14 @@ const PostConfermaSection = () => {
<TooltipTrigger asChild>
<Button className="w-fit cursor-not-allowed opacity-50">
<FileBadge />
Visualizza la registrazione
{locale === "it"
? "Visualizza la registrazione"
: "View the recording"}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Il documento non è più disponibile</p>
<p>
Se hai bisogno di una copia, contattaci via email o chat.
</p>
<p>{doc_non_disponibile}</p>
<p>{contatta_supporto}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -301,24 +345,30 @@ export const SaldoButton = ({
variant?: ButtonConfigs["variant"];
className?: string;
}) => {
const { locale } = useTranslation();
const { servizioId } = useServizio();
const router = useRouter();
const { mutate } = api.servizio.setupSaldoServizio.useMutation({
onMutate: () => {
toast.loading("Caricamento ...", { id: "preparazioneSaldo" });
},
onSuccess: async (data) => {
toast.success("Pagamento saldo preparato", {
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 (
<Button
aria-label="Procedi al saldo"
aria-label={locale === "it" ? "Procedi al saldo" : "Proceed to payment"}
className={cn("w-fit", className)}
onClick={() => {
mutate({
@ -329,7 +379,7 @@ export const SaldoButton = ({
variant={variant}
>
<PackageCheck />
Procedi al saldo
{locale === "it" ? "Procedi al saldo" : "Proceed to payment"}
</Button>
);
};
@ -341,25 +391,35 @@ const SaldoConsulenzaButton = ({
variant?: ButtonConfigs["variant"];
className?: string;
}) => {
const { locale } = useTranslation();
const { annuncioId } = useServizioAnnuncio();
const { servizioId } = useServizio();
const router = useRouter();
const { mutate } = api.servizio.setupSaldoConsulenzaServizio.useMutation({
onMutate: () => {
toast.loading("Caricamento ...", { id: "preparazioneSaldo" });
},
onSuccess: async (data) => {
toast.success("Pagamento saldo preparato", {
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 (
<Button
aria-label="Procedi al saldo consulenza"
aria-label={
locale === "it"
? "Procedi al saldo consulenza"
: "Proceed to payment for the consultation"
}
className={cn("w-fit", className)}
onClick={() => {
mutate({
@ -370,7 +430,9 @@ const SaldoConsulenzaButton = ({
variant={variant}
>
<PackageCheck />
Procedi al saldo della consulenza
{locale === "it"
? "Procedi al saldo della consulenza"
: "Proceed to payment for the consultation"}
</Button>
);
};

View file

@ -102,7 +102,7 @@ export const AnnuncioDisplay = ({
return (
<div
className={cn(
"grid @md:grid-cols-[12rem_1fr] grid-cols-[7rem_1fr] gap-x-4 gap-y-2 rounded-md border border-card bg-card md:max-w-3xl lg:gap-x-4 xl:gap-x-6",
"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",
className,
)}
>
@ -127,7 +127,7 @@ export const AnnuncioDisplay = ({
/>
</div>
<div className="col-span-1 flex w-full max-w-xl flex-wrap gap-5 md:col-span-1 md:mt-4 md:pr-8">
<div className="col-span-1 flex w-full max-w-xl flex-wrap gap-5 pt-2 md:col-span-1 md:mt-4 md:pr-8">
<span className="text-wrap">{data.titolo_it}</span>
</div>

View file

@ -51,21 +51,22 @@ export const AnnuncioDettaglio = ({ data }: { data: AnnuncioRicerca }) => {
className="w-full"
variant="outline"
>
<Expand /> Dettaglio
<Expand /> {locale === "it" ? "Dettaglio" : "Detail"}
</Button>
</CredenzaTrigger>
<CredenzaContent className="max-w-xl sm:max-w-6xl md:max-h-[90vh] md:overflow-y-auto">
<CredenzaHeader className="flex-row items-center justify-between gap-4 md:justify-start">
<CredenzaTitle className="text-xl">
Annuncio {data.codice}
{locale === "it" ? "Annuncio" : "Listing"} {data.codice}
</CredenzaTitle>
<Link href={`/annuncio/${data.codice}`} target="_blank">
<Button size="sm" variant="info">
<span>Pagina completa</span> <ExternalLink />
<span>{locale === "it" ? "Pagina completa" : "Full page"}</span>{" "}
<ExternalLink />
</Button>
</Link>
<CredenzaDescription className="sr-only">
Dettaglio
{locale === "it" ? "Dettaglio" : "Detail"}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody className="h-full overflow-auto pb-5">
@ -83,7 +84,9 @@ export const AnnuncioDettaglio = ({ data }: { data: AnnuncioRicerca }) => {
<TipoBadge tipo={data.tipo} />
{data.stato === "Trattativa" && (
<Badge className="bg-violet-500 font-semibold text-md">
Annuncio in Trattativa
{locale === "it"
? "Annuncio in Trattativa"
: "Listing in Negotiation"}
</Badge>
)}
</div>

View file

@ -1,5 +1,6 @@
import { CheckCircle } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "~/providers/I18nProvider";
import { useServizio } from "~/providers/ServizioProvider";
import { api } from "~/utils/api";
import { CardAnnuncio } from "../annuncio_card";
@ -23,6 +24,7 @@ import { AnnuncioDettaglio } from "./annuncio_dettaglio";
export const AnnunciCompatibili = () => {
const { servizioId, isAdmin } = useServizio();
const { locale } = useTranslation();
const [adminOverride, setAdminOverride] = useState(false);
const { data, isLoading } = api.servizio.getCompatibileAnnunci.useQuery({
servizioId,
@ -32,13 +34,17 @@ export const AnnunciCompatibili = () => {
<Dialog>
<DialogTrigger asChild>
<Button className="w-full bg-purple-500 hover:bg-purple-600 sm:w-fit dark:text-white">
Esplora Annunci Compatibili
{locale === "it"
? "Esplora Annunci Compatibili"
: "Explore Compatible Listings"}
</Button>
</DialogTrigger>
<DialogContent className="max-w-xl p-2 sm:max-w-5xl sm:p-6 md:max-w-7xl 2xl:max-w-400">
<DialogHeader className="flex-row flex-wrap items-center gap-5 pt-6">
<DialogTitle>
Annunci compatibili con i parametri del servizio
{locale === "it"
? "Annunci compatibili con i parametri del servizio"
: "Listings compatible with the service parameters"}
</DialogTitle>
{isAdmin && (
<div className="flex flex-wrap items-center gap-x-2">
@ -63,8 +69,9 @@ export const AnnunciCompatibili = () => {
<>
{!data || data.length === 0 ? (
<span>
Nessun annuncio compatibile trovato. Amplia la ricerca per
trovare annunci compatibili.
{locale === "it"
? "Nessun annuncio compatibile trovato. Amplia la ricerca per trovare annunci compatibili."
: "No compatible listings found. Broaden your search to find compatible listings."}
</span>
) : (
<div className="relative z-0 mx-auto grid max-w-full grid-flow-row grid-cols-1 gap-14 sm:grid-cols-2 sm:gap-6 sm:gap-y-10 lg:grid-cols-3 2xl:grid-cols-4">
@ -89,7 +96,11 @@ export const AnnunciCompatibili = () => {
className="flex h-9 w-full items-center justify-center gap-2 text-base [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
variant="success"
>
<span>Richiesta inviata</span>
<span>
{locale === "it"
? "Richiesta inviata"
: "Request sent"}
</span>
<CheckCircle />
</Badge>
) : (

View file

@ -143,7 +143,7 @@ const InvioConferma = ({ closeModal }: { closeModal: () => void }) => {
};
const DocCheckpoint = () => {
const { t } = useTranslation();
const { t, locale } = useTranslation();
const { userId, servizio, isAdmin, servizioId } = useServizio();
const utils = api.useUtils();
const { mutate, isPending } = api.servizio.updateServizio.useMutation({
@ -172,8 +172,9 @@ const DocCheckpoint = () => {
<CredenzaBody className="max-h-[80vh] overflow-auto pb-5">
<div className="space-y-5">
<p>
Devi ancora caricare un tuo documento legato alla permanenza e
motivazione di transitorietà
{locale === "it"
? "Devi ancora caricare un tuo documento legato alla permanenza e motivazione di transitorietà"
: "You still need to upload a document related to your stay and reason for transience"}
</p>
<DocumentiUploadSection
@ -182,17 +183,18 @@ const DocCheckpoint = () => {
isAdmin={isAdmin}
isPending={isPending}
onUpload={handleUpload}
title="Documento:"
title={locale === "it" ? "Documento:" : "Document:"}
userId={userId}
/>
<br />
<br />
<p className="text-muted-foreground text-sm">
I tuoi dati saranno conservati solamente per la durata del servizio,
in conformità con la nostra{" "}
{locale === "it"
? "I tuoi dati saranno conservati solamente per la durata del servizio, in conformità con la nostra "
: "Your data will be stored only for the duration of the service, in accordance with our "}
<Link className="underline" href="/privacy-policy" target="_blank">
Informativa sulla Privacy
{locale === "it" ? "Informativa sulla Privacy" : "Privacy Policy"}
</Link>
</p>
</div>
@ -209,7 +211,7 @@ const DocCheckpoint = () => {
};
export const ConfermaInLavorazioneModal = () => {
const { t } = useTranslation();
const { t, locale } = useTranslation();
const { data } = useServizioAnnuncio();
return (
<Credenza>
@ -219,19 +221,27 @@ export const ConfermaInLavorazioneModal = () => {
className="w-full sm:w-fit"
>
<CircleCheck />
Conferma in lavorazione
{locale === "it"
? "Conferma in lavorazione"
: "Confirmation in progress"}
</Button>
</CredenzaTrigger>
<CredenzaContent className="max-h-[90vh]">
<CredenzaHeader>
<CredenzaTitle>In attesa di conferma</CredenzaTitle>
<CredenzaTitle>
{locale === "it"
? "In attesa di conferma"
: "Awaiting confirmation"}
</CredenzaTitle>
<CredenzaDescription className="sr-only">
Consegna status modal
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody className="prose dark:prose-invert max-h-[80vh] overflow-auto pb-5">
<p>
Richiesta di conferma inviata il:{" "}
{locale === "it"
? "Richiesta di conferma inviata il:"
: "Confirmation request sent on:"}{" "}
{data.user_confirmed_at &&
new Date(data.user_confirmed_at).toLocaleString("it", {
day: "2-digit",
@ -242,9 +252,9 @@ export const ConfermaInLavorazioneModal = () => {
})}
</p>
<p>
La conferma è ancora in lavorazione. Stiamo verificando con i
proprietari le condizioni e dettagli della conferma per riferirteli.
Attendi una comunicazione.
{locale === "it"
? "La conferma è ancora in lavorazione. Stiamo verificando con i proprietari le condizioni e dettagli della conferma per riferirteli. Attendi una comunicazione."
: "The confirmation is still in progress. We are checking with the owners the conditions and details of the confirmation to report them to you. Please wait for a communication."}
</p>
</CredenzaBody>

View file

@ -1,372 +0,0 @@
import { add } from "date-fns";
import { ArrowRight, Package, PackageCheck, Trash2 } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react";
import toast from "react-hot-toast";
import { SaldoButton } from "~/components/servizio/annuncio_actions";
import {
AnnuncioCard,
AnnuncioDisplay,
} from "~/components/servizio/annuncio_card";
import { ServizioActions } from "~/components/servizio/servizio_actions";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { cn } from "~/lib/utils";
import {
ServizioAnnuncioProvider,
useServizio,
} from "~/providers/ServizioProvider";
import { api } from "~/utils/api";
import { Confirm } from "../confirm";
import { AlarmClockSVG } from "../svgs";
import { AnnunciCompatibili } from "./compatibili_dialog";
const ServizioCard = ({
cardClassName,
header,
headerClassName,
content,
contentClassName,
}: {
cardClassName?: string;
header: React.ReactNode;
headerClassName?: string;
content: React.ReactNode;
contentClassName?: string;
}) => {
return (
<Card
className={cn(
"gap-0 rounded-none rounded-b-md border-primary border-t-0 pb-0",
cardClassName,
)}
>
<CardHeader
className={cn(
"flex flex-col items-start justify-between gap-3 space-y-0 px-4 pb-8 sm:flex-row",
headerClassName,
)}
>
{header}
</CardHeader>
<CardContent className={cn("space-y-4 p-4 pt-0", contentClassName)}>
{content}
</CardContent>
</Card>
);
};
export const ServizioContent = () => {
const { servizio, isAdmin, userId, servizioId } = useServizio();
const utils = api.useUtils();
const { mutate: updateServizio } = api.servizio.updateServizio.useMutation({
onError: (error) => {
toast.error(error.message);
},
onSuccess: async () => {
toast.success("Servizio modificato con successo");
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
await utils.servizio.getPacksSolution.invalidate({ servizioId });
},
});
const { mutate } = api.servizio.removeAnnuncioServizio.useMutation({
onError: (error) => {
console.error(error);
toast.error("Errore durante la rimozione dell'annuncio");
},
onSuccess: async () => {
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
await utils.servizio.getCompatibileAnnunci.invalidate({
servizioId,
});
toast.success("Annuncio rimosso");
},
});
const router = useRouter();
useEffect(() => {
const id = router.asPath.split("#")[1];
if (id) {
document.getElementById(id)?.scrollIntoView({
behavior: "smooth",
});
}
}, []);
if (servizio.isInterrotto) {
return (
<ServizioCard
cardClassName="gap-4"
content={
<>
<p className="font-medium text-lg">Servizio interrotto</p>
<p>
Non è possibile riutilizzare questo servizio, poichè è stato
interrotto.
</p>
</>
}
header={
<>
<div className="flex flex-col gap-4">
<CardTitle className="flex items-center gap-2 text-xl sm:text-2xl">
<Package className="size-6 text-primary" />
Cerco Affitto {servizio.tipologia}
</CardTitle>
<CardDescription className="space-y-2 pl-2 text-foreground/70">
<p>
aggiunto il{" "}
{servizio.created_at.toLocaleDateString("it", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
</p>
</CardDescription>
</div>
</>
}
/>
);
}
if (!servizio.isOkAcconto || servizio.decorrenza === null) {
return (
<ServizioCard
content={
<div className="flex w-full flex-col items-center justify-center gap-4">
<div className="flex w-full max-w-md flex-col items-center justify-center gap-4 text-center">
<AlarmClockSVG className="size-46" />
<h1 className="text-xl">Servizio non ancora attivato</h1>
<Link
aria-label="Attiva Servizio"
className="w-full"
href={`/servizio/onboard/${servizio.servizio_id}`}
>
<Button className="w-full text-lg" variant="success">
<span>Attiva il Servizio Ora!</span>
<ArrowRight />
</Button>
</Link>
{isAdmin && (
<Button
className="w-full"
onClick={() =>
updateServizio({
data: {
decorrenza: new Date(),
isOkAcconto: true,
},
servizioId: servizio.servizio_id,
})
}
variant="success"
>
Attiva Manualmente (Azione Admin)
</Button>
)}
</div>
{servizio.annunci.length > 0 && (
<div className="w-full space-y-2">
<p>
Sono già stati selezionati {servizio.annunci.length} annunci
che potrebbero interessarti.
</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={() => {
mutate({
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>
}
contentClassName={cn(!isAdmin && "gap-0")}
header={
<div className="flex w-full flex-col gap-2">
<ServizioActions />
</div>
}
headerClassName={cn(!isAdmin && "pb-0")}
/>
);
}
if (
new Date() >
add(servizio.decorrenza, {
days: 60,
}) &&
!servizio.isOkSaldo
) {
return (
<ServizioCard
cardClassName="gap-4"
content={
<>
<p className="font-medium text-lg">Servizio Scaduto</p>
<p>
Non è possibile riutilizzare questo servizio, poichè il periodo di
scelta degli annunci è scaduto (60 giorni dalla data di
decorrenza) e non è stato confermato nessun immobile.
</p>
</>
}
header={
<>
<ServizioActions />
</>
}
headerClassName="pb-0"
/>
);
}
const annuncioConfermato = servizio.annunci.filter(
(a) => a.accettato_conferma_at !== null,
);
if (servizio.isOkSaldo && annuncioConfermato.length > 0) {
return (
<ServizioCard
content={
<>
{annuncioConfermato.map((data) => {
return (
<ServizioAnnuncioProvider
data={data}
key={data.id}
servizioId={servizio.servizio_id}
userId={userId}
>
<AnnuncioCard />
</ServizioAnnuncioProvider>
);
})}
</>
}
header={
<>
<ServizioActions />
</>
}
/>
);
}
return (
<ServizioCard
cardClassName="gap-y-2"
content={
<>
{annuncioConfermato.length > 0 && (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<PackageCheck className="size-6 text-primary" />
<span className="font-medium">Saldi in attesa:</span>
</div>
{servizio.annunci
.filter((a) => a.accettato_conferma_at !== null)
.map((a) => (
<div
className="flex items-center gap-2 rounded-md bg-sky-400 p-2"
key={a.annunci_id}
>
<h3>
{a.codice} - confermato il:{" "}
{a.accettato_conferma_at &&
new Date(a.accettato_conferma_at).toLocaleDateString(
"it",
{
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
month: "2-digit",
year: "numeric",
},
)}
</h3>
<SaldoButton
annuncioId={a.annunci_id}
className="border-none"
variant={"outline"}
/>
</div>
))}
</div>
)}
<div className="flex flex-col gap-4">
{servizio.annunci.map((data) => {
return (
<ServizioAnnuncioProvider
data={data}
key={data.id}
servizioId={servizio.servizio_id}
userId={userId}
>
<AnnuncioCard />
</ServizioAnnuncioProvider>
);
})}
<div className="flex items-center justify-center">
<AnnunciCompatibili />
</div>
</div>
</>
}
header={
<>
<ServizioActions />
</>
}
headerClassName="pb-0"
/>
);
};

View file

@ -27,7 +27,7 @@ export const ContattiProprietarioModal = () => {
const { servizio } = useServizio();
const [open, setOpen] = useState(false);
const { data } = useServizioAnnuncio();
const { t } = useTranslation();
const { t, locale } = useTranslation();
const isUnlocked = data.open_contatti_at != null;
const needsDocuments =
@ -49,7 +49,7 @@ export const ContattiProprietarioModal = () => {
variant="success"
>
<CircleUser />
Contatti
{locale === "it" ? "Contatti" : "Contacts"}
</Button>
) : (
<Button
@ -58,7 +58,7 @@ export const ContattiProprietarioModal = () => {
variant="warning"
>
<CircleUser />
Ottieni i contatti
{locale === "it" ? "Ottieni i contatti" : "Get contacts"}
</Button>
)}
</CredenzaTrigger>
@ -82,7 +82,7 @@ export const ContattiProprietarioModal = () => {
};
const DocCheckpoint = () => {
const { t } = useTranslation();
const { t, locale } = useTranslation();
const { userId, servizio, isAdmin } = useServizio();
const utils = api.useUtils();
const { mutate, isPending } = api.users.editUserAnagrafica.useMutation({
@ -115,10 +115,15 @@ const DocCheckpoint = () => {
<CredenzaBody className="max-h-[80vh] overflow-auto pb-5">
<div className="flex flex-col flex-wrap gap-4">
<div>
<p className="font-semibold">Carica i tuoi documenti</p>
<p className="font-semibold">
{locale === "it"
? "Carica i tuoi documenti"
: "Upload your documents"}
</p>
<p>
se si ha una scansione unica fronte/retro dei doc, caricarla due
volte.
{locale === "it"
? "se si ha una scansione unica fronte/retro dei doc, caricarla due volte."
: "if you have a single front/back scan of the documents, upload it twice."}
</p>
</div>
@ -157,10 +162,11 @@ const DocCheckpoint = () => {
</div>
<p className="text-muted-foreground text-sm">
I tuoi dati saranno conservati solamente per la durata del servizio,
in conformità con la nostra{" "}
{locale === "it"
? "I tuoi dati saranno conservati solamente per la durata del servizio, in conformità con la nostra "
: "Your data will be stored only for the duration of the service, in accordance with our "}
<Link className="underline" href="/privacy-policy" target="_blank">
Informativa sulla Privacy
{locale === "it" ? "Informativa sulla Privacy" : "Privacy Policy"}
</Link>
</p>
</div>
@ -179,7 +185,7 @@ const DocCheckpoint = () => {
const PreUnlock = () => {
const { userId, servizioId } = useServizio();
const { annuncioId } = useServizioAnnuncio();
const { t } = useTranslation();
const { t, locale } = useTranslation();
const utils = api.useUtils();
@ -227,8 +233,11 @@ const PreUnlock = () => {
<div className="flex items-center gap-2 rounded-md bg-yellow-200 p-2">
<TriangleAlert className="size-8" />
<p>
Il proprietario riceverà la notifica alle ore 9.00{" "}
{currentHour >= 20 && "di domani"}
{locale === "it"
? "Il proprietario riceverà la notifica alle ore 9.00"
: "The owner will receive the notification at 9:00 AM"}{" "}
{currentHour >= 20 &&
(locale === "it" ? "di domani" : "tomorrow")}
</p>
</div>
)}

View file

@ -19,7 +19,8 @@ export const ServiziHeader = ({ userId }: { userId: UsersId }) => {
const [editData, setEditData] = useState<Servizio | undefined>(undefined);
return (
<div className="flex items-center justify-end">
<div className="flex w-full flex-wrap items-center justify-between gap-2">
<h3 className="font-semibold text-xl">Servizi cliente</h3>
<NewServizioModal
editData={editData}
open={open}

View file

@ -0,0 +1,448 @@
import { add } from "date-fns";
import {
ArrowRight,
CalendarClock,
CircleCheck,
CircleCheckBig,
CircleMinus,
ClockFading,
type LucideIcon,
PackageCheck,
Trash2,
} from "lucide-react";
import Link from "next/link";
import toast from "react-hot-toast";
import { cn } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import {
ServizioAnnuncioProvider,
ServizioProvider,
useServizio,
} from "~/providers/ServizioProvider";
import type { UsersId } from "~/schemas/public/Users";
import type { ServizioData } from "~/server/controllers/servizio.controller";
import { api } from "~/utils/api";
import { Confirm } from "../confirm";
import { AlarmClockSVG } from "../svgs";
import { Button } from "../ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../ui/card";
import { SaldoButton } from "./annuncio_actions";
import { AnnuncioCard, AnnuncioDisplay } from "./annuncio_card";
import { AnnunciCompatibili } from "./compatibili_dialog";
import {
AdminActions,
ServizioBasicActions,
ServizioPacksInfos,
} from "./servizio_actions";
export const ServizioList = ({
servizi,
isAdmin,
userId,
}: {
servizi: ServizioData[];
isAdmin: boolean;
userId: UsersId;
}) => {
const { t } = useTranslation();
if (servizi.length === 0) {
return (
<div className="m-4 rounded-md bg-yellow-100 p-4 text-yellow-900">
{t.servizio.nessun_servizio}
</div>
);
}
if (servizi.length === 1 && servizi[0]) {
return <Servizio isAdmin={isAdmin} servizio={servizi[0]} userId={userId} />;
}
return (
<div className="flex flex-col gap-4">
{servizi.map((servizio) => (
<ServizioLinkCard
isAdmin={isAdmin}
key={servizio.servizio_id}
servizio={servizio}
userId={userId}
/>
))}
</div>
);
};
const ServizioLinkCard = ({
servizio,
isAdmin,
userId,
}: {
servizio: ServizioData;
isAdmin: boolean;
userId: UsersId;
}) => {
const { t } = useTranslation();
let status: keyof typeof t.servizio.status = "attivo";
let StatusIcon: LucideIcon = CircleCheck;
if (servizio.isInterrotto) {
status = "interrotto";
StatusIcon = Trash2;
} else if (!servizio.isOkAcconto) {
status = "in_attesa";
StatusIcon = ClockFading;
} else if (servizio.decorrenza) {
const now = new Date();
const decorrenza = new Date(servizio.decorrenza);
if (now > add(decorrenza, { days: 60 }) && !servizio.isOkSaldo) {
status = "scaduto";
StatusIcon = CircleMinus;
}
if (servizio.isOkSaldo) {
status = "completato";
StatusIcon = CircleCheckBig;
}
}
return (
<Link
className="group"
href={
isAdmin
? `/area-riservata/admin/user-view/servizio/${userId}/${servizio.servizio_id}`
: `/area-riservata/servizio/${servizio.servizio_id}`
}
key={servizio.servizio_id}
>
<Card
className={cn(
status === "attivo" && "border-green-500",
status === "in_attesa" && "border-yellow-500",
status === "interrotto" && "border-red-500",
status === "scaduto" && "border-gray-500",
status === "completato" && "border-blue-500",
"transition-shadow hover:bg-muted/15 hover:shadow-md",
)}
>
<CardHeader>
<CardTitle className="group-hover:underline">
{t.servizio.servizio_titolo} {servizio.tipologia}
</CardTitle>
<CardDescription className="">
{t.servizio.servizio_creato_il}{" "}
{servizio.created_at.toLocaleDateString("it-IT")}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col flex-wrap gap-4 sm:flex-row sm:justify-between">
<div
className={cn(
"flex items-center gap-2",
status === "attivo" && "text-green-500",
status === "in_attesa" && "text-yellow-500",
status === "interrotto" && "text-red-500",
status === "scaduto" && "text-gray-500",
status === "completato" && "text-blue-500",
)}
>
<StatusIcon className="size-5" />
<span>{t.servizio.status[status]}</span>
</div>
<div className="flex items-center gap-2 rounded-md text-muted-foreground group-hover:text-foreground">
<span>{t.servizio.vai_al_servizio}</span>
<ArrowRight />
</div>
</CardContent>
</Card>
</Link>
);
};
export const Servizio = ({
servizio,
userId,
isAdmin,
}: {
servizio: ServizioData;
userId: UsersId;
isAdmin: boolean;
}) => {
return (
<ServizioProvider
isAdmin={isAdmin}
servizio={servizio}
servizioId={servizio.servizio_id}
userId={userId}
>
<Content />
</ServizioProvider>
);
};
const Content = () => {
const { servizio } = useServizio();
const { t } = useTranslation();
const dec_txt = servizio.decorrenza
? new Date(servizio.decorrenza).toLocaleDateString("it-IT")
: "N/A";
const end_txt = servizio.decorrenza
? new Date(add(servizio.decorrenza, { days: 60 })).toLocaleDateString(
"it-IT",
)
: "N/A";
return (
<div className="w-full space-y-4">
<div className="flex flex-col flex-wrap items-start justify-between gap-4 sm:flex-row">
<div>
<h3 className="font-semibold text-xl">
{t.servizio.servizio_titolo} {servizio.tipologia}
</h3>
<div className="flex flex-wrap items-center gap-2 text-muted-foreground text-sm">
<div className="flex items-center gap-2">
<CalendarClock className="size-5" />
<span>{t.servizio.durata_servizio}</span>
</div>
<span>
{dec_txt} - {end_txt}
</span>
</div>
</div>
<div className="flex flex-col gap-4">
<AdminActions />
<ServizioBasicActions className="w-full sm:justify-end" />
</div>
</div>
<ServizioPacksInfos />
<Main />
</div>
);
};
const Main = () => {
const { servizio, isAdmin, userId, servizioId } = useServizio();
const { t } = useTranslation();
const utils = api.useUtils();
const { mutate: updateServizio } = api.servizio.updateServizio.useMutation({
onError: (error) => {
toast.error(error.message);
},
onSuccess: async () => {
toast.success("Servizio modificato con successo");
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
await utils.servizio.getPacksSolution.invalidate({ servizioId });
},
});
const { mutate: removeAnnuncioServizio } =
api.servizio.removeAnnuncioServizio.useMutation({
onError: (error) => {
console.error(error);
toast.error("Errore durante la rimozione dell'annuncio");
},
onSuccess: async () => {
await utils.servizio.getAllServizioAnnunci.invalidate({ userId });
await utils.servizio.getCompatibileAnnunci.invalidate({
servizioId,
});
toast.success("Annuncio rimosso");
},
});
if (servizio.isInterrotto) {
return (
<>
<p className="font-medium text-lg">{t.servizio.servizio_interrotto}</p>
<p>{t.servizio.servizio_interrotto_desc}</p>
</>
);
}
if (!servizio.isOkAcconto || servizio.decorrenza === null) {
return (
<div className="flex w-full flex-col items-center justify-center gap-4">
<div className="flex w-full max-w-md flex-col items-center justify-center gap-4 text-center">
<AlarmClockSVG className="size-46" />
<h1 className="text-xl">{t.servizio.non_attivo}</h1>
<Link
aria-label="Attiva Servizio"
className="w-full"
href={`/servizio/onboard/${servizio.servizio_id}`}
>
<Button className="w-full text-lg" variant="success">
<span>{t.servizio.attiva_cta}</span>
<ArrowRight />
</Button>
</Link>
{isAdmin && (
<Button
className="w-full"
onClick={() =>
updateServizio({
data: {
decorrenza: new Date(),
isOkAcconto: true,
},
servizioId: servizio.servizio_id,
})
}
variant="success"
>
Attiva Manualmente (Azione Admin)
</Button>
)}
</div>
{servizio.annunci.length > 0 && (
<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>
);
}
if (
new Date() >
add(servizio.decorrenza, {
days: 60,
}) &&
!servizio.isOkSaldo
) {
return (
<>
<p className="font-medium text-lg">{t.servizio.scaduto}</p>
<p>{t.servizio.scaduto_desc}</p>
</>
);
}
const annuncioConfermato = servizio.annunci.filter(
(a) => a.accettato_conferma_at !== null,
);
if (servizio.isOkSaldo && annuncioConfermato.length > 0) {
return (
<>
{annuncioConfermato.map((data) => {
return (
<ServizioAnnuncioProvider
data={data}
key={data.id}
servizioId={servizio.servizio_id}
userId={userId}
>
<AnnuncioCard className="max-w-full" />
</ServizioAnnuncioProvider>
);
})}
</>
);
}
return (
<>
{annuncioConfermato.length > 0 && (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<PackageCheck className="size-6 text-primary" />
<span className="font-medium">{t.servizio.saldi_in_attesa}</span>
</div>
{servizio.annunci
.filter((a) => a.accettato_conferma_at !== null)
.map((a) => (
<div
className="flex items-center gap-2 rounded-md bg-sky-400 p-2"
key={a.annunci_id}
>
<h3>
{a.codice} -{" "}
{a.accettato_conferma_at &&
new Date(a.accettato_conferma_at).toLocaleDateString("it", {
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
month: "2-digit",
year: "numeric",
})}
</h3>
<SaldoButton
annuncioId={a.annunci_id}
className="border-none"
variant={"outline"}
/>
</div>
))}
</div>
)}
<div className="grid grid-flow-row grid-cols-1 gap-4 sm:grid-cols-2">
{servizio.annunci.map((data) => {
return (
<ServizioAnnuncioProvider
data={data}
key={data.id}
servizioId={servizio.servizio_id}
userId={userId}
>
<AnnuncioCard />
</ServizioAnnuncioProvider>
);
})}
{servizio.annunci.length % 2 === 1 && (
<div className="hidden size-full rounded-md border border-(--pattern-fg) bg-[repeating-linear-gradient(45deg,var(--pattern-fg)_0,var(--pattern-fg)_1px,transparent_0,transparent_50%)] bg-size-[10px_10px] bg-fixed opacity-10 [--pattern-fg:#696969] sm:block dark:[--pattern-fg:#e1e1e1]" />
)}
</div>
<div className="flex items-center justify-center">
<AnnunciCompatibili />
</div>
</>
);
};

View file

@ -1,18 +1,15 @@
import { add } from "date-fns";
import {
Bug,
CalendarClock,
ChevronDown,
ChevronUp,
ClockAlert,
Cog,
Euro,
ExternalLink,
Info,
Plus,
Search,
SlidersHorizontal,
Trash2,
UserCircle,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
@ -60,6 +57,7 @@ import {
} from "~/forms/FormEditServizioAdmin";
import { FormNewServizio, type FormValues } from "~/forms/FormNewServizio";
import { cn, formatCurrency } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import { useServizio } from "~/providers/ServizioProvider";
import type { AnnunciId } from "~/schemas/public/Annunci";
import type { ServizioServizioId } from "~/schemas/public/Servizio";
@ -67,121 +65,35 @@ import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import type { UsersId } from "~/schemas/public/Users";
import { api } from "~/utils/api";
import { Confirm } from "../confirm";
import { Progress } from "../ui/progress";
export const ServizioActions = () => {
export const AdminActions = () => {
const { servizio, isAdmin, userId } = useServizio();
if (!isAdmin) return null;
return (
<div className="flex w-full flex-col flex-wrap justify-between gap-3">
{isAdmin && (
<div className="flex items-center gap-3">
<span className="font-semibold">Azioni Admin:</span>
<AddAnnuncio servizioId={servizio.servizio_id} userId={userId} />
<EditServizioAdmin />
<Link
href={`/area-riservata/admin/user-view/ordini/${userId}?servizioId=${servizio.servizio_id}`}
>
<Button variant="warning">
<Search />
<span>Ordini</span>
</Button>
</Link>
</div>
)}
<ServizioInfos />
<div className="flex flex-wrap items-center gap-3">
<AddAnnuncio servizioId={servizio.servizio_id} userId={userId} />
<EditServizioAdmin />
<Link
href={`/area-riservata/admin/user-view/ordini/${userId}?servizioId=${servizio.servizio_id}`}
>
<Button variant="warning">
<Search />
<span>Ordini</span>
</Button>
</Link>
</div>
);
};
const ServizioInfos = () => {
const { servizio } = useServizio();
const [open, setOpen] = useState(false);
const openContattiAnnunci = servizio.annunci.filter(
(a) => a.open_contatti_at !== null,
);
if (!servizio.decorrenza) return null;
const dec_txt = new Date(servizio.decorrenza).toLocaleDateString("it-IT");
const end_txt = new Date(
add(servizio.decorrenza, { days: 60 }),
).toLocaleDateString("it-IT");
return (
<Collapsible className="space-y-2" onOpenChange={setOpen} open={open}>
<CollapsibleTrigger asChild>
{open ? (
<Button
className="w-full justify-between data-[state=open]:bg-accent dark:data-[state=open]:bg-input/50"
variant="outline"
>
<span className="flex items-center gap-2">
<Info />
<span>Informazioni servizio</span>
</span>
<span>
<ChevronUp />
</span>
</Button>
) : (
<Button className="w-full justify-between" variant="outline">
<div className="flex items-center gap-6">
<span className="flex items-center gap-2">
<UserCircle className="size-4 text-primary" />
<span>{openContattiAnnunci.length} / 10</span>
</span>
<span className="flex items-center gap-2">
<CalendarClock className="size-4 text-primary" />
<span>{end_txt}</span>
</span>
</div>
<div className="flex items-center gap-2">
<span>Dettagli</span>
<ChevronDown />
</div>
</Button>
)}
</CollapsibleTrigger>
<CollapsibleContent className="mb-4 flex w-full flex-col gap-4 rounded-md border bg-card p-2 sm:items-start sm:gap-6">
<ServizioBasicActions />
<div className="flex w-full flex-col gap-4 sm:flex-row sm:items-start sm:gap-6">
<div className="flex flex-2 flex-col gap-2 sm:w-1/2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<UserCircle className="text-primary" />
<span className="font-medium">Contatti sbloccati</span>
</div>
<span className="text-sm">{openContattiAnnunci.length} / 10</span>
</div>
<Progress className="h-2" value={openContattiAnnunci.length * 10} />
</div>
<div className="flex flex-col flex-wrap gap-2 sm:w-1/2 sm:flex-row sm:items-center">
<div className="flex items-center gap-2">
<CalendarClock className="text-primary" />
<span className="font-medium">Durata servizio:</span>
</div>
<span>
{dec_txt} - {end_txt}
</span>
</div>
</div>
<ServizioPacksInfos />
</CollapsibleContent>
</Collapsible>
);
};
const ServizioBasicActions = ({ className }: { className?: string }) => {
export const ServizioBasicActions = ({ className }: { className?: string }) => {
const { servizio, isAdmin } = useServizio();
const canUserEdit =
servizio.isOkAcconto &&
servizio.decorrenza !== null &&
const canUserEdit = servizio.isOkAcconto && servizio.decorrenza !== null;
/*&&
new Date() <= add(new Date(servizio.decorrenza), { days: 60 }) &&
!servizio.annunci.some((a) => a.user_confirmed_at !== null) &&
!servizio.isInterrotto &&
!servizio.annunci.some((a) => a.user_confirmed_at !== null);
!servizio.annunci.some((a) => a.user_confirmed_at !== null);*/
if (isAdmin || canUserEdit) {
return (
@ -196,79 +108,95 @@ const ServizioBasicActions = ({ className }: { className?: string }) => {
export const ServizioPacksInfos = () => {
const { servizio } = useServizio();
const { t } = useTranslation();
const { data: packs } = api.servizio.getPacksSolution.useQuery({
servizioId: servizio.servizio_id,
});
const title = "Prezzi:";
const sub = "(basati sui parametri di ricerca)";
if (!packs) return null;
return (
<div className="w-full space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold text-lg">{title}</h3>
<span className="text-muted-foreground text-sm">{sub}</span>
</div>
<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>
return (
<Collapsible className="flex flex-col gap-2">
<CollapsibleTrigger asChild>
<Button
className="h-fit w-fit flex-wrap items-center py-1"
variant="outline"
>
<Euro className="size-5" />
<h3 className="font-semibold text-lg">{t.servizio.prezzi.title}</h3>
<ChevronDown className="size-4" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent 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>
{servizio.tipologia === TipologiaPosizioneEnum.Stabile && (
<div className="flex flex-1 flex-col gap-2 rounded-md bg-secondary/50 p-2">
<>
<span className="font-medium">Consulenza</span>
<span className="text-muted-foreground text-sm">
Il prezzo della consulenza varia in base alla tipologia
contrattuale.{" "}
</span>
<span className="font-semibold">
<Link
className="flex items-center gap-2 underline underline-offset-2"
href={"/prezzi#contratti"}
target="_blank"
>
<span>Vedi prezzi contratti</span>
<ExternalLink className="size-4 stroke-foreground" />
</Link>
</span>
</>
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
);
};
@ -390,7 +318,7 @@ const AddAnnuncio = ({
const InterruzioneServizio = () => {
const { servizio, userId, isAdmin } = useServizio();
const { t } = useTranslation();
const beforeInterruzioneLimit =
servizio.decorrenza != null &&
new Date() <=
@ -423,28 +351,23 @@ const InterruzioneServizio = () => {
servizio.annunci.some((a) => a.user_confirmed_at !== null))
}
>
<ClockAlert /> <span>Interruzione</span>
<ClockAlert /> <span>{t.servizio.interruzione.btn}</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Sei sicuro di voler richiedere l&apos;interruzione?
</AlertDialogTitle>
<AlertDialogTitle>{t.servizio.interruzione.title}</AlertDialogTitle>
<AlertDialogDescription>
Questa azione è irreversibile e comporta la disattivazione dei
servizi attivati. Confermando l&apos;azione recedi dal servizio
secondo le condizioni contrattuali. Verrai notificato via email
della conferma dell&apos;avvenuta interruzione.
{t.servizio.interruzione.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annulla</AlertDialogCancel>
<AlertDialogCancel>{t.annulla}</AlertDialogCancel>
<AlertDialogAction
aria-label="Confirm Interruzione"
onClick={() => mutate({ servizioId: servizio.servizio_id })}
>
Continua
{t.conferma}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@ -454,6 +377,7 @@ const InterruzioneServizio = () => {
const EditParametri = () => {
const { servizioId, userId, isAdmin, servizio } = useServizio();
const { t } = useTranslation();
const [openEditParametri, setOpenEditParametri] = useState(false);
const { data, isLoading } = api.servizio.getServizio.useQuery({ servizioId });
const utils = api.useUtils();
@ -497,14 +421,14 @@ const EditParametri = () => {
variant="secondary"
>
<SlidersHorizontal />
Parametri
{t.servizio.parametri.btn}
</Button>
</CredenzaTrigger>
<CredenzaContent className="max-h-[90vh] w-full sm:max-w-5xl">
<CredenzaHeader>
<CredenzaTitle>Parametri di ricerca</CredenzaTitle>
<CredenzaTitle>{t.servizio.parametri.title}</CredenzaTitle>
<CredenzaDescription className="sr-only">
Parametri
{t.servizio.parametri.title}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody className="max-h-[80vh] w-full max-w-3xl overflow-y-auto pb-5 sm:max-w-5xl">

View file

@ -10,6 +10,7 @@ import {
import { Button } from "~/components/ui/button";
import type { UsersId } from "~/schemas/public/Users";
import { api } from "~/utils/api";
import { LoadingPage } from "../loading";
export const AnnunciRichiesti = ({ userId }: { userId: UsersId }) => {
const { data, isLoading } = api.intrests.getUserInterestsAnnunci.useQuery({
@ -23,7 +24,7 @@ export const AnnunciRichiesti = ({ userId }: { userId: UsersId }) => {
toast.success("Annuncio rimosso con successo");
},
});
if (isLoading) return <div>Loading...</div>;
if (isLoading) return <LoadingPage />;
if (!data || data.length === 0) return null;

View file

@ -1,104 +0,0 @@
import { add } from "date-fns";
import { Building, ChevronDown, ChevronUp } from "lucide-react";
import { useState } from "react";
import { LoadingPage } from "~/components/loading";
import { ServizioContent } from "~/components/servizio/main";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/collapsible";
import { cn } from "~/lib/utils";
import { ServizioProvider } from "~/providers/ServizioProvider";
import type { UsersId } from "~/schemas/public/Users";
import type { ServizioData } from "~/server/controllers/servizio.controller";
import { api } from "~/utils/api";
export const TabRicerca = ({
userId,
isAdmin,
}: {
userId: UsersId;
isAdmin: boolean;
}) => {
const { data, isLoading } = api.servizio.getAllServizioAnnunci.useQuery({
userId,
});
if (isLoading) return <LoadingPage />;
if (!data || data.length === 0) {
return (
<div className="m-4 rounded-md bg-yellow-100 p-4 text-yellow-900">
Nessun servizio attualmente inserito
</div>
);
}
return (
<div className="flex flex-col gap-8">
{data.map((servizio) => {
return (
<Row
defaultOpen={
!servizio.isInterrotto ||
new Date() < add(servizio.created_at, { days: 60 })
}
isAdmin={isAdmin}
key={servizio.servizio_id}
servizio={servizio}
userId={userId}
/>
);
})}
</div>
);
};
const Row = ({
servizio,
userId,
isAdmin,
defaultOpen = false,
}: {
servizio: ServizioData;
userId: UsersId;
isAdmin: boolean;
defaultOpen: boolean;
}) => {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<ServizioProvider
isAdmin={isAdmin}
servizio={servizio}
servizioId={servizio.servizio_id}
userId={userId}
>
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
<div
className={cn(
"flex items-center rounded-md border border-primary bg-secondary py-2 shadow-sm hover:shadow-md",
isOpen && "rounded-b-none",
)}
>
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-4 px-3 sm:px-4">
<div className="flex items-center gap-2 font-semibold">
<Building className="size-6 stroke-foreground" />
<span className="sm:text-lg">Affitto {servizio.tipologia}</span>
<span className="text-muted-foreground text-xs sm:text-sm">
{`${servizio.created_at.toLocaleDateString("it")}`}
</span>
</div>
<div className="float-end flex items-center justify-start pl-2">
{isOpen ? <ChevronUp /> : <ChevronDown />}
</div>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="mt-0 overflow-hidden transition-all duration-300 ease-in-out data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
<ServizioContent />
</CollapsibleContent>
</Collapsible>
</ServizioProvider>
);
};

View file

@ -1299,6 +1299,47 @@ The Stable Rent service, ideal for those with demonstrable work references and f
salva: "Save",
seleziona_placeholder: "Select . . .",
servizio: {
status: {
attivo: "Active",
in_attesa: "Pending activation",
interrotto: "Interrupted",
scaduto: "Expired",
completato: "Completed",
},
dettaglio_servizio: "Service Details",
nessun_servizio: "No service currently available",
servizio_titolo: "Rental Service",
servizio_creato_il: "Created on:",
vai_al_servizio: "Go to Service",
durata_servizio: "Service Duration:",
servizio_interrotto: "Service Interrupted",
servizio_interrotto_desc:
"This service cannot be reused as it has been interrupted.",
non_attivo: "Service not yet activated",
attiva_cta: "Activate the Service Now!",
per_te_1: "Already selected",
per_te_2: "ads that might interest you.",
scaduto: "Service Expired",
scaduto_desc:
"This service cannot be reused as the ad selection period has expired (60 days from the start date) and no property has been confirmed.",
saldi_in_attesa: "Pending Balances:",
confermato_il: "Confirmed on:",
prezzi: {
title: "Service Prices:",
sub: "(based on search parameters)",
consulenza: "Consultation",
consulenza_desc: "The consultation price varies based on the contract type.",
gotoprezzi: "See contract prices",
},
parametri:{
title: "Search Parameters",
btn: "Parameters",
},
interruzione: {
btn: "Interruption",
title: "Are you sure you want to request the interruption?",
description: "This action is irreversible and will deactivate the activated services. By confirming the action, you withdraw from the service according to the contractual conditions. You will be notified via email of the confirmation of the interruption.",
},
conferma: {
accetto: "I accept the terms and conditions.",
causale: "Payment reference:",

View file

@ -855,6 +855,7 @@ export const it: LangDict = {
newPw: "Nuova password",
pwAggiornata: "Password aggiornata",
},
parametri: {
altriparams_desc: "Seleziona gli altri parametri",
altriparams_label: "Altri parametri",
@ -1297,6 +1298,47 @@ export const it: LangDict = {
salva: "Salva",
seleziona_placeholder: "Seleziona...",
servizio: {
status: {
attivo: "Attivo",
interrotto: "Interrotto",
in_attesa: "In Attesa di Attivazione",
scaduto: "Scaduto",
completato: "Completato",
},
dettaglio_servizio: "Dettaglio Servizio",
nessun_servizio: "Nessun servizio attualmente inserito",
servizio_titolo: "Servizio Affitto",
servizio_creato_il: "Creato il:",
vai_al_servizio: "Vai al Servizio",
durata_servizio: "Durata servizio:",
servizio_interrotto: "Servizio interrotto",
servizio_interrotto_desc:
"Non è possibile riutilizzare questo servizio, poichè è stato interrotto.",
non_attivo: "Servizio non ancora attivato",
attiva_cta: "Attiva il Servizio Ora!",
per_te_1: "Sono già stati selezionati",
per_te_2: "annunci che potrebbero interessarti.",
scaduto: "Servizio scaduto",
scaduto_desc:
"Non è possibile riutilizzare questo servizio, poichè il periodo di scelta degli annunci è scaduto (60 giorni dalla data di decorrenza) e non è stato confermato nessun immobile.",
saldi_in_attesa: "Saldi in attesa:",
confermato_il: "confermato il:",
prezzi: {
title: "Prezzi del servizio:",
sub: "(basati sui parametri di ricerca)",
consulenza: "Consulenza",
consulenza_desc: "Il prezzo della consulenza varia in base alla tipologia contrattuale.",
gotoprezzi: "Vedi prezzi contratti",
},
parametri:{
title: "Parametri di ricerca",
btn: "Parametri",
},
interruzione: {
btn: "Interruzione",
title: "Sei sicuro di voler richiedere l'interruzione?",
description: "Questa azione è irreversibile e comporta la disattivazione dei servizi attivati. Confermando l'azione recedi dal servizio secondo le condizioni contrattuali. Verrai notificato via email della conferma dell'avvenuta interruzione.",
},
conferma: {
accetto: "Accetto i termini e condizioni.",
causale: "Causale:",

View file

@ -769,6 +769,45 @@ export type LangDict = {
};
};
servizio: {
status: {
attivo: string;
interrotto: string;
in_attesa: string;
scaduto: string;
completato: string;
};
dettaglio_servizio: string;
nessun_servizio: string;
servizio_titolo: string;
servizio_creato_il: string;
vai_al_servizio: string;
durata_servizio: string;
servizio_interrotto: string;
servizio_interrotto_desc: string;
non_attivo: string;
attiva_cta: string;
per_te_1: string;
per_te_2: string;
scaduto: string;
scaduto_desc: string;
saldi_in_attesa: string;
confermato_il: string;
prezzi: {
title: string;
sub: string;
consulenza: string;
consulenza_desc: string;
gotoprezzi: string;
};
parametri: {
title: string;
btn: string;
};
interruzione: {
btn: string;
title: string;
description: string;
};
conferma: {
titolo: string;
descrizione: string;

View file

@ -1,12 +1,14 @@
import type { GetServerSideProps } from "next";
import { AreaRiservataLayoutUserView } from "~/components/Layout";
import { LoadingPage } from "~/components/loading";
import { ServiziHeader } from "~/components/servizio/servizi_header";
import { ServizioList } from "~/components/servizio/servizio";
import { AnnunciRichiesti } from "~/components/servizio/servizio_annunci_accordions";
import { TabRicerca } from "~/components/tables/ricerca-table";
import type { NextPageWithLayout } from "~/pages/_app";
import type { UsersId } from "~/schemas/public/Users";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { zUserId } from "~/server/utils/zod_types";
import { api } from "~/utils/api";
type RicercaUserProps = {
userId: UsersId;
@ -15,11 +17,17 @@ type RicercaUserProps = {
const RicercaUser: NextPageWithLayout<RicercaUserProps> = ({
userId,
}: RicercaUserProps) => {
const { data, isLoading } = api.servizio.getAllServizioAnnunci.useQuery({
userId,
});
if (isLoading) return <LoadingPage />;
return (
<div className="flex flex-col gap-5">
<ServiziHeader userId={userId} />
<AnnunciRichiesti userId={userId} />
<TabRicerca isAdmin userId={userId} />
<ServizioList isAdmin servizi={data || []} userId={userId} />
</div>
);
};

View file

@ -0,0 +1,108 @@
import type { GetServerSideProps } from "next";
import { AreaRiservataLayoutUserView } from "~/components/Layout";
import { LoadingPage } from "~/components/loading";
import { Servizio } from "~/components/servizio/servizio";
import { Status500 } from "~/components/status-page";
import type { NextPageWithLayout } from "~/pages/_app";
import type { ServizioServizioId } from "~/schemas/public/Servizio";
import type { UsersId } from "~/schemas/public/Users";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { zServizioId, zUserId } from "~/server/utils/zod_types";
import { api } from "~/utils/api";
type ServizioUserProps = {
servizioId: ServizioServizioId;
userId: UsersId;
};
const ServizioUser: NextPageWithLayout<ServizioUserProps> = ({
servizioId,
userId,
}: ServizioUserProps) => {
const { data, isLoading } = api.servizio.getServizioData.useQuery({
servizioId,
});
if (isLoading) {
return <LoadingPage />;
}
if (!data) {
return <Status500 />;
}
return (
<div className="mx-auto max-w-8xl grow space-y-4">
<Servizio
isAdmin={true}
key={data.servizio_id}
servizio={data}
userId={userId}
/>
</div>
);
};
export default ServizioUser;
export const getServerSideProps = (async (context) => {
const user_id = context.params?.slug?.[0];
const servizio_id = context.params?.slug?.[1];
if (typeof servizio_id !== "string" || typeof user_id !== "string") {
console.error("Error: servizioId or userId is not a string");
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}
const parsedUserId = zUserId.safeParse(user_id);
if (!parsedUserId.success) {
console.error("Error parsing userId", parsedUserId.error);
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}
const parsedServizioId = zServizioId.safeParse(servizio_id);
if (!parsedServizioId.success) {
console.error("Error parsing servizioId", parsedServizioId.error);
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}
const servizioId = parsedServizioId.data;
const userId = parsedUserId.data;
const access_token = context.req.cookies.access_token;
const helper = await TrpcAuthedFetchingIstance({ access_token });
if (helper) {
await helper.trpc.servizio.getServizio.prefetch({
servizioId,
});
return {
props: {
servizioId,
trpcState: helper.trpc.dehydrate(),
userId,
},
};
}
return {
redirect: {
destination: `/area-riservata/admin/utenti`,
permanent: false,
},
};
}) satisfies GetServerSideProps<ServizioUserProps>;
ServizioUser.getLayout = function getLayout(page) {
return <AreaRiservataLayoutUserView>{page}</AreaRiservataLayoutUserView>;
};

View file

@ -0,0 +1,99 @@
import type { GetServerSideProps } from "next";
import { AreaRiservataLayout } from "~/components/Layout";
import { LoadingPage } from "~/components/loading";
import { Servizio } from "~/components/servizio/servizio";
import { Status500 } from "~/components/status-page";
import type { NextPageWithLayout } from "~/pages/_app";
import { useTranslation } from "~/providers/I18nProvider";
import { useEnforcedSession } from "~/providers/SessionProvider";
import type { ServizioServizioId } from "~/schemas/public/Servizio";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { zServizioId } from "~/server/utils/zod_types";
import { api } from "~/utils/api";
type ServizioPageProps = {
servizioId: ServizioServizioId;
};
const ServizioPage: NextPageWithLayout<ServizioPageProps> = ({
servizioId,
}: ServizioPageProps) => {
const { user } = useEnforcedSession();
const { t } = useTranslation();
const { data, isLoading } = api.servizio.getServizioData.useQuery({
servizioId,
});
if (isLoading) {
return <LoadingPage />;
}
if (!data) {
return <Status500 />;
}
return (
<div className="mx-auto w-full max-w-8xl grow space-y-4 p-4">
<div className="flex justify-between">
<h3 className="font-bold text-2xl">{t.servizio.dettaglio_servizio}</h3>
</div>
<Servizio
isAdmin={false}
key={data.servizio_id}
servizio={data}
userId={user.id}
/>
</div>
);
};
export default ServizioPage;
export const getServerSideProps = (async (context) => {
const servizio_id = context.params?.servizioId;
if (typeof servizio_id !== "string") {
console.error("Error: servizioId is not a string");
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}
const parsedServizioId = zServizioId.safeParse(servizio_id);
if (!parsedServizioId.success) {
console.error("Error parsing servizioId", parsedServizioId.error);
return {
redirect: {
destination: "/500",
permanent: false,
},
};
}
const servizioId = parsedServizioId.data;
const access_token = context.req.cookies.access_token;
const helper = await TrpcAuthedFetchingIstance({ access_token });
if (helper) {
await helper.trpc.servizio.getServizio.prefetch({
servizioId,
});
return {
props: {
servizioId,
trpcState: helper.trpc.dehydrate(),
},
};
}
return {
redirect: {
destination: `/area-riservata/admin/utenti`,
permanent: false,
},
};
}) satisfies GetServerSideProps<ServizioPageProps>;
ServizioPage.getLayout = function getLayout(page) {
return <AreaRiservataLayout>{page}</AreaRiservataLayout>;
};

View file

@ -35,6 +35,7 @@ import {
getServiziByUserId,
getServizioAnnuncio,
getServizioById,
getServizioDataById,
getServizioOrdini,
getServizioPagamentoData,
getUserPagamenti,
@ -130,6 +131,15 @@ export const servizioRouter = createTRPCRouter({
.query(async ({ input }) => {
return await getAllServizioAnnunci(input.userId);
}),
getServizioData: protectedProcedure
.input(
z.object({
servizioId: zServizioId,
}),
)
.query(async ({ input }) => {
return await getServizioDataById(input.servizioId);
}),
getAnnunciAvailableToAdd: adminProcedure
.input(
z.object({

View file

@ -5,7 +5,7 @@ import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
import { z } from "zod/v4";
import type { PurchaseData } from "~/components/acquisto_receipt";
import { env } from "~/env";
import type { AnnunciId } from "~/schemas/public/Annunci";
import type { Annunci, AnnunciId } from "~/schemas/public/Annunci";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum";
@ -20,13 +20,18 @@ import type {
} from "~/schemas/public/Prezziario";
import type {
NewServizio,
Servizio,
ServizioServizioId,
ServizioUpdate,
} from "~/schemas/public/Servizio";
import type { ServizioAnnunciUpdate } from "~/schemas/public/ServizioAnnunci";
import type {
ServizioAnnunci,
ServizioAnnunciUpdate,
} from "~/schemas/public/ServizioAnnunci";
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import type { Users, UsersId } from "~/schemas/public/Users";
import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica";
import type { UsersStorageUserStorageId } from "~/schemas/public/UsersStorage";
import type { Context } from "~/server/api/trpc";
import {
addEventToQueue,
@ -41,7 +46,11 @@ import {
import { db } from "~/server/db";
import { NewMail, type NewMailProps } from "~/server/services/mailer";
import { getUser } from "~/server/services/user.service";
import { withImages, withVideos } from "~/utils/kysely-helper";
import {
parseNullableDateString,
withImages,
withVideos,
} from "~/utils/kysely-helper";
import { GetUserInterests } from "../services/interests.service";
import { getPrezziarioByIdHandler } from "../services/prezziario.service";
import type { AnnuncioRicerca } from "./annunci.controller";
@ -703,12 +712,74 @@ export const setupSaldoConsulenzaServizio = async ({
}
};
export type ServizioData = Awaited<
ReturnType<typeof getAllServizioAnnunci>
>[number];
export const getAllServizioAnnunci = async (userId: UsersId) => {
type DocRef = {
storageId: string;
ref: UsersStorageUserStorageId | null;
};
export type ServizioData = Pick<
Servizio,
| "servizio_id"
| "created_at"
| "decorrenza"
| "tipologia"
| "budget"
| "permanenza"
| "isInterrotto"
| "isOkAcconto"
| "isOkSaldo"
| "isOkConsulenza"
| "skipPayment"
| "skipControlloDoc"
| "skipDocMotivazione"
| "n_adulti"
| "n_minori"
| "entromese"
> & {
doc_personale_fronte: DocRef | null;
doc_personale_retro: DocRef | null;
doc_motivazione: DocRef | null;
annunci: (ServizioAnnunci &
Pick<
Annunci,
| "id"
| "codice"
| "comune"
| "provincia"
| "prezzo"
| "consegna"
| "numero_camere"
| "mq"
| "tipo"
| "titolo_it"
| "titolo_en"
| "desc_en"
| "desc_it"
| "modificato_il"
| "stato"
| "external_videos"
| "media_updated_at"
| "homepage"
| "web"
| "disponibile_da"
| "persone"
| "permanenza"
> & {
images: {
img: string;
thumb: string;
}[];
videos: {
thumb: string;
video: string;
}[];
})[];
};
export const getAllServizioAnnunci = async (
userId: UsersId,
): Promise<ServizioData[]> => {
try {
return await db
const data = await db
.selectFrom("servizio")
.where("user_id", "=", userId)
.leftJoin(
@ -820,9 +891,36 @@ export const getAllServizioAnnunci = async (userId: UsersId) => {
),
).as("annunci"),
])
.orderBy("servizio.created_at", "desc")
.execute();
const formattedData = data.map((item) => {
return {
...item,
annunci: item.annunci.map((annuncio) => ({
...annuncio,
created_at: new Date(annuncio.created_at),
open_contatti_at: parseNullableDateString(annuncio.open_contatti_at),
user_confirmed_at: parseNullableDateString(
annuncio.user_confirmed_at,
),
accettato_conferma_at: parseNullableDateString(
annuncio.accettato_conferma_at,
),
contratto_decorrenza: parseNullableDateString(
annuncio.contratto_decorrenza,
),
contratto_scadenza: parseNullableDateString(
annuncio.contratto_scadenza,
),
modificato_il: parseNullableDateString(annuncio.modificato_il),
disponibile_da: parseNullableDateString(annuncio.disponibile_da),
media_updated_at: parseNullableDateString(annuncio.media_updated_at),
})),
};
});
return formattedData;
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
@ -831,6 +929,160 @@ export const getAllServizioAnnunci = async (userId: UsersId) => {
}
};
export const getServizioDataById = async (
servizioId: ServizioServizioId,
): Promise<ServizioData> => {
try {
const data = await db
.selectFrom("servizio")
.leftJoin(
"users_anagrafica",
"servizio.user_id",
"users_anagrafica.userid",
)
.select((eb) => [
"servizio.servizio_id",
"servizio.created_at",
"servizio.decorrenza",
"servizio.tipologia",
"servizio.budget",
"servizio.permanenza",
"servizio.isInterrotto",
"servizio.isOkAcconto",
"servizio.isOkSaldo",
"servizio.isOkConsulenza",
"servizio.skipPayment",
"servizio.skipControlloDoc",
"servizio.skipDocMotivazione",
"servizio.n_adulti",
"servizio.n_minori",
"servizio.entromese",
jsonObjectFrom(
eb
.selectFrom("users_storage")
.select([
"users_storage.storageId as storageId",
"users_anagrafica.doc_personale_fronte_ref as ref",
])
.whereRef(
"users_storage.user_storage_id",
"=",
"users_anagrafica.doc_personale_fronte_ref",
),
).as("doc_personale_fronte"),
jsonObjectFrom(
eb
.selectFrom("users_storage")
.select([
"users_storage.storageId as storageId",
"users_anagrafica.doc_personale_retro_ref as ref",
])
.whereRef(
"users_storage.user_storage_id",
"=",
"users_anagrafica.doc_personale_retro_ref",
),
).as("doc_personale_retro"),
jsonObjectFrom(
eb
.selectFrom("users_storage")
.select([
"users_storage.storageId as storageId",
"servizio.doc_motivazione_ref as ref",
])
.whereRef(
"users_storage.user_storage_id",
"=",
"servizio.doc_motivazione_ref",
),
).as("doc_motivazione"),
jsonArrayFrom(
eb
.selectFrom("servizio_annunci")
.selectAll("servizio_annunci")
.innerJoin("annunci", "annunci.id", "servizio_annunci.annunci_id")
.select((_eb) => [
"annunci.id",
"annunci.codice",
"annunci.comune",
"annunci.provincia",
"annunci.prezzo",
"annunci.consegna",
"annunci.numero_camere",
"annunci.mq",
"annunci.tipo",
"annunci.titolo_it",
"annunci.titolo_en",
"annunci.desc_en",
"annunci.desc_it",
"annunci.modificato_il",
"annunci.stato",
"annunci.external_videos",
"annunci.media_updated_at",
"annunci.homepage",
"annunci.web",
"annunci.disponibile_da",
"annunci.persone",
"annunci.permanenza",
withImages(),
withVideos(),
])
//ignora annunci che non sono disponibili
.where("annunci.web", "=", true)
.where("annunci.stato", "!=", "Sospeso")
.orderBy("servizio_annunci.user_confirmed_at", (ob) =>
ob.asc().nullsLast(),
)
.orderBy("servizio_annunci.open_contatti_at", (ob) =>
ob.asc().nullsLast(),
)
.orderBy("servizio_annunci.created_at", "asc")
.whereRef(
"servizio_annunci.servizio_id",
"=",
"servizio.servizio_id",
),
).as("annunci"),
])
.orderBy("servizio.created_at", "desc")
.where("servizio.servizio_id", "=", servizioId)
.executeTakeFirst();
if (!data) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Servizio not found",
});
}
const formattedData = {
...data,
annunci: data.annunci.map((annuncio) => ({
...annuncio,
created_at: new Date(annuncio.created_at),
open_contatti_at: parseNullableDateString(annuncio.open_contatti_at),
user_confirmed_at: parseNullableDateString(annuncio.user_confirmed_at),
accettato_conferma_at: parseNullableDateString(
annuncio.accettato_conferma_at,
),
contratto_decorrenza: parseNullableDateString(
annuncio.contratto_decorrenza,
),
contratto_scadenza: parseNullableDateString(
annuncio.contratto_scadenza,
),
modificato_il: parseNullableDateString(annuncio.modificato_il),
disponibile_da: parseNullableDateString(annuncio.disponibile_da),
media_updated_at: parseNullableDateString(annuncio.media_updated_at),
})),
};
return formattedData;
} catch (e) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Servizio annunci not found: ${(e as Error).message}`,
});
}
};
export const addServizioAnnunci = async ({
servizioId,
annunci,

View file

@ -50,6 +50,7 @@ export const db = new Kysely<Database>({
new HandleEmptyInListsPlugin({
strategy: replaceWithNoncontingentExpression,
}),
//new ParseJSONResultsPlugin(),
],
});

View file

@ -1,3 +1,4 @@
import { isValid } from "date-fns";
import { expressionBuilder } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
import type Database from "~/schemas/Database";
@ -52,3 +53,11 @@ export function withVideos() {
.orderBy("videos_refs.ordine", "asc"),
).as("videos");
}
export function parseNullableDateString(str: string | null): Date | null {
if (str === null) {
return null;
}
const date = new Date(str);
return isValid(date) ? date : null;
}