feat: enhance RichiesteTable with improved status and decorrenza handling

- Updated RichiesteTable to display decorrenza with tooltip based on status.
- Enhanced status display with badges indicating various states (e.g., Attesa attivazione, Interrotto).
- Added Popover for displaying Annunci details in RichiesteTable.
- Refactored API to fetch user interests and related announcements.
- Introduced Queue component to display email events for users.
- Updated translations to include new "Requests" section in admin navigation.
- Improved database queries for fetching user interests and announcements.
This commit is contained in:
Marco Pedone 2026-03-25 17:30:33 +01:00
parent 8d07c82e7e
commit 7adb33f485
12 changed files with 659 additions and 380 deletions

View file

@ -15,7 +15,10 @@ import { getStorageUrl } from "~/lib/storage_utils";
import { cn, formatCurrency } from "~/lib/utils"; import { cn, formatCurrency } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider"; import { useTranslation } from "~/providers/I18nProvider";
import { useServizio, useServizioAnnuncio } from "~/providers/ServizioProvider"; import { useServizio, useServizioAnnuncio } from "~/providers/ServizioProvider";
import type { AnnunciId } from "~/schemas/public/Annunci";
import type { ServizioServizioId } from "~/schemas/public/Servizio";
import type { AnnuncioRicerca } from "~/server/controllers/annunci.controller"; import type { AnnuncioRicerca } from "~/server/controllers/annunci.controller";
import type { ServizioAnnuncioData } from "~/server/controllers/servizio.controller";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { import {
@ -30,15 +33,9 @@ export const AnnuncioCard = ({ className }: { className?: string }) => {
const { data } = useServizioAnnuncio(); const { data } = useServizioAnnuncio();
return ( return (
<BasicAnnuncioCard <AnnuncioCardContent
className={className} className={className}
data={{ data={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,
}}
interactions={ interactions={
isAdmin ? ( isAdmin ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@ -60,20 +57,22 @@ export const AnnuncioCard = ({ className }: { className?: string }) => {
<UserActions /> <UserActions />
) )
} }
isAdmin={isAdmin}
/> />
); );
}; };
export const BasicAnnuncioCard = ({ export const AnnuncioCardContent = ({
interactions, interactions,
className, className,
data, data,
isAdmin,
}: { }: {
interactions?: React.ReactNode; interactions?: React.ReactNode;
className?: string; className?: string;
data: AnnuncioRicerca; data: ServizioAnnuncioData;
isAdmin: boolean;
}) => { }) => {
const { isAdmin } = useServizio();
return ( return (
<Card <Card
className={cn( className={cn(
@ -99,18 +98,29 @@ export const BasicAnnuncioCard = ({
<div className="flex w-full flex-col gap-4">{interactions}</div> <div className="flex w-full flex-col gap-4">{interactions}</div>
</div> </div>
{isAdmin && <AnnuncioNote />} {isAdmin && (
<AnnuncioNote
annuncioId={data.id}
note={data.note}
servizioId={data.servizio_id}
/>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
); );
}; };
const AnnuncioNote = () => { const AnnuncioNote = ({
const { servizioId } = useServizio(); servizioId,
const { data } = useServizioAnnuncio(); annuncioId,
note,
const [newNote, setNewNote] = useState(data.note || ""); }: {
servizioId: ServizioServizioId;
annuncioId: AnnunciId;
note: string | null;
}) => {
const [newNote, setNewNote] = useState<string | null>(note);
const utils = api.useUtils(); const utils = api.useUtils();
const { mutate, isPending } = api.servizio.updateServizioAnnunci.useMutation({ const { mutate, isPending } = api.servizio.updateServizioAnnunci.useMutation({
onSuccess: async () => { onSuccess: async () => {
@ -142,21 +152,22 @@ const AnnuncioNote = () => {
<div className="flex w-full flex-col gap-2"> <div className="flex w-full flex-col gap-2">
<Textarea <Textarea
className="min-h-64" className="min-h-64"
id={`note-${data.id}`} id={`note-${annuncioId}`}
onChange={(e) => setNewNote(e.target.value)} onChange={(e) => setNewNote(e.target.value)}
placeholder="" placeholder=""
value={newNote} value={newNote || ""}
/> />
<LoadingButton <LoadingButton
disabled={newNote === data.note} disabled={newNote === note}
loading={isPending} loading={isPending}
onClick={() => onClick={() => {
mutate({ mutate({
annuncioId: data.id, annuncioId,
servizioId, servizioId,
data: { note: newNote }, data: { note: newNote },
}) });
} }}
type="button"
variant="success" variant="success"
> >
Salva Salva

View file

@ -12,6 +12,7 @@ import {
type LucideIcon, type LucideIcon,
MousePointerClick, MousePointerClick,
PackageCheck, PackageCheck,
SearchX,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@ -160,7 +161,7 @@ const ServizioLinkCard = ({
if (servizio.isInterrotto) { if (servizio.isInterrotto) {
status = "interrotto"; status = "interrotto";
StatusIcon = Trash2; StatusIcon = SearchX;
} else if (!servizio.isOkAcconto) { } else if (!servizio.isOkAcconto) {
status = "in_attesa"; status = "in_attesa";
StatusIcon = ClockFading; StatusIcon = ClockFading;
@ -332,7 +333,7 @@ const Content = () => {
}; };
const Main = () => { const Main = () => {
const { servizio, isAdmin } = useServizio(); const { servizio, isAdmin, userId } = useServizio();
const { t } = useTranslation(); const { t } = useTranslation();
const utils = api.useUtils(); const utils = api.useUtils();
const { mutate: attivazioneSenzaPagamento } = const { mutate: attivazioneSenzaPagamento } =
@ -349,10 +350,28 @@ const Main = () => {
// se interrotto, mostra avvisos // se interrotto, mostra avvisos
if (servizio.isInterrotto) { if (servizio.isInterrotto) {
return ( return (
<> <div className="flex w-full flex-col items-center justify-center gap-4 py-10 text-center">
<p className="font-medium text-lg">{t.servizio.servizio_interrotto}</p> <div className="flex size-14 items-center justify-center rounded-full bg-red-100 dark:bg-red-500/10">
<p>{t.servizio.servizio_interrotto_desc}</p> <SearchX className="size-7 text-red-500 dark:text-red-400" />
</> </div>
<div className="flex flex-col gap-1">
<p className="font-semibold text-xl">
{t.servizio.servizio_interrotto}
</p>
<p className="max-w-sm text-muted-foreground text-sm">
{t.servizio.servizio_interrotto_desc}
</p>
</div>
<Link
href={
isAdmin
? `/area-riservata/admin/user-view/ricerca/${userId}`
: "/area-riservata/dashboard"
}
>
<Button variant="secondary">Torna alla lista dei servizi</Button>
</Link>
</div>
); );
} }
// mostra onboarding // mostra onboarding

View file

@ -1,6 +1,6 @@
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { BasicAnnuncioCard } from "~/components/servizio/annuncio_card"; import { AnnuncioDisplay } from "~/components/servizio/annuncio_card";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
@ -8,6 +8,8 @@ import {
AccordionTrigger, AccordionTrigger,
} from "~/components/ui/accordion"; } from "~/components/ui/accordion";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { cn } from "~/lib/utils";
import type { ServizioServizioId } from "~/schemas/public/Servizio"; import type { ServizioServizioId } from "~/schemas/public/Servizio";
import type { UsersId } from "~/schemas/public/Users"; import type { UsersId } from "~/schemas/public/Users";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
@ -64,11 +66,24 @@ export const AnnunciRichiesti = ({
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{data.map((a) => { {data.map((a) => {
return ( return (
<BasicAnnuncioCard <Card
className="w-full border-2 border-pink-500" className="w-full max-w-7xl gap-2 rounded-md border-2 border-pink-500 bg-muted py-3"
data={a} key={a.id}
interactions={ >
<> <CardHeader className="flex flex-row flex-wrap items-center justify-between space-y-0 px-3 pb-2">
<CardTitle className="text-2xl">
Annuncio {a.codice}
</CardTitle>
</CardHeader>
<CardContent className="px-3">
<div className="grid grid-cols-1 gap-4">
<div
className={cn("@container flex w-full flex-col gap-4")}
id="container"
>
<AnnuncioDisplay data={a} />
<div className="flex w-full flex-col gap-4">
{servizioId && ( {servizioId && (
<Button <Button
onClick={() => { onClick={() => {
@ -89,10 +104,11 @@ export const AnnunciRichiesti = ({
Rimuovi Rimuovi
<Trash2 className="size-4" /> <Trash2 className="size-4" />
</Button> </Button>
</> </div>
} </div>
key={a.id} </div>
/> </CardContent>
</Card>
); );
})} })}
{data.length % 2 === 1 && ( {data.length % 2 === 1 && (

View file

@ -1,6 +1,16 @@
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { ExternalLink } from "lucide-react"; import { format } from "date-fns";
import {
CircleCheck,
CircleCheckBig,
CircleEllipsis,
ClockFading,
ExternalLink,
SearchX,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { AnnuncioCardContent } from "~/components/servizio/annuncio_card";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { DataTable } from "~/components/ui/data-table"; import { DataTable } from "~/components/ui/data-table";
import { DataTableColumnHeader } from "~/components/ui/dataTable-header"; import { DataTableColumnHeader } from "~/components/ui/dataTable-header";
@ -8,6 +18,21 @@ import type {
PinnedFiltro, PinnedFiltro,
SearchFiltro, SearchFiltro,
} from "~/components/ui/dataTable-toolbar"; } from "~/components/ui/dataTable-toolbar";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "~/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { UserAvatar } from "~/components/user_avatar";
import { cn } from "~/lib/utils";
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum"; import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import type { RichiesteData } from "~/server/controllers/servizio.controller"; import type { RichiesteData } from "~/server/controllers/servizio.controller";
@ -17,13 +42,12 @@ type RichiesteTableProps = {
export const RichiesteTable = ({ data }: RichiesteTableProps) => { export const RichiesteTable = ({ data }: RichiesteTableProps) => {
const tabledata = data; const tabledata = data;
const columns_titles = { const columns_titles = {
id: "ID Ordine", id: "ID Ordine",
username: "Utente", username: "Utente",
created_at: "Creato il", decorrenza: "Decorrenza",
status: "Status", status: "Status",
tipo: "Tipo", tipologia: "Tipo",
annunci: "Annunci", annunci: "Annunci",
actions: "Azioni", actions: "Azioni",
}; };
@ -39,10 +63,6 @@ export const RichiesteTable = ({ data }: RichiesteTableProps) => {
{ label: "Stabile", value: TipologiaPosizioneEnum.Stabile }, { label: "Stabile", value: TipologiaPosizioneEnum.Stabile },
]; ];
// const [selectedOrdine, setSelectedOrdine] = useState<OrdiniOrdineId | null>(
// null,
// );
type Column = (typeof tabledata)[number]; type Column = (typeof tabledata)[number];
const columns: ColumnDef<Column>[] = [ const columns: ColumnDef<Column>[] = [
@ -58,10 +78,17 @@ export const RichiesteTable = ({ data }: RichiesteTableProps) => {
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<Link <Link
aria-label="Visualizza Utente" className="flex items-center gap-2"
href={`/area-riservata/admin/user-view/ricerca/${row.original.user_id}`} href={`/area-riservata/admin/user-view/ricerca/${row.original.user_id}`}
> >
<Button size="sm">{row.original.username}</Button> <UserAvatar
className="size-8"
userId={row.original.user_id}
username={row.original.username}
/>
<span className="font-semibold text-sm">
{row.original.username}
</span>
</Link> </Link>
); );
}, },
@ -74,16 +101,39 @@ export const RichiesteTable = ({ data }: RichiesteTableProps) => {
), ),
}, },
{ {
accessorKey: "created_at", accessorKey: "decorrenza",
cell: ({ row }) => { cell: ({ row }) => {
const date = row.getValue("created_at"); let decorrenzaColor = "";
return new Date(date as Date).toLocaleDateString(); let decorrenzaTooltip = "";
if (!row.original.isOkAcconto || !row.original.decorrenza) {
decorrenzaColor = "text-muted-foreground";
decorrenzaTooltip = "Attesa di attivazione";
} else if (row.original.isInterrotto) {
decorrenzaColor = "text-destructive";
decorrenzaTooltip = "Servizio interrotto";
} else if (row.original.isOkAcconto && !row.original.isOkSaldo) {
decorrenzaColor = "text-warning";
decorrenzaTooltip = "Attesa di saldo";
}
return (
<Tooltip>
<TooltipTrigger className={decorrenzaColor}>
{row.original.decorrenza
? format(row.original.decorrenza, "dd/MM")
: "---"}
</TooltipTrigger>
<TooltipContent>
<p>{decorrenzaTooltip}</p>
</TooltipContent>
</Tooltip>
);
}, },
enableHiding: false, enableHiding: false,
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader <DataTableColumnHeader
column={column} column={column}
title={columns_titles.created_at} title={columns_titles.decorrenza}
/> />
), ),
sortingFn: "datetime", sortingFn: "datetime",
@ -91,37 +141,94 @@ export const RichiesteTable = ({ data }: RichiesteTableProps) => {
{ {
accessorKey: "tipologia", accessorKey: "tipologia",
cell: ({ row }) => { cell: ({ row }) => {
return row.original.tipologia.toString(); const { tipologia } = row.original;
return (
<Badge
className={cn(
"px-1.5",
tipologia === TipologiaPosizioneEnum.Transitorio
? "border-transitorio bg-transitorio/15"
: "border-stabile bg-stabile/15",
)}
variant="outline"
>
{tipologia.toString()}
</Badge>
);
}, },
filterFn: (row, id, value) => { filterFn: (row, id, value) => {
return value.includes(row.getValue(id)); return value.includes(row.getValue(id));
}, },
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader column={column} title={columns_titles.tipo} /> <DataTableColumnHeader
column={column}
title={columns_titles.tipologia}
/>
), ),
}, },
{ {
accessorKey: "status", accessorKey: "status",
cell: ({ row }) => { cell: ({ row }) => {
let badgeClass = "";
let contents: React.ReactNode;
const annunci = row.original.annunci_servizio; const annunci = row.original.annunci_servizio;
const awaitingConferma = annunci.some( const awaitingConferma = annunci.some(
(a) => a.user_confirmed_at !== null && !a.hasConfermaAdmin, (a) => a.user_confirmed_at !== null && !a.hasConfermaAdmin,
); );
if (awaitingConferma) { const hasConfermaAdmin = annunci.some((a) => a.hasConfermaAdmin);
return "Conferma in corso"; if (row.original.isInterrotto) {
} badgeClass = "border-destructive bg-destructive/10 text-destructive";
contents = (
if (row.original.isOkSaldo) { <>
return "Saldo pagato"; <SearchX className="stroke-red-500 dark:stroke-red-400" />
} Interrotto
if (row.original.isOkAcconto) { </>
return "Acconto pagato"; );
} } else if (!row.original.onboardOk) {
if (!row.original.onboardOk) { badgeClass = "border-yellow-500 bg-yellow-500/10 text-yellow-600";
return "Attesa di attivazione"; contents = (
<>
<ClockFading className="stroke-yellow-500 dark:stroke-yellow-400" />
Attesa attivazione
</>
);
} else if (awaitingConferma) {
badgeClass = "border-sky-500 bg-sky-500/10 text-sky-500";
contents = (
<>
<CircleEllipsis className="stroke-sky-500 dark:stroke-sky-400" />
In Conferma
</>
);
} else if (row.original.isOkSaldo && hasConfermaAdmin) {
badgeClass = "border-blue-500 bg-blue-500/10 text-blue-500";
contents = (
<>
<CircleCheckBig className="stroke-blue-500 dark:stroke-blue-400" />
Saldo pagato
</>
);
} else if (row.original.isOkAcconto) {
badgeClass = "border-green-500 bg-green-500/10 text-green-500";
contents = (
<>
<CircleCheck className="stroke-green-500 dark:stroke-green-400" />
Attivo
</>
);
} }
return (
<Badge
className={cn(
"flex items-center px-1.5 text-muted-foreground [&>svg]:size-4",
badgeClass,
)}
variant="outline"
>
{contents}
</Badge>
);
}, },
filterFn: (row, id, value) => { filterFn: (row, id, value) => {
@ -135,7 +242,43 @@ export const RichiesteTable = ({ data }: RichiesteTableProps) => {
accessorKey: "annunci_servizio", accessorKey: "annunci_servizio",
cell: ({ row }) => { cell: ({ row }) => {
const annunci = row.original.annunci_servizio; const annunci = row.original.annunci_servizio;
return <span>{annunci.length}</span>; return (
<Popover>
<PopoverTrigger asChild>
<Button size="sm" variant="outline">
{annunci.length} Annunci
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-4xl">
<PopoverHeader>
<PopoverTitle>Annunci Inseriti</PopoverTitle>
<PopoverDescription className="sr-only">
Desc
</PopoverDescription>
</PopoverHeader>
<div className="flex flex-col gap-4 divide-y">
{annunci.map((a) => (
<AnnuncioCardContent
data={a}
interactions={
<div>
<p>
Visto contatti:{" "}
{a.open_contatti_at
? format(a.open_contatti_at, "dd/MM/yyyy HH:mm")
: "Non ancora visto"}
</p>
{/*TODO CAPIRE COSA AGGIUNGERE */}
</div>
}
isAdmin
key={a.annunci_id}
/>
))}
</div>
</PopoverContent>
</Popover>
);
}, },
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader column={column} title={columns_titles.annunci} /> <DataTableColumnHeader column={column} title={columns_titles.annunci} />
@ -143,16 +286,16 @@ export const RichiesteTable = ({ data }: RichiesteTableProps) => {
enableSorting: false, enableSorting: false,
}, },
{ {
cell: () => { cell: ({ row }) => {
return ( return (
<Button <Link
aria-label="Apri Dettagli Ordine" href={`/area-riservata/admin/user-view/servizio/${row.original.user_id}/${row.original.servizio_id}`}
//onClick={() => {}} target="_blank"
size="sm"
variant="outline"
> >
<Button aria-label="Apri servizio" size="sm" variant="outline">
<ExternalLink className="size-5" /> <ExternalLink className="size-5" />
</Button> </Button>
</Link>
); );
}, },
enableHiding: false, enableHiding: false,
@ -173,7 +316,7 @@ export const RichiesteTable = ({ data }: RichiesteTableProps) => {
title: "Stato Ordine", title: "Stato Ordine",
}, },
{ {
columnName: "tipo", columnName: "tipologia",
options: type_options, options: type_options,
title: "Tipo Ordine", title: "Tipo Ordine",
}, },
@ -185,16 +328,10 @@ export const RichiesteTable = ({ data }: RichiesteTableProps) => {
columns={columns} columns={columns}
columns_titles={columns_titles} columns_titles={columns_titles}
data={tabledata} data={tabledata}
defaultSort={[{ desc: true, id: "created_at" }]} defaultSort={[{ desc: true, id: "decorrenza" }]}
pinnedFiltri={pinnedFiltri} pinnedFiltri={pinnedFiltri}
searchColumn={searchFiltro} searchColumn={searchFiltro}
/> />
{/* <AdminWrapperOrdiniModal
ordineId={selectedOrdine}
setSelected={setSelectedOrdine}
/>
*/}
</div> </div>
); );
}; };

View file

@ -2,7 +2,6 @@
import { Popover as PopoverPrimitive } from "radix-ui"; import { Popover as PopoverPrimitive } from "radix-ui";
import type * as React from "react"; import type * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
function Popover({ function Popover({
@ -28,7 +27,7 @@ function PopoverContent({
<PopoverPrimitive.Content <PopoverPrimitive.Content
align={align} align={align}
className={cn( className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in", "data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-popover-foreground text-sm shadow-md outline-hidden ring-1 ring-foreground/10 duration-100 data-closed:animate-out data-open:animate-in",
className, className,
)} )}
data-slot="popover-content" data-slot="popover-content"
@ -45,4 +44,45 @@ function PopoverAnchor({
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />; return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
} }
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-col gap-0.5 text-sm", className)}
data-slot="popover-header"
{...props}
/>
);
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
className={cn("cn-font-heading font-medium", className)}
data-slot="popover-title"
{...props}
/>
);
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
className={cn("text-muted-foreground", className)}
data-slot="popover-description"
{...props}
/>
);
}
export {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
};

View file

@ -657,6 +657,7 @@ export const en: LangDict = {
items: [ items: [
{ href: "/area-riservata/admin/potenziali", title: "Potenziali" }, { href: "/area-riservata/admin/potenziali", title: "Potenziali" },
{ href: "/area-riservata/admin/utenti", title: "Users" }, { href: "/area-riservata/admin/utenti", title: "Users" },
{ href: "/area-riservata/admin/richieste", title: "Requests" },
{ href: "/area-riservata/admin/chats", title: "Chat List" }, { href: "/area-riservata/admin/chats", title: "Chat List" },
{ href: "/area-riservata/admin/ordini", title: "Orders" }, { href: "/area-riservata/admin/ordini", title: "Orders" },
{ href: "/area-riservata/admin/incroci", title: "Incroci" }, { href: "/area-riservata/admin/incroci", title: "Incroci" },

View file

@ -662,6 +662,7 @@ export const it: LangDict = {
items: [ items: [
{ href: "/area-riservata/admin/potenziali", title: "Potenziali" }, { href: "/area-riservata/admin/potenziali", title: "Potenziali" },
{ href: "/area-riservata/admin/utenti", title: "Utenti" }, { href: "/area-riservata/admin/utenti", title: "Utenti" },
{ href: "/area-riservata/admin/richieste", title: "Richieste" },
{ href: "/area-riservata/admin/chats", title: "Lista Chat" }, { href: "/area-riservata/admin/chats", title: "Lista Chat" },
{ href: "/area-riservata/admin/ordini", title: "Ordini" }, { href: "/area-riservata/admin/ordini", title: "Ordini" },
{ href: "/area-riservata/admin/incroci", title: "Incroci" }, { href: "/area-riservata/admin/incroci", title: "Incroci" },

View file

@ -74,7 +74,7 @@ const ComunicazioniUser: NextPageWithLayout<ComunicazioniUserProps> = ({
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> </div>
<Queue userId={userId} />
<EmailAccordion isAdmin={user.isAdmin} userId={userId} /> <EmailAccordion isAdmin={user.isAdmin} userId={userId} />
</main> </main>
); );
@ -119,3 +119,13 @@ export const getServerSideProps = (async (context) => {
ComunicazioniUser.getLayout = function getLayout(page) { ComunicazioniUser.getLayout = function getLayout(page) {
return <AreaRiservataLayoutUserView>{page}</AreaRiservataLayoutUserView>; return <AreaRiservataLayoutUserView>{page}</AreaRiservataLayoutUserView>;
}; };
const Queue = ({ userId }: { userId: UsersId }) => {
const { data } = api.comunicazioni.getUserQueuedEmails.useQuery({ userId });
console.log("Queue data", data);
return (
<div>
<h2>Eventi email in coda per user {userId}</h2>
</div>
);
};

View file

@ -6,13 +6,13 @@ import { db } from "~/server/db";
import { import {
AddIntrest, AddIntrest,
GetUserInterests, GetUserInterests,
GetUserInterestsAnnunci,
HasUserInterest, HasUserInterest,
RemoveInterest, RemoveInterest,
} from "~/server/services/interests.service"; } from "~/server/services/interests.service";
import { NewMail } from "~/server/services/mailer"; import { NewMail } from "~/server/services/mailer";
import { findUser_byId_MINI } from "~/server/services/user.service"; import { findUser_byId_MINI } from "~/server/services/user.service";
import { zAnnuncioId, zUserId } from "~/server/utils/zod_types"; import { zAnnuncioId, zUserId } from "~/server/utils/zod_types";
import { withImages, withVideos } from "~/utils/kysely-helper";
export const intrestsRouter = createTRPCRouter({ export const intrestsRouter = createTRPCRouter({
addIntrest: protectedProcedure addIntrest: protectedProcedure
@ -85,49 +85,7 @@ export const intrestsRouter = createTRPCRouter({
}), }),
) )
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
try { return await GetUserInterestsAnnunci(input.userId || ctx.session.id);
const interests = await GetUserInterests(
input.userId || ctx.session.id,
);
if (!interests || interests.length === 0) {
return [];
}
const annunci = await db
.selectFrom("annunci")
.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",
withImages(),
withVideos(),
])
.where("web", "=", true)
.where("stato", "!=", "Sospeso")
.where("annunci.id", "in", interests)
.execute();
return annunci;
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore durante il recupero degli annunci: ${(e as Error).message}`,
});
}
}), }),
hasUserInterest: protectedProcedure hasUserInterest: protectedProcedure
.input( .input(

View file

@ -1,11 +1,13 @@
import type { Override } from "TypeHelpers.type"; import type { Override } from "TypeHelpers.type";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { add, set } from "date-fns"; import { add, set } from "date-fns";
import { sql } from "kysely";
import type { Attachment } from "nodemailer/lib/mailer"; import type { Attachment } from "nodemailer/lib/mailer";
import type { AnnunciId } from "~/schemas/public/Annunci"; import type { AnnunciId } from "~/schemas/public/Annunci";
import type { NewEventQueue } from "~/schemas/public/EventQueue"; import type { NewEventQueue } from "~/schemas/public/EventQueue";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini"; import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe"; import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
import type { UsersId } from "~/schemas/public/Users";
import { import {
type MinimalSMSData, type MinimalSMSData,
SendMessage, SendMessage,
@ -155,3 +157,20 @@ export const processEvents = async () => {
} }
return { message: `${events.length} events processed` }; return { message: `${events.length} events processed` };
}; };
export const userEmailQueue = async (userId: UsersId) => {
try {
const events = await db
.selectFrom("event_queue")
.selectAll()
.where(sql`data->>'tipo'`, "=", "email")
.where(sql`data->'data'->>'userId'`, "=", userId)
.execute();
return events;
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error while getting user events from queue, ${(e as Error).message}`,
});
}
};

View file

@ -1,4 +1,3 @@
import type { Override } from "TypeHelpers.type";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { add, isBefore } from "date-fns"; import { add, isBefore } from "date-fns";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
@ -7,6 +6,7 @@ import type { PurchaseData } from "~/components/acquisto_receipt";
import { env } from "~/env"; import { env } from "~/env";
import type { PaymentType } from "~/i18n/stripe"; import type { PaymentType } from "~/i18n/stripe";
import { parseDBComune, parseDBNazione } from "~/lib/catasto"; import { parseDBComune, parseDBNazione } from "~/lib/catasto";
import { withParsedDates } from "~/lib/dateParserJsonKysely";
import type { Annunci, AnnunciId } from "~/schemas/public/Annunci"; import type { Annunci, AnnunciId } from "~/schemas/public/Annunci";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum"; import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import type { Ordini, OrdiniOrdineId } from "~/schemas/public/Ordini"; import type { Ordini, OrdiniOrdineId } from "~/schemas/public/Ordini";
@ -344,11 +344,8 @@ type DocRef = {
storageId: string; storageId: string;
ref: UsersStorageUserStorageId | null; ref: UsersStorageUserStorageId | null;
}; };
export type ServizioData = Servizio & {
doc_personale_fronte: DocRef | null; export type ServizioAnnuncioData = ServizioAnnunci &
doc_personale_retro: DocRef | null;
doc_motivazione: DocRef | null;
annunci: (ServizioAnnunci &
Pick< Pick<
Annunci, Annunci,
| "id" | "id"
@ -382,7 +379,12 @@ export type ServizioData = Servizio & {
thumb: string; thumb: string;
video: string; video: string;
}[]; }[];
})[]; };
export type ServizioData = Servizio & {
doc_personale_fronte: DocRef | null;
doc_personale_retro: DocRef | null;
doc_motivazione: DocRef | null;
annunci: ServizioAnnuncioData[];
ordini: (Ordini & Pick<Prezziario, "testo_condizioni">)[]; ordini: (Ordini & Pick<Prezziario, "testo_condizioni">)[];
}; };
@ -390,7 +392,8 @@ export const getAllServizioAnnunci = async (
userId: UsersId, userId: UsersId,
): Promise<ServizioData[]> => { ): Promise<ServizioData[]> => {
try { try {
const data = await db const data = await withParsedDates(
db
.selectFrom("servizio") .selectFrom("servizio")
.where("user_id", "=", userId) .where("user_id", "=", userId)
.leftJoin( .leftJoin(
@ -490,47 +493,33 @@ export const getAllServizioAnnunci = async (
eb eb
.selectFrom("ordini") .selectFrom("ordini")
.selectAll("ordini") .selectAll("ordini")
.innerJoin("prezziario", "prezziario.idprezziario", "ordini.packid") .innerJoin(
"prezziario",
"prezziario.idprezziario",
"ordini.packid",
)
.select("prezziario.testo_condizioni") .select("prezziario.testo_condizioni")
.whereRef("ordini.servizio_id", "=", "servizio.servizio_id") .whereRef("ordini.servizio_id", "=", "servizio.servizio_id")
.orderBy("ordini.created_at", "desc"), .orderBy("ordini.created_at", "desc"),
).as("ordini"), ).as("ordini"),
]) ])
.orderBy("servizio.created_at", "desc") .orderBy("servizio.created_at", "desc"),
.execute(); [
"annunci.modificato_il",
"annunci.created_at",
"annunci.open_contatti_at",
"annunci.user_confirmed_at",
"annunci.accettato_conferma_at",
"annunci.contratto_decorrenza",
"annunci.contratto_scadenza",
"annunci.disponibile_da",
"annunci.media_updated_at",
"ordini.created_at",
"ordini.paid_at",
],
).execute();
const formattedData = data.map((item) => { return data;
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),
})),
ordini: item.ordini.map((ordine) => ({
...ordine,
created_at: new Date(ordine.created_at),
paid_at: ordine.paid_at ? new Date(ordine.paid_at) : null,
})),
};
});
return formattedData;
} catch (e) { } catch (e) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
@ -1460,31 +1449,14 @@ export const getOrdineRicevutaData = async ({
}; };
export type RichiesteData = Servizio & export type RichiesteData = Servizio &
Pick<Users, "username"> & { Pick<Users, "username"> & {
annunci_servizio: (Override< annunci_servizio: ServizioAnnuncioData[];
ServizioAnnunci, ordini_servizio: (Ordini & Pick<Prezziario, "testo_condizioni">)[];
{
created_at: string;
open_contatti_at: string | null;
user_confirmed_at: string | null;
accettato_conferma_at: string | null;
contratto_decorrenza: string | null;
contratto_scadenza: string | null;
}
> &
Pick<Annunci, "codice" | "titolo_it">)[];
ordini_servizio: (Override<
Ordini,
{
created_at: string;
paid_at: string | null;
}
> &
Pick<Prezziario, "testo_condizioni">)[];
}; };
export const getRichieste = async (): Promise<RichiesteData[]> => { export const getRichieste = async (): Promise<RichiesteData[]> => {
try { try {
return await db const data = await withParsedDates(
db
.selectFrom("servizio") .selectFrom("servizio")
.innerJoin("users", "users.id", "servizio.user_id") .innerJoin("users", "users.id", "servizio.user_id")
.selectAll("servizio") .selectAll("servizio")
@ -1495,7 +1467,40 @@ export const getRichieste = async (): Promise<RichiesteData[]> => {
.selectFrom("servizio_annunci") .selectFrom("servizio_annunci")
.selectAll("servizio_annunci") .selectAll("servizio_annunci")
.innerJoin("annunci", "annunci.id", "servizio_annunci.annunci_id") .innerJoin("annunci", "annunci.id", "servizio_annunci.annunci_id")
.select(["annunci.codice", "annunci.titolo_it"]) .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(),
])
.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( .whereRef(
"servizio.servizio_id", "servizio.servizio_id",
"=", "=",
@ -1507,14 +1512,32 @@ export const getRichieste = async (): Promise<RichiesteData[]> => {
eb eb
.selectFrom("ordini") .selectFrom("ordini")
.selectAll("ordini") .selectAll("ordini")
.innerJoin("prezziario", "prezziario.idprezziario", "ordini.packid") .innerJoin(
"prezziario",
"prezziario.idprezziario",
"ordini.packid",
)
.select("prezziario.testo_condizioni") .select("prezziario.testo_condizioni")
.whereRef("ordini.servizio_id", "=", "servizio.servizio_id") .whereRef("ordini.servizio_id", "=", "servizio.servizio_id")
.orderBy("ordini.created_at", "desc"), .orderBy("ordini.created_at", "desc"),
).as("ordini_servizio"), ).as("ordini_servizio"),
]) ])
.orderBy("servizio.created_at", "desc") .orderBy("servizio.created_at", "desc"),
.execute(); [
"annunci_servizio.created_at",
"annunci_servizio.open_contatti_at",
"annunci_servizio.disponibile_da",
"annunci_servizio.user_confirmed_at",
"annunci_servizio.accettato_conferma_at",
"annunci_servizio.contratto_decorrenza",
"annunci_servizio.contratto_scadenza",
"annunci_servizio.modificato_il",
"annunci_servizio.media_updated_at",
"ordini_servizio.created_at",
"ordini_servizio.paid_at",
],
).execute();
return data;
} catch (e) { } catch (e) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",

View file

@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server";
import type { AnnunciId } from "~/schemas/public/Annunci"; import type { AnnunciId } from "~/schemas/public/Annunci";
import type { UsersId } from "~/schemas/public/Users"; import type { UsersId } from "~/schemas/public/Users";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { withImages, withVideos } from "~/utils/kysely-helper";
export const AddIntrest = async ({ export const AddIntrest = async ({
annuncioId, annuncioId,
@ -91,3 +92,46 @@ export const GetUserInterests = async (userId: UsersId) => {
}); });
} }
}; };
export const GetUserInterestsAnnunci = async (userId: UsersId) => {
try {
const interests = await GetUserInterests(userId);
if (!interests || interests.length === 0) {
return [];
}
const annunci = await db
.selectFrom("annunci")
.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",
withImages(),
withVideos(),
])
.where("web", "=", true)
.where("stato", "!=", "Sospeso")
.where("annunci.id", "in", interests)
.execute();
return annunci;
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore durante il recupero degli annunci: ${(e as Error).message}`,
});
}
};