feat: implement manual order creation dialog and associated form components

This commit is contained in:
Marco Pedone 2026-02-24 10:40:32 +01:00
parent 51d58a3008
commit fd7da28535
9 changed files with 475 additions and 9 deletions

View file

@ -0,0 +1,88 @@
import { useState } from "react";
import { FormNewOrder } from "~/forms/FormNewOrdine";
import type { ServizioServizioId } from "~/schemas/public/Servizio";
import type { UsersId } from "~/schemas/public/Users";
import { api } from "~/utils/api";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTrigger,
} from "../custom_ui/credenza";
import { LoadingPage } from "../loading";
import { Button } from "../ui/button";
export const OrdineManualeDialog = ({
userId,
servizioId,
}: {
userId: UsersId;
servizioId: ServizioServizioId;
}) => {
const [open, setOpen] = useState(false);
return (
<Credenza onOpenChange={setOpen} open={open}>
<CredenzaTrigger asChild>
<Button size="sm" variant="outline">
Nuovo Ordine Manuale
</Button>
</CredenzaTrigger>
<CredenzaContent className="max-h-[90vh] w-full px-3 sm:max-w-5xl">
<CredenzaHeader>
<CredenzaTitle>Nuovo Ordine Manuale</CredenzaTitle>
<CredenzaDescription className="sr-only">
nuovo ordine modal
</CredenzaDescription>
</CredenzaHeader>
<Content servizioId={servizioId} setOpen={setOpen} userId={userId} />
</CredenzaContent>
</Credenza>
);
};
const Content = ({
userId,
servizioId,
setOpen,
}: {
userId: UsersId;
servizioId: ServizioServizioId;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
const { data, isLoading } = api.prezziario.getPrezziario.useQuery();
if (isLoading) return <LoadingPage />;
if (!data)
return (
<>
<CredenzaBody className="max-h-[80vh] w-full overflow-y-auto pb-5">
<p className="text-center text-muted-foreground text-sm">
Errore nel caricamento del prezziario.
</p>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button aria-label="Close Credenza" variant="outline">
Chiudi
</Button>
</CredenzaClose>
</CredenzaFooter>
</>
);
return (
<FormNewOrder
prezziario={data}
servizioId={servizioId}
setOpen={setOpen}
userId={userId}
/>
);
};

View file

@ -9,6 +9,7 @@ import {
ExternalLink,
Info,
Plus,
Search,
SlidersHorizontal,
Trash2,
UserCircle,
@ -78,6 +79,14 @@ export const ServizioActions = () => {
<span className="font-semibold">Azioni Admin:</span>
<AddAnnuncio servizioId={servizio.servizio_id} userId={userId} />
<EditServizioAdmin />
<Link
href={`/area-riservata/admin/user-view/ordini/${userId}?servizioId=${servizio.servizio_id}`}
>
<Button variant="warning">
<Search />
<span>Ordini</span>
</Button>
</Link>
</div>
)}
<ServizioInfos />

View file

@ -191,7 +191,7 @@ export const OrdiniTable = ({ data, isAdmin, userId }: PaymentsTableProps) => {
}
: {
columnName: "packid",
placeholder: "Cerca pagamento...",
placeholder: "Cerca pacchetto...",
};
const pinnedFiltri: PinnedFiltro[] = [

View file

@ -70,6 +70,7 @@ export const PaymentsTable = ({
paid_at: row.paid_at,
raw_status: row.paymentstatus,
status: StatusMapping(row.paymentstatus),
intent_id: row.intent_id,
};
});
@ -164,6 +165,7 @@ export const PaymentsTable = ({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Azioni</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Wrench />
@ -206,7 +208,7 @@ export const PaymentsTable = ({
remove({ pagamentoId: data.id })
}
>
<Trash2 className="size-6" />
<Trash2 className="size-5" />
Elimina Pagamento
</DropdownMenuItem>
</DropdownMenuContent>

View file

@ -0,0 +1,258 @@
import { format } from "date-fns";
import { it } from "date-fns/locale";
import { CalendarIcon } from "lucide-react";
import toast from "react-hot-toast";
import { z } from "zod/v4";
import {
CredenzaBody,
CredenzaClose,
CredenzaFooter,
} from "~/components/custom_ui/credenza";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/custom_ui/form";
import { MultiSelect } from "~/components/custom_ui/multiselect";
import { Button } from "~/components/ui/button";
import { Calendar } from "~/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Switch } from "~/components/ui/switch";
import { cn, formatCurrency } from "~/lib/utils";
import { useZodForm } from "~/lib/zodForm";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import type { Prezziario } from "~/schemas/public/Prezziario";
import type { ServizioServizioId } from "~/schemas/public/Servizio";
import type { UsersId } from "~/schemas/public/Users";
import { zPrezziarioId } from "~/server/utils/zod_types";
import { api } from "~/utils/api";
type FormProps = {
prezziario: Prezziario[];
servizioId: ServizioServizioId;
userId: UsersId;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
export const FormNewOrder = ({
prezziario,
servizioId,
userId,
setOpen,
}: FormProps) => {
const schema = z.object({
packid: zPrezziarioId,
isActive: z.boolean(),
type: z.custom<OrderTypeEnum>(),
created_at: z.date(),
});
type FormValues = z.infer<typeof schema>;
z.config(z.locales.it());
const form = useZodForm(schema, {
defaultValues: {},
});
const utils = api.useUtils();
const { mutate: createOrder } = api.servizio.createOrdine.useMutation({
onError: (error) => {
toast.error(`Errore nella creazione dell'ordine: ${error.message}`);
},
onSuccess: async () => {
toast.success("Ordine creato con successo");
await utils.servizio.getOrdini.invalidate({
userId,
});
setOpen(false);
form.reset();
},
});
function onSubmit(fields: FormValues) {
createOrder({
data: {
...fields,
servizio_id: servizioId,
userid: userId,
},
});
}
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<CredenzaBody className="max-h-[80vh] w-full pb-5">
<div className="space-y-8 px-0.5">
<FormField
control={form.control}
name="packid"
render={({ field }) => (
<FormItem className="w-full">
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="select-pack">Servizio</FormLabel>
<FormMessage />
</div>
<FormControl>
<MultiSelect
defaultValue={undefined}
elementId="select-pack"
elementName="packid"
isMulti={false}
onValueChange={(value) => {
const selected = zPrezziarioId.safeParse(value.value);
if (selected.success) {
field.onChange(selected.data);
} else {
console.error(
"Invalid packid selected",
value.value,
);
}
}}
options={prezziario.map((p) => ({
label: `${p.idprezziario} - ${p.nome_it} (${formatCurrency(p.prezzo_cent / 100)})`,
value: p.idprezziario,
}))}
placeholder="Seleziona..."
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="titolo">Tipologia</FormLabel>
<FormMessage />
</div>
<FormControl>
<Select
defaultValue={field.value || ""}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Seleziona tipologia" />
</SelectTrigger>
</FormControl>
<SelectContent id="select-color">
{Object.values(OrderTypeEnum).map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="created_at"
render={({ field }) => (
<FormItem className="flex w-full flex-col">
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="created_at">Data Ordine</FormLabel>
<FormMessage />
</div>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
className={cn(
"h-10.5 w-full pl-3 text-left font-medium",
!field.value && "text-muted-foreground",
"rounded-lg border border-neutral-300 shadow-xs outline-hidden file:border-0 file:bg-transparent file:font-medium file:text-sm disabled:cursor-not-allowed disabled:opacity-50 dark:focus:border-transparent",
)}
id="created_at"
variant={"outline"}
>
{field.value ? (
format(field.value, "PPP", {
locale: it,
})
) : (
<span>Scegli data</span>
)}
<CalendarIcon className="ml-auto size-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-0">
<Calendar
autoFocus
captionLayout="dropdown"
defaultMonth={new Date()}
endMonth={new Date(2050, 12)}
locale={it}
mode="single"
onSelect={field.onChange}
selected={field.value || undefined}
/>
</PopoverContent>
</Popover>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<FormItem className="w-full">
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="isActive">Attivo</FormLabel>
<FormControl>
<Switch
className="data-[state=checked]:bg-neutral-700"
defaultChecked={field.value}
id="isActive"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</div>
<FormDescription>
Imposta se il servizio è attivo
</FormDescription>
</FormItem>
)}
/>
</div>
</CredenzaBody>
<CredenzaFooter>
<Button type="submit" variant="success">
Salva
</Button>
<CredenzaClose asChild>
<Button aria-label="Close Credenza" variant="outline">
Chiudi
</Button>
</CredenzaClose>
</CredenzaFooter>
</form>
</Form>
</>
);
};

View file

@ -1,20 +1,26 @@
import type { GetServerSideProps } from "next";
import Link from "next/link";
import { OrdineManualeDialog } from "~/components/area-riservata/ordine_manuale";
import { AreaRiservataLayoutUserView } from "~/components/Layout";
import { LoadingPage } from "~/components/loading";
import { Status500 } from "~/components/status-page";
import { OrdiniTable } from "~/components/tables/orders-table";
import { Button } from "~/components/ui/button";
import type { NextPageWithLayout } from "~/pages/_app";
import type { ServizioServizioId } from "~/schemas/public/Servizio";
import type { UsersId } from "~/schemas/public/Users";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { zUserId } from "~/server/utils/zod_types";
import { zServizioId, zUserId } from "~/server/utils/zod_types";
import { api } from "~/utils/api";
type OrdiniUserProps = {
userId: UsersId;
servizioId: ServizioServizioId | null;
};
const OrdiniUser: NextPageWithLayout<OrdiniUserProps> = ({
userId,
servizioId,
}: OrdiniUserProps) => {
const { data, isLoading } = api.servizio.getOrdini.useQuery({
userId,
@ -24,10 +30,31 @@ const OrdiniUser: NextPageWithLayout<OrdiniUserProps> = ({
if (!data) return <Status500 />;
return (
<main className="flex flex-1 flex-col items-start gap-4">
<div className="flex">
<h3 className="font-bold text-2xl">Ordini</h3>
<div className="flex w-full flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 font-bold text-2xl">
Ordini{" "}
<span className="text-sm">
{servizioId ? `- Servizio ${servizioId}` : ""}
</span>
</h3>
{servizioId && (
<div className="flex items-center gap-2">
<OrdineManualeDialog servizioId={servizioId} userId={userId} />
<Link href={`/area-riservata/admin/user-view/ordini/${userId}`}>
<Button size="sm" variant="destructive">
Rimuovi filtro
</Button>
</Link>
</div>
<OrdiniTable data={data} isAdmin userId={userId} />
)}
</div>
<OrdiniTable
data={data.filter((ordine) =>
servizioId ? ordine.servizio_id === servizioId : true,
)}
isAdmin
userId={userId}
/>
</main>
);
};
@ -55,10 +82,19 @@ export const getServerSideProps = (async (context) => {
await helpers.trpc.servizio.getOrdini.prefetch({
userId: parsed.data,
});
let servizioId: ServizioServizioId | null = null;
const parsedServizioId = zServizioId.safeParse(context.query?.servizioId);
if (parsedServizioId.success) {
servizioId = parsedServizioId.data;
}
return {
props: {
userId: parsed.data,
trpcState: helpers.trpc.dehydrate(),
servizioId,
},
};
}

View file

@ -1,5 +1,5 @@
import { z } from "zod/v4";
import type { OrdiniUpdate } from "~/schemas/public/Ordini";
import type { NewOrdini, OrdiniUpdate } from "~/schemas/public/Ordini";
import type { PaymentsUpdate } from "~/schemas/public/Payments";
import type { NewServizio, ServizioUpdate } from "~/schemas/public/Servizio";
import type { ServizioAnnunciUpdate } from "~/schemas/public/ServizioAnnunci";
@ -12,6 +12,7 @@ import {
publicProcedure,
} from "~/server/api/trpc";
import {
createOrdine,
getOrdineById,
getOrdini,
removeOrder,
@ -34,6 +35,7 @@ import {
getServiziByUserId,
getServizioAnnuncio,
getServizioById,
getServizioOrdini,
getServizioPagamentoData,
getUserPagamenti,
InteractionLogicTipologia,
@ -166,6 +168,16 @@ export const servizioRouter = createTRPCRouter({
.query(async ({ input, ctx }) => {
return await getOrdineRicevutaData({ ctx, ordineId: input.ordineId });
}),
getServizioOrdini: protectedProcedure
.input(
z.object({
servizioId: zServizioId,
}),
)
.query(async ({ input }) => {
return await getServizioOrdini(input.servizioId);
}),
getOrdini: protectedProcedure
.input(
z.object({
@ -363,7 +375,15 @@ export const servizioRouter = createTRPCRouter({
servizioId: input.servizioId,
});
}),
createOrdine: adminProcedure
.input(
z.object({
data: z.custom<NewOrdini>(),
}),
)
.mutation(async ({ input }) => {
return await createOrdine(input.data);
}),
updateOrder: adminProcedure
.input(
z.object({

View file

@ -1,6 +1,10 @@
import { TRPCError } from "@trpc/server";
import { jsonArrayFrom } from "kysely/helpers/postgres";
import type { OrdiniOrdineId, OrdiniUpdate } from "~/schemas/public/Ordini";
import type {
NewOrdini,
OrdiniOrdineId,
OrdiniUpdate,
} from "~/schemas/public/Ordini";
import type { UsersId } from "~/schemas/public/Users";
import { db, type Querier } from "~/server/db";
@ -61,6 +65,21 @@ export const getOrdineById = async (ordineId: OrdiniOrdineId) => {
}
};
export const createOrdine = async (data: NewOrdini) => {
try {
return await db
.insertInto("ordini")
.values(data)
.returningAll()
.executeTakeFirstOrThrow();
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error creating ordine: ${(e as Error).message}`,
});
}
};
export const removeOrder = async (ordineId: OrdiniOrdineId) => {
try {
await db

View file

@ -1847,3 +1847,37 @@ export const getOrdineRicevutaData = async ({
});
}
};
export const getServizioOrdini = async (servizioId: ServizioServizioId) => {
try {
const data = await db
.selectFrom("ordini")
.selectAll("ordini")
.select((eb) => [
jsonArrayFrom(
eb
.selectFrom("payments")
.whereRef("payments.ordine_id", "=", "ordini.ordine_id")
.selectAll("payments")
.orderBy("created_at", "desc"),
).as("pagamenti"),
])
.where("servizio_id", "=", servizioId)
.orderBy("created_at", "desc")
.execute();
return data.map((ordine) => ({
...ordine,
pagamenti: ordine.pagamenti.map((payment) => ({
...payment,
created_at: new Date(payment.created_at),
paid_at: payment.paid_at ? new Date(payment.paid_at) : null,
})),
}));
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error fetching servizio ordini: ${(e as Error).message}`,
});
}
};