refactor: remove ServiziHeader component and integrate its functionality directly into RicercaUser page

feat: update Servizio component to handle interruzioneDays and acceptedInterr logic

refactor: simplify servizio_actions by removing unused InterruzioneServizio and EditParametri components

feat: enhance FormEditServizioAdmin to include interruzioneDays and acceptedInterr fields

fix: adjust email service to delete obsolete emails and handle invalid email types gracefully

chore: update stripe controller to utilize interruzioneDays for lock expiration logic

fix: ensure correct handling of servizio expiration and interruzione logic in service controller
This commit is contained in:
Marco Pedone 2026-03-24 17:01:40 +01:00
parent b4bfccd4c7
commit 5bff7219e8
15 changed files with 579 additions and 368 deletions

View file

@ -0,0 +1,7 @@
ALTER TABLE IF EXISTS public.servizio
ADD COLUMN IF NOT EXISTS "interruzioneDays" integer NOT NULL DEFAULT 14;
ALTER TABLE IF EXISTS public.servizio
ADD COLUMN IF NOT EXISTS "acceptedInterr" boolean NOT NULL DEFAULT FALSE;
ALTER TABLE IF EXISTS public.servizio DROP COLUMN IF EXISTS "isSent";

View file

@ -120,6 +120,7 @@ const SelectedComp = memo(
href={`/annuncio/${selected.codice}`}
initial={{ opacity: 0, scale: 0 }}
key={selected.id}
rel="noopener noreferrer"
target="_blank"
transition={{
duration: 0.1,

View file

@ -0,0 +1,249 @@
import { add, differenceInDays, isBefore } from "date-fns";
import { ArrowRight, Clock, ClockAlert, XCircle } from "lucide-react";
import toast from "react-hot-toast";
import { Confirm } from "~/components/confirm";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { useTranslation } from "~/providers/I18nProvider";
import { useServizio } from "~/providers/ServizioProvider";
import { api } from "~/utils/api";
export const DisclaimerInterruzioneServizio = () => {
const { servizio, servizioId } = useServizio();
const { t } = useTranslation();
const utils = api.useUtils();
const { mutate: interrompiServizio } =
api.servizio.interruzioneServizio.useMutation({
onError: (error) => {
console.error(error);
toast.error("Errore durante la richiesta di interruzione");
},
onSuccess: async () => {
await utils.servizio.invalidate();
toast.success("Interruzione richiesta");
},
});
const { mutate: updateServizio } = api.servizio.updateServizio.useMutation({
onError: (error) => {
console.error(error);
toast.error("Errore");
},
onSuccess: async () => {
await utils.servizio.invalidate();
},
});
if (!servizio.decorrenza) return null;
return (
<main className="mx-auto max-w-4xl px-4 py-6 md:py-8">
<div className="mb-10 text-center">
<div className="mx-auto mb-6 flex size-16 items-center justify-center rounded-full bg-secondary">
<Clock className="size-8 text-primary" />
</div>
<h1 className="mb-4 text-balance font-bold text-2xl text-foreground tracking-tight md:text-4xl">
Sono passati {differenceInDays(new Date(), servizio.decorrenza)}{" "}
giorni dall'inizio della tua ricerca
</h1>
<p className="mx-auto max-w-xl text-pretty text-lg text-muted-foreground leading-relaxed">
Hai trovato qualche immobile interessante? <br /> Ricordati che{" "}
<b>se non intendi confermare</b> nessun immobile, puoi interrompere la
ricerca <b>entro 14 giorni dalla decorrenza</b>, evitando così di
dover pagare il saldo del servizio acquistato.
</p>
</div>
<div className="grid gap-6 md:grid-cols-2">
<Card className="border-2 border-primary shadow-lg">
<CardHeader className="pb-2 text-center">
<div className="mx-auto mb-3 flex size-14 items-center justify-center rounded-full bg-primary/10">
<ArrowRight className="size-7 text-primary" />
</div>
<CardTitle className="text-foreground text-xl">Continua</CardTitle>
<CardDescription className="text-base">
Continua a cercare casa
</CardDescription>
</CardHeader>
<div className="flex h-full w-full flex-col justify-between">
<CardContent className="">
<p className="mb-4 text-muted-foreground text-sm">
Avrai sempre modo di interrompere il servizio usando il pulsante
relativo.
</p>
</CardContent>
<CardFooter>
<Button
className="h-12 w-full bg-primary font-medium text-base text-primary-foreground hover:bg-primary/90"
onClick={() =>
updateServizio({ servizioId, data: { acceptedInterr: true } })
}
>
Continua con la ricerca
</Button>
</CardFooter>
</div>
</Card>
<Card className="border-border">
<CardHeader className="pb-2 text-center">
<div className="mx-auto mb-3 flex size-14 items-center justify-center rounded-full bg-muted">
<XCircle className="size-7 text-muted-foreground" />
</div>
<CardTitle className="text-foreground text-xl">
Interrompi
</CardTitle>
<CardDescription className="text-base">
Termina la tua ricerca ora
</CardDescription>
</CardHeader>
<div className="flex h-full w-full flex-col justify-between">
<CardContent className="">
<ul className="mb-6 space-y-2 text-muted-foreground text-sm">
<li className="flex items-center gap-2">
<span className="size-1.5 rounded-full bg-muted-foreground" />
Terminazione immediata del servizio
</li>
<li className="flex items-center gap-2">
<span className="size-1.5 rounded-full bg-muted-foreground" />
Nessun addebito aggiuntivo applicato
</li>
</ul>
</CardContent>
<CardFooter>
<Confirm
description={t.servizio.interruzione.description}
onConfirm={() => {
interrompiServizio({ servizioId });
}}
title={t.servizio.interruzione.title}
>
<Button
className="h-12 w-full border-border font-medium text-base text-muted-foreground hover:bg-muted hover:text-foreground"
variant="outline"
>
Interrompi il mio servizio ora
</Button>
</Confirm>
</CardFooter>
</div>
</Card>
</div>
<p className="mt-8 text-center text-muted-foreground text-sm">
Hai fino al{" "}
{add(servizio.decorrenza, {
days: servizio.interruzioneDays,
}).toLocaleDateString("it-IT")}{" "}
per interrompere il servizio senza costi aggiuntivi.
<br />
Se hai bisogno di assistenza, contattaci.
</p>
</main>
);
};
export const InterruzioneServizio = () => {
const { servizio, isAdmin } = useServizio();
const { t } = useTranslation();
const utils = api.useUtils();
const { mutate } = api.servizio.interruzioneServizio.useMutation({
onError: (error) => {
console.error(error);
toast.error("Errore durante la richiesta di interruzione");
},
onSuccess: async () => {
await utils.servizio.invalidate();
toast.success("Interruzione richiesta");
},
});
if (!servizio.decorrenza) return null;
const fineInterruzione = add(servizio.decorrenza, {
days: servizio.interruzioneDays,
});
const canInterrompere =
!servizio.isInterrotto &&
(isAdmin ||
(isBefore(new Date(), fineInterruzione) &&
servizio.annunci.every((a) => a.accettato_conferma_at === null)));
return (
<AlertDialog>
<AlertDialogTrigger asChild>
{canInterrompere ? (
<Button
aria-label="Request Interruzione"
className="w-full"
disabled={!canInterrompere}
>
<ClockAlert /> <span>{t.servizio.interruzione.btn}</span>
</Button>
) : (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<span className="inline-block w-full">
<Button
aria-label="Request Interruzione"
className="w-full"
disabled={!canInterrompere}
>
<ClockAlert /> <span>{t.servizio.interruzione.btn}</span>
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>Periodo di interruzione scaduto, contattaci.</p>
</TooltipContent>
</Tooltip>
)}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.servizio.interruzione.title}</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p>
<b>
Fine periodo di interruzione:{" "}
{fineInterruzione.toLocaleDateString("it-IT")}
</b>
</p>
<p className="whitespace-pre-line">
{t.servizio.interruzione.description}
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t.annulla}</AlertDialogCancel>
<AlertDialogAction
aria-label="Confirm Interruzione"
onClick={() => mutate({ servizioId: servizio.servizio_id })}
>
{t.conferma}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View file

@ -1,8 +1,19 @@
import { format } from "date-fns";
import { it } from "date-fns/locale";
import { CalendarIcon } from "lucide-react";
import { CalendarIcon, SlidersHorizontal } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { z } from "zod/v4";
import { Counter } from "~/components/counter";
import {
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaHeader,
CredenzaTitle,
CredenzaTrigger,
} from "~/components/custom_ui/credenza";
import {
Form,
FormControl,
@ -16,6 +27,14 @@ import { MultiSelect, UnpackOptions } from "~/components/custom_ui/multiselect";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Calendar } from "~/components/ui/calendar";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Label } from "~/components/ui/label";
import {
Popover,
@ -24,12 +43,16 @@ import {
} from "~/components/ui/popover";
import { Slider } from "~/components/ui/slider";
import { Switch } from "~/components/ui/switch";
import { FieldResetButton } from "~/forms/comps";
import { cn } from "~/lib/utils";
import { useZodForm } from "~/lib/zodForm";
import { useTranslation } from "~/providers/I18nProvider";
import { useServizio } from "~/providers/ServizioProvider";
import type { Servizio } from "~/schemas/public/Servizio";
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import { FieldResetButton } from "./comps";
import type { UsersId } from "~/schemas/public/Users";
import { api } from "~/utils/api";
import { DEFAULT_SERVIZIO_INTERRUZIONE_DURATION_DAYS } from "~/utils/config";
const Schema = z
.object({
@ -53,6 +76,8 @@ const Schema = z
skipPayment: z.boolean(),
skipControlloDoc: z.boolean(),
skipDocMotivazione: z.boolean(),
interruzioneDays: z.number().min(0),
acceptedInterr: z.boolean(),
})
.superRefine(({ n_adulti }, refinementContext) => {
if (n_adulti <= 0) {
@ -65,9 +90,9 @@ const Schema = z
}
});
export type FormValues = z.infer<typeof Schema>;
type FormValues = z.infer<typeof Schema>;
export const FormServizio = ({
const FormServizio = ({
initialData,
onSubmit,
isLimited,
@ -105,6 +130,8 @@ export const FormServizio = ({
skipPayment: false,
skipControlloDoc: true,
skipDocMotivazione: true,
interruzioneDays: DEFAULT_SERVIZIO_INTERRUZIONE_DURATION_DAYS, // default value for interruzioneDays
acceptedInterr: false,
};
}
const { locale, t } = useTranslation();
@ -613,6 +640,7 @@ export const FormServizio = ({
})}
</div>
</div>
{/*
<Separator />
{!isLimited && (
@ -701,3 +729,114 @@ export const FormServizio = ({
</Form>
);
};
export const NewServizioModal = ({ userId }: { userId: UsersId }) => {
const [open, setOpen] = useState(false);
const utils = api.useUtils();
const { mutate: add } = api.servizio.addServizio.useMutation({
onError: (error) => {
toast.error(error.message);
},
onSuccess: async () => {
toast.success("Servizio creato con successo");
await utils.servizio.invalidate();
setOpen(false);
},
});
function onSubmit(fields: FormValues) {
add({ data: { ...fields, user_id: userId } });
}
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild>
<Button
onClick={() => {
setOpen(true);
}}
variant="outline"
>
Nuovo Servizio
</Button>
</DialogTrigger>
<DialogContent className="max-h-[80%] w-full overflow-auto p-4 sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Creazione Servizio</DialogTitle>
<DialogDescription className="sr-only">desc</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto">
<FormServizio
initialData={undefined}
isLimited={false}
onSubmit={onSubmit}
/>
</div>
</DialogContent>
</Dialog>
);
};
export const EditServizioModal = () => {
const { servizioId, isAdmin, servizio } = useServizio();
const { t } = useTranslation();
const [openEditParametri, setOpenEditParametri] = useState(false);
const utils = api.useUtils();
const { mutate: update } = api.servizio.updateServizio.useMutation({
onError: (error) => {
toast.error(error.message);
},
onSuccess: async () => {
toast.success("Servizio modificato con successo");
await utils.servizio.invalidate();
setOpenEditParametri(false);
},
});
function onSubmit(fields: FormValues) {
update({
data: {
...fields,
},
servizioId,
});
}
return (
<Credenza onOpenChange={setOpenEditParametri} open={openEditParametri}>
<CredenzaTrigger asChild>
<Button
aria-label="Parametri di ricerca"
className="w-full"
disabled={
!isAdmin &&
(servizio.isInterrotto ||
servizio.annunci.some((a) => a.hasConfermaAdmin))
}
variant="secondary"
>
<SlidersHorizontal />
{t.servizio.parametri.btn}
</Button>
</CredenzaTrigger>
<CredenzaContent className="max-h-[90vh] w-full sm:max-w-5xl">
<CredenzaHeader>
<CredenzaTitle>{t.servizio.parametri.title}</CredenzaTitle>
<CredenzaDescription className="sr-only">
{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">
<FormServizio
initialData={servizio}
isLimited={!isAdmin}
onSubmit={onSubmit}
/>
</CredenzaBody>
</CredenzaContent>
</Credenza>
);
};

View file

@ -1,75 +0,0 @@
import { useState } from "react";
import toast from "react-hot-toast";
import { NewRinnovoModal } from "~/components/servizio/rinnovo";
import { FormServizio, type FormValues } from "~/forms/FormServizio";
import type { UsersId } from "~/schemas/public/Users";
import { api } from "~/utils/api";
import { Button } from "../ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../ui/dialog";
export const ServiziHeader = ({ userId }: { userId: UsersId }) => {
return (
<div className="flex w-full flex-wrap items-center justify-between gap-2">
<h3 className="font-semibold text-xl">Servizi cliente</h3>
<div className="flex items-center gap-2">
<NewServizioModal userId={userId} />
<NewRinnovoModal userId={userId} />
</div>
</div>
);
};
const NewServizioModal = ({ userId }: { userId: UsersId }) => {
const [open, setOpen] = useState(false);
const utils = api.useUtils();
const { mutate: add } = api.servizio.addServizio.useMutation({
onError: (error) => {
toast.error(error.message);
},
onSuccess: async () => {
toast.success("Servizio creato con successo");
await utils.servizio.invalidate();
setOpen(false);
},
});
function onSubmit(fields: FormValues) {
add({ data: { ...fields, user_id: userId } });
}
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild>
<Button
onClick={() => {
setOpen(true);
}}
variant="outline"
>
Nuovo Servizio
</Button>
</DialogTrigger>
<DialogContent className="max-h-[80%] w-full overflow-auto p-4 sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Creazione Servizio</DialogTitle>
<DialogDescription className="sr-only">desc</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto">
<FormServizio
initialData={undefined}
isLimited={false}
onSubmit={onSubmit}
/>
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -1,4 +1,4 @@
import { add } from "date-fns";
import { add, isAfter, isBefore } from "date-fns";
import {
ArrowRight,
BadgeCheck,
@ -16,6 +16,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import toast from "react-hot-toast";
import { DisclaimerInterruzioneServizio } from "~/components/servizio/interruzione";
import { cn } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import {
@ -28,6 +29,7 @@ import type { Rinnovi } from "~/schemas/public/Rinnovi";
import type { UsersId } from "~/schemas/public/Users";
import type { ServizioData } from "~/server/controllers/servizio.controller";
import { api } from "~/utils/api";
import { DEFAULT_SERVIZIO_DURATION_DAYS } from "~/utils/config";
import { Confirm } from "../confirm";
import { AlarmClockSVG } from "../svgs";
import {
@ -165,7 +167,10 @@ const ServizioLinkCard = ({
} else if (servizio.decorrenza) {
const now = new Date();
const decorrenza = new Date(servizio.decorrenza);
if (now > add(decorrenza, { days: 60 }) && !servizio.isOkSaldo) {
if (
now > add(decorrenza, { days: DEFAULT_SERVIZIO_DURATION_DAYS }) &&
!servizio.isOkSaldo
) {
status = "scaduto";
StatusIcon = CircleMinus;
}
@ -285,9 +290,9 @@ const Content = () => {
? new Date(servizio.decorrenza).toLocaleDateString("it-IT")
: "";
const end_txt = servizio.decorrenza
? new Date(add(servizio.decorrenza, { days: 60 })).toLocaleDateString(
"it-IT",
)
? new Date(
add(servizio.decorrenza, { days: DEFAULT_SERVIZIO_DURATION_DAYS }),
).toLocaleDateString("it-IT")
: "";
return (
@ -341,6 +346,7 @@ const Main = () => {
},
});
// se interrotto, mostra avvisos
if (servizio.isInterrotto) {
return (
<>
@ -349,6 +355,7 @@ const Main = () => {
</>
);
}
// mostra onboarding
if (!servizio.onboardOk) {
return (
<div className="flex w-full flex-col items-center justify-center gap-8">
@ -376,6 +383,7 @@ const Main = () => {
</div>
);
}
// se non ok acconto, mostra invito a completare pagamento acconto o ad attivare se acconto già inserito
if (!servizio.isOkAcconto || servizio.decorrenza === null) {
const acconti = servizio.ordini.filter(
(o) => o.type === OrderTypeEnum.Acconto,
@ -468,13 +476,15 @@ const Main = () => {
</div>
);
}
//servizio scaduto se decorrenza + 60 giorni è passato e non è ok saldo
if (
new Date() >
add(servizio.decorrenza, {
days: 60,
days: DEFAULT_SERVIZIO_DURATION_DAYS,
}) &&
!servizio.isOkSaldo
) {
//todo capire come fare con saldo quando scade
return (
<>
<p className="font-medium text-lg">{t.servizio.scaduto}</p>
@ -482,10 +492,12 @@ const Main = () => {
</>
);
}
const annuncioConfermato = servizio.annunci.filter(
(a) => a.accettato_conferma_at !== null,
);
// in conferma, mostra solo annunci confermati
if (servizio.isOkSaldo && annuncioConfermato.length > 0) {
return (
<>
@ -503,6 +515,23 @@ const Main = () => {
</>
);
}
// se superato periodo interruzione, ma non interrotto, non scaduto e non in conferma, mostra avviso
if (
!servizio.acceptedInterr &&
isBefore(
new Date(),
add(servizio.decorrenza, { days: servizio.interruzioneDays }),
) &&
isAfter(
new Date(),
add(servizio.decorrenza, { days: servizio.interruzioneDays - 5 }),
)
) {
return <DisclaimerInterruzioneServizio />;
}
// normal case
return (
<>
{annuncioConfermato.length > 0 && (
@ -511,9 +540,7 @@ const Main = () => {
<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) => (
{annuncioConfermato.map((a) => (
<div
className="flex items-center gap-2 rounded-md bg-sky-400 p-2"
key={a.annunci_id}
@ -550,7 +577,7 @@ const Main = () => {
</ServizioAnnuncioProvider>
);
})}
{servizio.annunci.length % 2 === 1 && (
{servizio.annunci.length > 1 && servizio.annunci.length % 2 === 1 && (
<div className="@sm:block hidden size-full rounded-md border border-(--pattern-fg) bg-[repeating-linear-gradient(45deg,var(--pattern-fg)_0,var(--pattern-fg)_1px,transparent_0,transparent_50%)] bg-size-[10px_10px] bg-fixed opacity-10 [--pattern-fg:#696969] dark:[--pattern-fg:#e1e1e1]" />
)}
</div>

View file

@ -1,16 +1,13 @@
import { add, isBefore } from "date-fns";
import {
Bug,
Check,
ChevronDown,
ClockAlert,
Cog,
Euro,
ExternalLink,
Logs,
Plus,
Search,
SlidersHorizontal,
Trash2,
} from "lucide-react";
import Link from "next/link";
@ -28,17 +25,8 @@ import {
} from "~/components/custom_ui/credenza";
import { MultiSelect } from "~/components/custom_ui/multiselect";
import { LoadingPage } from "~/components/loading";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { InterruzioneServizio } from "~/components/servizio/interruzione";
import { EditServizioModal } from "~/components/servizio/parametri_ricerca";
import { Button } from "~/components/ui/button";
import {
Collapsible,
@ -54,17 +42,11 @@ import {
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
import {
type FormValues as EditFormValues,
FormEditServizio,
} from "~/forms/FormEditServizioAdmin";
import { FormNewOrder } from "~/forms/FormNewOrdine";
import { FormServizio, type FormValues } from "~/forms/FormServizio";
import { formatCurrency } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import { useServizio } from "~/providers/ServizioProvider";
@ -118,7 +100,7 @@ const UserActions = () => {
if (isAdmin || canUserEdit) {
return (
<>
<EditParametri />
<EditServizioModal />
<InterruzioneServizio />
</>
);
@ -581,155 +563,6 @@ const AddAnnuncio = ({
);
};
const InterruzioneServizio = () => {
const { servizio, isAdmin } = useServizio();
const { t } = useTranslation();
const utils = api.useUtils();
const { mutate } = api.servizio.interruzioneServizio.useMutation({
onError: (error) => {
console.error(error);
toast.error("Errore durante la richiesta di interruzione");
},
onSuccess: async () => {
await utils.servizio.invalidate();
toast.success("Interruzione richiesta");
},
});
if (!servizio.decorrenza) return null;
const fineInterruzione = add(servizio.decorrenza, {
days: 14,
});
const canInterrompere =
!servizio.isInterrotto &&
(isAdmin ||
(isBefore(new Date(), fineInterruzione) &&
servizio.annunci.every((a) => a.accettato_conferma_at === null)));
return (
<AlertDialog>
<AlertDialogTrigger asChild>
{canInterrompere ? (
<Button
aria-label="Request Interruzione"
className="w-full"
disabled={!canInterrompere}
>
<ClockAlert /> <span>{t.servizio.interruzione.btn}</span>
</Button>
) : (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<span className="inline-block w-full">
<Button
aria-label="Request Interruzione"
className="w-full"
disabled={!canInterrompere}
>
<ClockAlert /> <span>{t.servizio.interruzione.btn}</span>
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>Periodo di interruzione scaduto, contattaci.</p>
</TooltipContent>
</Tooltip>
)}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.servizio.interruzione.title}</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p>
<b>
Fine periodo di interruzione:{" "}
{fineInterruzione.toLocaleDateString("it-IT")}
</b>
</p>
<p className="whitespace-pre-line">
{t.servizio.interruzione.description}
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t.annulla}</AlertDialogCancel>
<AlertDialogAction
aria-label="Confirm Interruzione"
onClick={() => mutate({ servizioId: servizio.servizio_id })}
>
{t.conferma}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
const EditParametri = () => {
const { servizioId, isAdmin, servizio } = useServizio();
const { t } = useTranslation();
const [openEditParametri, setOpenEditParametri] = useState(false);
const utils = api.useUtils();
const { mutate: update } = api.servizio.updateServizio.useMutation({
onError: (error) => {
toast.error(error.message);
},
onSuccess: async () => {
toast.success("Servizio modificato con successo");
await utils.servizio.invalidate();
setOpenEditParametri(false);
},
});
function onSubmit(fields: FormValues) {
update({
data: {
...fields,
},
servizioId,
});
}
return (
<Credenza onOpenChange={setOpenEditParametri} open={openEditParametri}>
<CredenzaTrigger asChild>
<Button
aria-label="Parametri di ricerca"
className="w-full"
disabled={
!isAdmin &&
(servizio.isInterrotto ||
servizio.annunci.some((a) => a.hasConfermaAdmin))
}
variant="secondary"
>
<SlidersHorizontal />
{t.servizio.parametri.btn}
</Button>
</CredenzaTrigger>
<CredenzaContent className="max-h-[90vh] w-full sm:max-w-5xl">
<CredenzaHeader>
<CredenzaTitle>{t.servizio.parametri.title}</CredenzaTitle>
<CredenzaDescription className="sr-only">
{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">
<FormServizio
initialData={servizio}
isLimited={!isAdmin}
onSubmit={onSubmit}
/>
</CredenzaBody>
</CredenzaContent>
</Credenza>
);
};
const EditServizioAdmin = () => {
const { servizioId, userId, servizio } = useServizio();
const [open, setOpen] = useState(false);

View file

@ -2,6 +2,7 @@ import { format } from "date-fns";
import { enUS, it } from "date-fns/locale";
import { CalendarIcon, X } from "lucide-react";
import { z } from "zod/v4";
import { Counter } from "~/components/counter";
import {
Form,
FormControl,
@ -24,6 +25,7 @@ import { cn } from "~/lib/utils";
import { useZodForm } from "~/lib/zodForm";
import { useTranslation } from "~/providers/I18nProvider";
import type { Servizio } from "~/schemas/public/Servizio";
import { DEFAULT_SERVIZIO_INTERRUZIONE_DURATION_DAYS } from "~/utils/config";
const Schema = z.object({
decorrenza: z.date().nullable(),
@ -31,11 +33,12 @@ const Schema = z.object({
isOkAcconto: z.boolean(),
isOkConsulenza: z.boolean(),
isOkSaldo: z.boolean(),
isSent: z.boolean(),
skipPayment: z.boolean(),
skipControlloDoc: z.boolean(),
skipDocMotivazione: z.boolean(),
onboardOk: z.boolean(),
interruzioneDays: z.number(),
acceptedInterr: z.boolean(),
});
export type FormValues = z.infer<typeof Schema>;
@ -61,11 +64,12 @@ export const FormEditServizio = ({
isOkAcconto: false,
isOkConsulenza: false,
isOkSaldo: false,
isSent: false,
skipPayment: false,
skipControlloDoc: false,
skipDocMotivazione: false,
onboardOk: false,
interruzioneDays: DEFAULT_SERVIZIO_INTERRUZIONE_DURATION_DAYS,
acceptedInterr: false,
};
}
const { locale, t } = useTranslation();
@ -82,30 +86,6 @@ export const FormEditServizio = ({
<Form {...form}>
<form className="space-y-8 px-0.5" onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex w-full flex-col items-start justify-between gap-4 sm:flex-row">
<FormField
control={form.control}
name="isSent"
render={({ field }) => (
<FormItem className="w-full">
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="isSent">Inviato</FormLabel>
<FormControl>
<Switch
className="data-[state=checked]:bg-neutral-700"
defaultChecked={field.value}
id="isSent"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</div>
<FormDescription>
La mail di benvenuto al cliente è stata inviata
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isInterrotto"
@ -128,6 +108,53 @@ export const FormEditServizio = ({
</FormItem>
)}
/>
<FormField
control={form.control}
name="acceptedInterr"
render={({ field }) => (
<FormItem className="w-full">
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="acceptedInterr">
Informativa Interruzione Vista
</FormLabel>
<FormControl>
<Switch
className="data-[state=checked]:bg-neutral-700"
defaultChecked={field.value}
id="acceptedInterr"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="interruzioneDays"
render={({ field }) => (
<FormItem className={cn("w-full")}>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel
className={cn("font-semibold text-base")}
htmlFor="interruzioneDays"
>
Giorni interruzione
</FormLabel>
<FormMessage />
</div>
<FormControl>
<Counter
name="interruzioneDays"
onlyPositive
setValue={(value) => field.onChange(Number(value))}
value={Number(field.value)}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex w-full flex-col items-start justify-between gap-4 sm:flex-row">
<FormField

View file

@ -2,14 +2,11 @@ import type { KyselyPlugin, UnknownRow } from "kysely";
import {
createQueryId,
DummyDriver,
type Insertable,
Kysely,
NoResultError,
PostgresAdapter,
PostgresIntrospector,
PostgresQueryCompiler,
type Selectable,
type Updateable,
} from "kysely";
import { jsonArrayFrom } from "kysely/helpers/postgres";
import { describe, expect, expectTypeOf, it } from "vitest";
@ -39,11 +36,6 @@ interface ItemTable {
updated_at: Date | null;
}
export type Box = Selectable<BoxTable>;
export type Item = Selectable<ItemTable>;
export type NewItem = Insertable<ItemTable>;
export type ItemUpdate = Updateable<ItemTable>;
// ---------------------------------------------------------------------------
// DummyDriver db — used only for compile-time type tests
// ---------------------------------------------------------------------------

View file

@ -1,7 +1,8 @@
import type { GetServerSideProps } from "next";
import { AreaRiservataLayoutUserView } from "~/components/Layout";
import { LoadingPage } from "~/components/loading";
import { ServiziHeader } from "~/components/servizio/servizi_header";
import { NewServizioModal } from "~/components/servizio/parametri_ricerca";
import { NewRinnovoModal } from "~/components/servizio/rinnovo";
import { ServizioList } from "~/components/servizio/servizio";
import { AnnunciRichiesti } from "~/components/servizio/servizio_annunci_accordions";
import type { NextPageWithLayout } from "~/pages/_app";
@ -27,7 +28,13 @@ const RicercaUser: NextPageWithLayout<RicercaUserProps> = ({
return (
<div className="flex flex-col gap-5">
<ServiziHeader userId={userId} />
<div className="flex w-full flex-wrap items-center justify-between gap-2">
<h3 className="font-semibold text-xl">Servizi cliente</h3>
<div className="flex items-center gap-2">
<NewServizioModal userId={userId} />
<NewRinnovoModal userId={userId} />
</div>
</div>
<AnnunciRichiesti userId={userId} />
<ServizioList data={data || []} isAdmin userId={userId} />
</div>

View file

@ -45,8 +45,6 @@ export default interface ServizioTable {
reddito: ColumnType<string | null, string | null, string | null>;
isSent: ColumnType<boolean, boolean | undefined, boolean>;
decorrenza: ColumnType<Date | null, Date | string | null, Date | string | null>;
isInterrotto: ColumnType<boolean, boolean | undefined, boolean>;
@ -72,6 +70,10 @@ export default interface ServizioTable {
skipDocMotivazione: ColumnType<boolean, boolean | undefined, boolean>;
onboardOk: ColumnType<boolean, boolean | undefined, boolean>;
interruzioneDays: ColumnType<number, number | undefined, number>;
acceptedInterr: ColumnType<boolean, boolean | undefined, boolean>;
}
export type Servizio = Selectable<ServizioTable>;

View file

@ -1,6 +1,6 @@
import type { Override } from "TypeHelpers.type";
import { TRPCError } from "@trpc/server";
import { add } from "date-fns";
import { add, isBefore } from "date-fns";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
import { z } from "zod/v4";
import type { PurchaseData } from "~/components/acquisto_receipt";
@ -224,14 +224,20 @@ export const attivaServizio_SaltaPagamentoAcconto = async (
() => new Error(`Ordine not found - ordineId: ${ordineId}`),
);
await tx
const servizio = await tx
.updateTable("servizio")
.set({
decorrenza: new Date(),
isOkAcconto: true,
})
.where("servizio_id", "=", ordine.servizio_id)
.execute();
.returning(["interruzioneDays"])
.executeTakeFirstOrThrow(
() =>
new Error(
`Failed to update servizio - servizioId: ${ordine.servizio_id}`,
),
);
await NewMail({
template: {
@ -271,7 +277,7 @@ export const attivaServizio_SaltaPagamentoAcconto = async (
userId: ordine.userId,
},
},
lock_expires: add(new Date(), { days: 10 }),
lock_expires: add(new Date(), { days: servizio.interruzioneDays - 5 }),
});
return true;
});
@ -290,7 +296,7 @@ export const AdminAttivaServizio_Ufficio = async (
return await db.transaction().execute(async (tx) => {
const data = await tx
.selectFrom("servizio")
.select("servizio.user_id")
.select(["servizio.user_id", "servizio.interruzioneDays"])
.innerJoin("users", "users.id", "servizio.user_id")
.select("users.email")
.where("servizio_id", "=", servizioId)
@ -322,7 +328,7 @@ export const AdminAttivaServizio_Ufficio = async (
userId: data.user_id,
},
},
lock_expires: add(today, { days: 10 }),
lock_expires: add(today, { days: data.interruzioneDays - 5 }), //avviso 5 giorni prima della scadenza del periodo di ripensamento
});
return true;
});
@ -908,11 +914,12 @@ export const interruzioneServizio = async (servizioId: ServizioServizioId) => {
message: "Servizio decorrenza not set",
});
}
const isWithinRipensamento =
new Date() <=
const isWithinRipensamento = isBefore(
new Date(),
add(servizio.decorrenza, {
days: 14,
});
days: servizio.interruzioneDays,
}),
);
if (!isWithinRipensamento) {
throw new TRPCError({

View file

@ -98,7 +98,8 @@ export const whIntentSucceededHandler = async ({
db: trx,
userId: ordine.userid,
});
const lock_expires = new Date(Date.now() + 3000); // Lock for 30 sec
const lock_expires = add(new Date(), { seconds: 30 }); // Lock for 30 sec
switch (ordine.type) {
case OrderTypeEnum.Acconto: {
@ -108,14 +109,20 @@ export const whIntentSucceededHandler = async ({
);
}
await trx
const servizio = await trx
.updateTable("servizio")
.where("servizio_id", "=", ordine.servizio_id)
.set({
decorrenza: new Date(),
isOkAcconto: true,
})
.execute();
.returning(["interruzioneDays"])
.executeTakeFirstOrThrow(
() =>
new Error(
`Failed to update servizio - servizioId: ${ordine.servizio_id}`,
),
);
await addEventToQueue({
data: {
@ -163,13 +170,15 @@ export const whIntentSucceededHandler = async ({
mailType: "avvisoInterruzione",
},
mail: {
subject: "Avviso Interruzione",
subject: "Promemoria durata servizio - Infoalloggi.it",
to: utente.email,
},
userId: utente.id,
},
},
lock_expires: add(new Date(), { days: 10 }),
lock_expires: add(new Date(), {
days: servizio.interruzioneDays - 5,
}),
});
//SERVIZIO ATTIVATO
@ -305,35 +314,6 @@ export const whIntentSucceededHandler = async ({
break;
}
case OrderTypeEnum.Altro:
// Handle "Altro" type if needed
// await addEventToQueue({
// data: {
// tipo: "email",
// data: {
// template: {
// mailType: "pagamentoConferma",
// },
// mail: {
// subject: "Pagamento Confermato - Infoalloggi.it",
// to: utente.email,
// attachments: [
// {
// filename: `ricevuta_cortesia_${ordine.ordine_id}.pdf`,
// generate: {
// type: "ricevuta_cortesia",
// ordineId: ordine.ordine_id,
// },
// encoding: "base64",
// contentType: "application/pdf",
// },
// ],
// },
// userId: utente.id,
// },
// },
// lock_expires,
// });
break;
}
await addEventToQueue({

View file

@ -47,6 +47,19 @@ export const getEmails = async ({ userId }: { userId: UsersId }) => {
}),
);
const obsoleteMails = gen.filter((mail) => mail.html === "");
if (obsoleteMails.length > 0) {
await db
.deleteFrom("emails")
.where(
"id_email",
"in",
obsoleteMails.map((mail) => mail.id_email),
)
.execute();
}
return gen;
} catch (e) {
throw new TRPCError({

View file

@ -183,15 +183,12 @@ export const genMail = async ({ ...mail }: MailsTemplates) => {
title = "Servizio Attivato";
break;
case "avvisoInterruzione":
//TODO da fare
mailComponent = AvvisoInterruzione();
title = "Avviso Interruzione";
break;
default:
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Tipo di email non valido: ${mail satisfies never}`,
});
console.error(`Tipo di email non valido: ${mail satisfies never}`);
return { html: "", title: "Email non valido/modello obsoleto" };
}
return { html: await render(mailComponent), title };
@ -206,6 +203,11 @@ export const NewMail = async ({ template, userId, mail }: NewMailProps) => {
try {
if (template) {
const { html } = await genMail({ ...template });
if (!html || html.trim() === "") {
throw new Error(
`Il template per il tipo ${template.mailType} è obsoleto e non genera più l'html. Email non inviata.`,
);
}
await mailer({
html,
...mail,