infoalloggi-monorepo/apps/infoalloggi/src/components/servizio/annuncio_card.tsx
Marco Pedone f52dd96c11 feat: implement Stripe reports management for admin panel
- Add new page for managing Stripe reports with functionality to generate and view reports.
- Create API router for handling Stripe report operations including listing, setting up, and generating reports.
- Introduce caching service for managing cache invalidation related to reports.
- Update Typst templates for formatting financial reports in EUR.
- Enhance utility functions for price formatting and error handling.
- Implement a script to check for unused tRPC procedures in the codebase.
2026-04-03 18:57:39 +02:00

342 lines
9.1 KiB
TypeScript

import type { Table } from "@tanstack/react-table";
import { format } from "date-fns";
import { NotebookPen, Toolbox } from "lucide-react";
import Image from "next/image";
import { type RefObject, useState } from "react";
import toast from "react-hot-toast";
import LoadingButton from "~/components/custom_ui/loading-button";
import {
AdminActions,
UserActions,
} from "~/components/servizio/annuncio_actions";
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 { cn, formatCurrency } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
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 {
RichiesteData,
ServizioAnnuncioData,
} from "~/server/controllers/servizio.controller";
import { api } from "~/utils/api";
import { Button } from "../ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "../ui/collapsible";
import { AnnuncioDettaglio, TipoBadge } from "./annuncio_dettaglio";
export const AnnuncioCard = ({ className }: { className?: string }) => {
const { isAdmin } = useServizio();
const { data } = useServizioAnnuncio();
return (
<AnnuncioCardContent
className={className}
data={data}
interactions={
isAdmin ? (
<div className="flex flex-col gap-2">
<Collapsible className="space-y-2">
<CollapsibleTrigger asChild>
<Button size="sm" variant="outline">
<Toolbox />
<span>Azioni Utente</span>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="rounded-md border border-card bg-card p-2">
<UserActions />
</CollapsibleContent>
</Collapsible>
<AdminActions />
</div>
) : (
<UserActions />
)
}
isAdmin={isAdmin}
/>
);
};
const AnnuncioCardContent = ({
interactions,
className,
data,
isAdmin,
}: {
interactions?: React.ReactNode;
className?: string;
data: ServizioAnnuncioData;
isAdmin: boolean;
}) => {
return (
<Card
className={cn(
"w-full max-w-7xl gap-2 rounded-md border-none bg-muted py-3",
className,
)}
>
<CardHeader className="flex flex-row flex-wrap items-center justify-between space-y-0 px-3 pb-2">
<CardTitle className="text-2xl">Annuncio {data.codice}</CardTitle>
</CardHeader>
<CardContent className="px-3">
<div
className={cn(
"grid grid-cols-1 gap-4",
isAdmin && "sm:grid-cols-[2fr_1fr]",
)}
>
<div
className={cn("@container flex w-full flex-col gap-4")}
id="container"
>
<AnnuncioDisplay data={data} />
<div className="flex w-full flex-col gap-4">{interactions}</div>
</div>
{isAdmin && (
<AnnuncioNote
annuncioId={data.id}
note={data.note}
servizioId={data.servizio_id}
/>
)}
</div>
</CardContent>
</Card>
);
};
export const AnnuncioCardRichieste = ({
tableRef,
data,
}: {
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">
<CardTitle className="text-2xl">Annuncio {data.codice}</CardTitle>
</CardHeader>
<CardContent className="px-3">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[2fr_1fr]">
<div
className={cn("@container flex w-full flex-col gap-4")}
id="container"
>
<AnnuncioDisplay data={data} />
<div className="flex w-full flex-col gap-4">
<div>
<p>
Visto contatti:{" "}
{data.open_contatti_at
? format(data.open_contatti_at, "dd/MM/yyyy HH:mm")
: "Non ancora visto"}
</p>
{/*TODO CAPIRE COSA AGGIUNGERE */}
</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 || ""}
/>
<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>
);
};
const AnnuncioNote = ({
servizioId,
annuncioId,
note,
}: {
servizioId: ServizioServizioId;
annuncioId: AnnunciId;
note: string | null;
}) => {
const [newNote, setNewNote] = useState<string | null>(note);
const utils = api.useUtils();
const { mutate, isPending } = api.servizio.updateServizioAnnunci.useMutation({
onSuccess: async () => {
toast.success("Note aggiornate");
await utils.servizio.invalidate();
},
onError: () => {
toast.error("Errore nell'aggiornamento note");
},
});
const isDesktop = useMediaQuery("(min-width: 40rem)");
return (
<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-${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>
</CollapsibleContent>
</Collapsible>
);
};
export const AnnuncioDisplay = ({
data,
className,
}: {
data: AnnuncioRicerca;
className?: string;
}) => {
const { t } = useTranslation();
return (
<div
className={cn(
"grid @md:grid-cols-[12rem_1fr] grid-cols-[7rem_1fr] @sm:gap-x-4 gap-x-1 gap-y-2 rounded-md bg-card md:max-w-5xl lg:gap-x-4 xl:gap-x-6",
className,
)}
>
<div className="row-span-1 md:row-span-3">
<Image
alt={data.codice}
className="size-24 rounded-md bg-[#e6e9ec] object-cover md:size-48"
height={1080}
priority
src={
(data?.images[0] &&
getStorageUrl({
storageId: data.images[0].img,
params: {
cacheKey: data.media_updated_at?.toISOString(),
media: "image",
},
})) ||
"/fallback-image.png"
}
width={1920}
/>
</div>
<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>
<div className="col-span-2 flex w-full max-w-xl items-center justify-between gap-2 pr-3 md:col-span-1 md:pr-8">
<TipoBadge mini tipo={data.tipo} />
<span className="max-w-auto truncate">
{handleConsegna({
aggiornamento: t.card.in_aggiornamento,
consegna: data.consegna,
consegna_da: t.card.consegna_da,
mesi: t.parametri.mesi,
subito: t.card.consegna_subito,
})}
</span>
<span className="font-semibold">
{formatCurrency(data.prezzo / 1e2)}
</span>
</div>
<div className="col-span-2 w-full max-w-xl md:col-span-1 md:pr-8">
<AnnuncioDettaglio data={data} />
</div>
</div>
);
};