feat: add IncrociDialog component and integrate into admin and user interactions

This commit is contained in:
Marco Pedone 2026-04-16 16:31:25 +02:00
parent c7dc508821
commit eef0030676
8 changed files with 276 additions and 111 deletions

View file

@ -1,8 +1,9 @@
import { ArrowRight, BadgePlus } from "lucide-react";
import { ArrowRight, BadgePlus, Edit, Printer } from "lucide-react";
import Link from "next/link";
import toast from "react-hot-toast";
import { ContattoAnnuncio } from "~/components/annuncio-interactions/contatto_modal";
import LoadingButton from "~/components/custom_ui/loading-button";
import { IncrociDialog } from "~/components/servizio/incroci";
import { Button } from "~/components/ui/button";
import { useAnnuncio } from "~/providers/AnnuncioProvider";
import { useTranslation } from "~/providers/I18nProvider";
@ -14,9 +15,13 @@ import { api } from "~/utils/api";
export const AnnuncioInteractions = ({ disabled }: { disabled?: boolean }) => {
const { user } = useSession();
const { id, tipo } = useAnnuncio();
const { locale } = useTranslation();
if (!user) return null;
if (user.isAdmin) {
return <AdminInteractions />;
}
if (disabled) {
return (
<div className="w-full rounded-md bg-red-500 p-2 text-center font-semibold text-base text-white">
@ -26,6 +31,39 @@ export const AnnuncioInteractions = ({ disabled }: { disabled?: boolean }) => {
</div>
);
}
return <UserInteractions />;
};
const AdminInteractions = () => {
const { id } = useAnnuncio();
return (
<div className="flex w-full flex-col gap-6">
<Link
className="w-full"
href={`/area-riservata/admin/edit-annuncio/${id}`}
>
<Button className="w-full" type="button">
<Edit /> Modifica annuncio
</Button>
</Link>
<Link
className="w-full"
href={`/area-riservata/scheda-annuncio-stampa/${id}`}
target="_blank"
>
<Button className="w-full" type="button" variant="outline">
<Printer /> Scheda immobile
</Button>
</Link>
<IncrociDialog annuncioId={id} />
</div>
);
};
const UserInteractions = () => {
const { user } = useSession();
const { id, tipo } = useAnnuncio();
const { locale } = useTranslation();
if (!user) return null;
return (
<div className="flex w-full flex-col justify-around gap-3 md:gap-5">
{user ? (
@ -41,25 +79,11 @@ export const AnnuncioInteractions = ({ disabled }: { disabled?: boolean }) => {
) : (
<div className="flex w-full flex-col gap-2">
<ContattoAnnuncio />
{/* <span className="w-full text-center text-sm">oppure</span>
<Link
aria-label="Login"
href={{
pathname: "/login",
query: { redirect: router.asPath },
}}
>
<Button className="w-full">
<LogIn />
<span>Accedi se hai un account</span>
</Button>
</Link> */}
</div>
)}
</div>
);
};
const ServizioInteraction = ({
userId,
tipologia,

View file

@ -13,6 +13,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import toast from "react-hot-toast";
import { IncrociDialog } from "~/components/servizio/incroci";
import {
AlertDialog,
AlertDialogCancel,
@ -73,6 +74,10 @@ export const AdminActions = () => {
<h3 className="ml-1 font-semibold">Azioni Admin:</h3>
<div className="flex w-full max-w-fit flex-wrap justify-start gap-3">
<AnnuncioContatti data={data} />
<IncrociDialog
annuncioId={data.id}
props={{ className: "w-fit", size: "sm" }}
/>
<AnnuncioConfermaPopover
annuncioId={annuncioId}
servizioId={servizioId}

View file

@ -1,7 +1,8 @@
import type { Table } from "@tanstack/react-table";
import { format } from "date-fns";
import { NotebookPen, Toolbox } from "lucide-react";
import { Eye, NotebookPen, Toolbox } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { type RefObject, useState } from "react";
import toast from "react-hot-toast";
import LoadingButton from "~/components/custom_ui/loading-button";
@ -11,12 +12,17 @@ import {
AnnuncioContatti,
UserActions,
} from "~/components/servizio/annuncio_actions";
import { IncrociDialog } from "~/components/servizio/incroci";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Textarea } from "~/components/ui/textarea";
import { useMediaQuery } from "~/hooks/use-media-query";
import { handleConsegna } from "~/lib/annuncio_details";
import { getStorageUrl } from "~/lib/storage_utils";
import { MutationPageRestore } from "~/lib/tableUtils";
import {
MutationPageRestore,
type onMutate,
type onSettled,
} from "~/lib/tableUtils";
import { cn, formatCurrency } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import { useServizio, useServizioAnnuncio } from "~/providers/ServizioProvider";
@ -125,21 +131,8 @@ export const AnnuncioCardRichieste = ({
tableRef: RefObject<Table<RichiesteData> | null>;
data: ServizioAnnuncioData;
}) => {
const [newNote, setNewNote] = useState<string | null>(data.note);
const utils = api.useUtils();
const { onMutate, onSettled } = MutationPageRestore(tableRef);
const { mutate, isPending } = api.servizio.updateServizioAnnunci.useMutation({
onMutate,
onSettled,
onSuccess: async () => {
toast.success("Note aggiornate");
await utils.servizio.invalidate();
},
onError: () => {
toast.error("Errore nell'aggiornamento note");
},
});
const isDesktop = useMediaQuery("(min-width: 40rem)");
return (
<Card className="w-full max-w-7xl gap-2 rounded-md border-none bg-muted py-3">
<CardHeader className="flex flex-row flex-wrap items-center justify-between space-y-0 px-3 pb-2">
@ -164,6 +157,15 @@ export const AnnuncioCardRichieste = ({
</div>
</div>
<div className="flex flex-wrap gap-4">
<Link href={`/annuncio/${data.codice}`} target="_blank">
<Button size="sm" type="button" variant="outline">
<Eye /> vista pubblica
</Button>
</Link>
<IncrociDialog
annuncioId={data.id}
props={{ className: "w-fit" , size: "sm"}}
/>
<div>
<AnnuncioContatti data={data} />
</div>
@ -176,48 +178,13 @@ export const AnnuncioCardRichieste = ({
</div>
</div>
</div>
<Collapsible
className={cn("space-y-2", isDesktop && "relative -top-10")}
open={isDesktop || undefined}
>
<CollapsibleTrigger asChild>
<Button
className={cn(isDesktop && "cursor-default")}
size="sm"
variant={isDesktop ? "flat" : "outline"}
>
<NotebookPen />
<span>Annotazioni</span>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="">
<div className="flex w-full flex-col gap-2">
<Textarea
className="min-h-64"
id={`note-${data.id}`}
onChange={(e) => setNewNote(e.target.value)}
placeholder=""
value={newNote || ""}
<AnnuncioNote
annuncioId={data.id}
note={data.note}
onMutate={onMutate}
onSettled={onSettled}
servizioId={data.servizio_id}
/>
<LoadingButton
disabled={newNote === data.note}
loading={isPending}
onClick={() => {
mutate({
annuncioId: data.id,
servizioId: data.servizio_id,
data: { note: newNote },
});
}}
type="button"
variant="success"
>
Salva
</LoadingButton>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</CardContent>
</Card>
@ -228,14 +195,20 @@ const AnnuncioNote = ({
servizioId,
annuncioId,
note,
onMutate,
onSettled,
}: {
servizioId: ServizioServizioId;
annuncioId: AnnunciId;
note: string | null;
onMutate?: onMutate;
onSettled?: onSettled;
}) => {
const [newNote, setNewNote] = useState<string | null>(note);
const utils = api.useUtils();
const { mutate, isPending } = api.servizio.updateServizioAnnunci.useMutation({
onMutate,
onSettled,
onSuccess: async () => {
toast.success("Note aggiornate");
await utils.servizio.invalidate();
@ -245,12 +218,36 @@ const AnnuncioNote = ({
},
});
const isDesktop = useMediaQuery("(min-width: 40rem)");
if (isDesktop) {
return (
<Collapsible
className={cn("space-y-2", isDesktop && "relative -top-10")}
open={isDesktop || undefined}
<div className="flex w-full flex-col gap-2">
<Textarea
className="min-h-64"
id={`note-${annuncioId}`}
onChange={(e) => setNewNote(e.target.value)}
placeholder=""
value={newNote || ""}
/>
<LoadingButton
disabled={newNote === note}
loading={isPending}
onClick={() => {
mutate({
annuncioId,
servizioId,
data: { note: newNote },
});
}}
type="button"
variant="success"
>
Salva
</LoadingButton>
</div>
);
}
return (
<Collapsible className={cn("space-y-2", isDesktop && "relative -top-10")}>
<CollapsibleTrigger asChild>
<Button
className={cn(isDesktop && "cursor-default")}

View file

@ -0,0 +1,115 @@
import { format } from "date-fns";
import { Waypoints } from "lucide-react";
import Link from "next/link";
import { Button, type ButtonProps } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { UserAvatar } from "~/components/user_avatar";
import { cn } from "~/lib/utils";
import type { AnnunciId } from "~/schemas/public/Annunci";
import { api } from "~/utils/api";
export const IncrociDialog = ({
annuncioId,
props,
}: {
annuncioId: AnnunciId;
props?: ButtonProps;
}) => {
return (
<Dialog>
<DialogTrigger asChild>
<Button
className={cn("w-full", props?.className)}
variant={props?.variant || "info"}
{...props}
>
<Waypoints /> Incroci
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Lista Incroci</DialogTitle>
<DialogDescription>
Lista utenti che hanno ricevuto i contatti di questo annuncio
</DialogDescription>
</DialogHeader>
<IncrociTable annuncioId={annuncioId} />
</DialogContent>
</Dialog>
);
};
const IncrociTable = ({ annuncioId }: { annuncioId: AnnunciId }) => {
const { data: incroci, isLoading } = api.annunci.getIncrociAnnuncio.useQuery({
annuncioId,
});
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="">Utente</TableHead>
<TableHead>Data</TableHead>
<TableHead>Note</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<p>Caricamento...</p>
) : (
incroci?.map((incrocio) => (
<TableRow key={incrocio.servizio_id}>
<TableCell>
<Link
aria-label="Visualizza Utente"
className="flex items-center gap-2"
href={`/area-riservata/admin/user-view/ricerca/${incrocio.user_id}`}
>
<UserAvatar
className="size-8"
userId={incrocio.user_id}
username={incrocio.username}
/>
<span className="font-semibold text-sm">
{incrocio.username}
</span>
</Link>
</TableCell>
<TableCell>
<div>
<p>
Aggiunto: {format(incrocio.created_at, "dd/MM/yyyy HH:mm")}
</p>
<p>
Aperto:{" "}
{incrocio.open_contatti_at
? format(incrocio.open_contatti_at, "dd/MM/yyyy HH:mm")
: "Non aperto"}
</p>
</div>
</TableCell>
<TableCell className="whitespace-pre-wrap">
{incrocio.note}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
);
};

View file

@ -2,7 +2,20 @@
import type { Table } from "@tanstack/react-table";
import type { RefObject } from "react";
export const MutationPageRestore = (tableRef: RefObject<Table<any> | null>) => {
export type onMutate = () => {
pageIdx: number | undefined;
};
export type onSettled = (
_a: any,
_b: any,
_c: any,
context?: { pageIdx?: number },
) => void;
export const MutationPageRestore = (
tableRef: RefObject<Table<any> | null>,
): { onMutate: onMutate; onSettled: onSettled } => {
return {
onMutate: () => {
const pageIdx = tableRef.current?.getState().pagination.pageIndex; // 0 based index

View file

@ -7,9 +7,7 @@ import {
Car,
Clock,
Copy,
Edit,
ExternalLink,
Printer,
Ruler,
Share2,
TrafficCone,
@ -64,7 +62,6 @@ import { cn } from "~/lib/utils";
import { AnnuncioContext, useAnnuncio } from "~/providers/AnnuncioProvider";
import { useTranslation } from "~/providers/I18nProvider";
import { buildRicercaUrl } from "~/providers/RicercaProvider";
import { useSession } from "~/providers/SessionProvider";
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import type { AnnuncioData } from "~/server/controllers/annunci.controller";
import { generateSSGHelper } from "~/server/utils/ssgHelper";
@ -121,7 +118,6 @@ const AnnuncioDettaglio: NextPage<AnnuncioProps> = ({
};
const AnnuncioView = ({ data, flag }: AnnuncioProps) => {
const { user } = useSession();
useStaleImageReload();
const isDesktop = useMediaQuery("(min-width: 768px)");
@ -146,33 +142,7 @@ const AnnuncioView = ({ data, flag }: AnnuncioProps) => {
<div className="mb-6 flex flex-col md:mb-0 md:w-1/3">
<div className="flex w-full flex-col flex-nowrap items-center justify-center gap-6">
<CardInfos />
{user?.isAdmin ? (
<>
<Link
className="w-full"
href={`/area-riservata/admin/edit-annuncio/${data.id}`}
>
<Button className="w-full" type="button">
<Edit /> Modifica annuncio
</Button>
</Link>
<Link
className="w-full"
href={`/area-riservata/scheda-annuncio-stampa/${data.id}`}
target="_blank"
>
<Button
className="w-full"
type="button"
variant="outline"
>
<Printer /> Scheda immobile
</Button>
</Link>
</>
) : (
<AnnuncioInteractions disabled={flag === "true"} />
)}
<ComeFunziona />
{data.tipo &&
(data.tipo === "Transitorio" || data.tipo === "Stabile") && (

View file

@ -22,6 +22,7 @@ import {
removeAnnuncioMedia,
type VideoToRemove,
} from "~/server/controllers/annunci.controller";
import { getIncrociAnnuncio } from "~/server/controllers/servizio.controller";
import { db } from "~/server/db";
import {
invalidateCache,
@ -188,4 +189,13 @@ export const annunciRouter = createTRPCRouter({
...input,
});
}),
getIncrociAnnuncio: protectedProcedure
.input(
z.object({
annuncioId: zAnnuncioId,
}),
)
.query(async ({ input }) => {
return await getIncrociAnnuncio(input.annuncioId);
}),
});

View file

@ -1282,3 +1282,34 @@ export const getRichieste = async (): Promise<RichiesteData[]> => {
});
}
};
export const getIncrociAnnuncio = async (annuncioId: AnnunciId) => {
try {
const data = await db
.selectFrom("servizio_annunci")
.innerJoin(
"servizio",
"servizio.servizio_id",
"servizio_annunci.servizio_id",
)
.innerJoin("users", "users.id", "servizio.user_id")
.where("servizio_annunci.annunci_id", "=", annuncioId)
.select([
"servizio_annunci.open_contatti_at",
"servizio_annunci.created_at",
"servizio.servizio_id",
"servizio.user_id",
"users.username",
"servizio_annunci.note",
])
.orderBy("servizio_annunci.created_at", "desc")
.execute();
return data;
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error fetching incroci annuncio: ${(e as Error).message}`,
});
}
};