infoalloggi-monorepo/apps/infoalloggi/src/components/tables/richieste-table.tsx

708 lines
19 KiB
TypeScript
Raw Normal View History

import type { ColumnDef, Table } from "@tanstack/react-table";
import {
add,
format,
formatDuration,
intervalToDuration,
isBefore,
} from "date-fns";
import { it } from "date-fns/locale";
import {
BadgeEuro,
CircleCheck,
CircleCheckBig,
ClipboardPen,
ClockFading,
ExternalLink,
Logs,
ReceiptEuro,
2026-05-08 14:16:29 +02:00
SearchSlash,
SearchX,
UserCog,
} from "lucide-react";
import Link from "next/link";
import { useRef } from "react";
import toast from "react-hot-toast";
import { NotePopover } from "~/components/notePopover";
import { AnnuncioCardRichieste } from "~/components/servizio/annuncio_card";
import { OrdersModal } from "~/components/servizio/servizio_actions";
2026-05-12 16:33:59 +02:00
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "~/components/ui/context-menu";
import { DataTable } from "~/components/ui/data-table";
import { DataTableColumnHeader } from "~/components/ui/dataTable-header";
import type {
PinnedFiltro,
SearchFiltro,
} from "~/components/ui/dataTable-toolbar";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "~/components/ui/sheet";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { UserAvatar } from "~/components/user_avatar";
2026-05-12 16:33:59 +02:00
import { WhatsAppIcon2 } from "~/components/whatsapp-svg";
import { MutationPageRestore } from "~/lib/tableUtils";
import { cn } from "~/lib/utils";
import { orderTypeEnum } from "~/schemas/public/OrderTypeEnum";
import { statusConfermaEnum } from "~/schemas/public/StatusConfermaEnum";
import { tipologiaPosizioneEnum } from "~/schemas/public/TipologiaPosizioneEnum";
import type { RichiesteData } from "~/server/controllers/servizio.controller";
import { api } from "~/utils/api";
import { DEFAULT_SERVIZIO_DURATION_DAYS } from "~/utils/config";
type RichiesteTableProps = {
data: RichiesteData[];
};
const StatusMapping = {
attesa_attivazione: "Attesa di attivazione",
interrotto: "Interrotto",
2026-05-08 14:16:29 +02:00
disabled: "Disattivato",
onboarding: "Onboarding",
scaduto: "Scaduto",
consulenza_da_pagare: "Consulenza da pagare",
consulenza_da_inserire: "Consulenza da inserire",
attesa_saldo: "Attesa pagamento saldo",
attesa_caparra: "Attesa versamento caparra",
attesa_conferma: "Attesa accettazione conferma",
attivo: "Attivo",
attesa_pagamento_acconto: "Attesa pagamento acconto",
confermato: "Confermato",
};
export type StatusKey = keyof typeof StatusMapping;
function statusParser(data: RichiesteData): keyof typeof StatusMapping {
if (data.isInterrotto) {
return "interrotto";
}
2026-05-08 14:16:29 +02:00
if (data.isDisabled) {
return "disabled";
}
if (!data.onboardOk) {
return "onboarding";
}
if (!data.isOkAcconto || !data.decorrenza) {
const acconti = data.ordini_servizio.filter(
(o) => o.type === orderTypeEnum.enum.Acconto,
);
const activeAcconto = acconti.find((a) => a.isActive);
//hasActiveAcconto
if (activeAcconto) {
return "attesa_attivazione";
}
return "attesa_pagamento_acconto";
}
if (
isBefore(
add(data.decorrenza, {
days: DEFAULT_SERVIZIO_DURATION_DAYS,
}),
new Date(),
) &&
!data.isOkSaldo
) {
return "scaduto";
}
const contrattoInserito = data.annunci_servizio.filter(
(a) => a.doc_contratto_added,
);
if (contrattoInserito.length > 0) {
if (data.isOkSaldo) {
if (data.isOkConsulenza) {
return "confermato";
}
if (
//hasConsulanzaDaPagare
data.ordini_servizio.some(
(o) => o.type === orderTypeEnum.enum.Consulenza && !o.isActive,
)
) {
return "consulenza_da_pagare";
}
return "consulenza_da_inserire";
}
}
const annuncioCaparraVersata = data.annunci_servizio.filter(
(a) => a.status_conferma === statusConfermaEnum.enum["Caparra versata"],
);
if (annuncioCaparraVersata.length > 0) {
if (data.isOkSaldo) {
return "confermato";
}
return "attesa_saldo";
}
const annuncioConfermato = data.annunci_servizio.filter(
(a) => a.status_conferma === statusConfermaEnum.enum["Conferma accettata"],
);
if (annuncioConfermato.length > 0) {
return "attesa_caparra";
}
const annuncioAttesaConferma = data.annunci_servizio.filter(
(a) => a.status_conferma === statusConfermaEnum.enum["Inviato conferma"],
);
if (annuncioAttesaConferma.length > 0) {
return "attesa_conferma";
}
return "attivo";
}
export const RichiesteTable = ({ data }: RichiesteTableProps) => {
const tabledata = data.map((d) => ({
...d,
status: statusParser(d),
}));
const columns_titles = {
id: "ID Ordine",
2026-05-06 09:42:34 +02:00
created_at: "Creato il",
username: "Utente",
note: "Note",
decorrenza: "Decorrenza",
status: "Status",
tipologia: "Tipo",
annunci: "Annunci",
ordini: "Ordini",
interruzione_expires_at: "Scad. interr.",
2026-05-06 09:42:34 +02:00
actions: "Apri",
};
const statusOptions = Object.entries(StatusMapping).map(([key, label]) => ({
label,
value: key,
}));
const type_options = [
{ label: "Transitorio", value: tipologiaPosizioneEnum.enum.Transitorio },
{ label: "Stabile", value: tipologiaPosizioneEnum.enum.Stabile },
];
type Column = (typeof tabledata)[number];
const tableRef = useRef<Table<Column> | null>(null);
const utils = api.useUtils();
const { onMutate, onSettled } = MutationPageRestore(tableRef);
const { mutate, isPending } = api.users.editUser.useMutation({
onMutate,
onSettled,
onSuccess: async () => {
toast.success("Note aggiornate con successo");
await utils.users.invalidate();
await utils.servizio.invalidate();
},
onError: (error) => {
toast.error(error.message);
},
});
const searchFiltro: SearchFiltro = {
columnName: "username",
placeholder: "Cerca...",
};
const columns: ColumnDef<Column>[] = [
2026-05-06 09:42:34 +02:00
{
cell: ({ row }) => {
return (
<Link
href={`/area-riservata/admin/user-view/servizio/${row.original.user_id}/${row.original.servizio_id}`}
target="_blank"
>
<Button aria-label="Apri servizio" size="sm" variant="outline">
<ExternalLink className="size-5" />
</Button>
</Link>
);
},
header: ({ column }) => (
<DataTableColumnHeader column={column} title={columns_titles.actions} />
),
enableHiding: false,
id: "actions",
},
{
accessorKey: "id",
cell: () => null,
enableHiding: false,
header: () => null,
},
2026-05-06 09:42:34 +02:00
{
accessorKey: "created_at",
sortingFn: "datetime",
cell: ({ row }) => {
return <p>{format(row.original.created_at, "dd/MM")}</p>;
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={columns_titles.created_at}
/>
),
},
{
accessorKey: "username",
cell: ({ row }) => {
const data = row.original;
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<Link
className="flex items-center gap-2"
href={`/area-riservata/admin/user-view/ricerca/${data.user_id}`}
>
<UserAvatar
className="size-8"
userId={data.user_id}
username={data.username}
/>
<span className="font-semibold text-sm">{data.username}</span>
</Link>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuLabel className="flex h-8 items-center gap-2">
<UserAvatar
className="size-5"
style={{ height: "1.25rem", width: "1.25rem" }}
userId={data.user_id}
username={data.username}
/>
<span className="font-semibold text-sm">{data.username}</span>
</ContextMenuLabel>
<ContextMenuItem>
<Link
aria-label="Visualizza Profilo"
className="flex w-full items-center gap-2"
href={`/area-riservata/admin/user-view/edit-user/${data.user_id}`}
>
<UserCog className="text-muted-foreground" /> Profilo
</Link>
</ContextMenuItem>
{data.extra_telefono_numero ? (
<ContextMenuSub>
<ContextMenuSubTrigger className="flex w-full items-center gap-2">
<WhatsAppIcon2 className="size-4 fill-muted-foreground stroke-0" />
WhatsApp
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>
<Link
className="flex w-full items-center gap-2"
href={`https://wa.me/${data.telefono}`}
target="_blank"
>
<WhatsAppIcon2 className="size-4 fill-muted-foreground stroke-0" />
WhatsApp ({data.nome})
</Link>
</ContextMenuItem>
<ContextMenuItem>
<Link
className="flex w-full items-center gap-2"
href={`https://wa.me/${data.extra_telefono_numero}`}
target="_blank"
>
<WhatsAppIcon2 className="size-4 fill-muted-foreground stroke-0" />
WhatsApp ({data.extra_telefono_titolo})
</Link>
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
) : (
<ContextMenuItem>
<Link
className="flex w-full items-center gap-2"
href={`https://wa.me/${data.telefono}`}
target="_blank"
>
<WhatsAppIcon2 className="size-4 fill-muted-foreground stroke-0" />
WhatsApp
</Link>
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
},
enableHiding: false,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={columns_titles.username}
/>
),
},
{
accessorKey: "note",
cell: ({ row }) => {
const note = row.original.note;
return (
<NotePopover
isPending={isPending}
mutate={mutate}
note={note}
userId={row.original.user_id}
/>
);
},
header: ({ column }) => (
<DataTableColumnHeader column={column} title={columns_titles.note} />
),
enableSorting: false,
},
{
accessorKey: "decorrenza",
cell: ({ row }) => {
let decorrenzaColor = "";
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";
2026-05-08 14:16:29 +02:00
} else if (row.original.isDisabled) {
decorrenzaColor = "text-muted-foreground";
decorrenzaTooltip = "Servizio disattivato";
} 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,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={columns_titles.decorrenza}
/>
),
sortingFn: "datetime",
},
{
accessorKey: "tipologia",
cell: ({ row }) => {
const { tipologia } = row.original;
return (
<Badge
className={cn(
"px-1.5",
tipologia === tipologiaPosizioneEnum.enum.Transitorio
? "border-transitorio bg-transitorio/15"
: "border-stabile bg-stabile/15",
)}
variant="outline"
>
{tipologia.toString()}
</Badge>
);
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={columns_titles.tipologia}
/>
),
},
{
accessorKey: "status",
cell: ({ row }) => {
let badgeClass = "";
let icon: React.ReactNode;
const statusText =
StatusMapping[row.original.status as StatusKey] ||
row.original.status;
switch (row.original.status) {
case "interrotto":
badgeClass =
"border-destructive bg-destructive/10 text-destructive";
icon = <SearchX className="stroke-red-500 dark:stroke-red-400" />;
break;
2026-05-08 14:16:29 +02:00
case "disabled":
badgeClass = "border-gray-500 bg-muted/10 text-muted-foreground";
icon = <SearchSlash className="" />;
break;
case "onboarding":
badgeClass = "border-pink-500 bg-pink-500/10 text-pink-600";
icon = (
<ClipboardPen className="stroke-pink-500 dark:stroke-pink-400" />
);
break;
case "attesa_attivazione":
badgeClass = "border-yellow-500 bg-yellow-500/10 text-yellow-600";
icon = (
<ClockFading className="stroke-yellow-500 dark:stroke-yellow-400" />
);
break;
case "attesa_pagamento_acconto":
badgeClass = "border-orange-500 bg-orange-500/10 text-orange-600";
icon = (
<BadgeEuro className="stroke-orange-500 dark:stroke-orange-400" />
);
break;
case "scaduto":
badgeClass = "border-red-500 bg-red-500/10 text-red-500";
icon = <SearchX className="stroke-red-500 dark:stroke-red-400" />;
break;
case "confermato":
badgeClass = "border-blue-500 bg-blue-500/10 text-blue-500";
icon = (
<CircleCheckBig className="stroke-blue-500 dark:stroke-blue-400" />
);
break;
case "consulenza_da_pagare":
badgeClass = "border-orange-500 bg-orange-500/10 text-orange-600";
icon = (
<BadgeEuro className="stroke-orange-500 dark:stroke-orange-400" />
);
break;
case "consulenza_da_inserire":
badgeClass = "border-yellow-500 bg-yellow-500/10 text-yellow-600";
icon = (
<ClockFading className="stroke-yellow-500 dark:stroke-yellow-400" />
);
break;
case "attesa_saldo":
badgeClass = "border-orange-500 bg-orange-500/10 text-orange-600";
icon = (
<BadgeEuro className="stroke-orange-500 dark:stroke-orange-400" />
);
break;
case "attesa_caparra":
badgeClass = "border-yellow-500 bg-yellow-500/10 text-yellow-600";
icon = (
<ReceiptEuro className="stroke-yellow-500 dark:stroke-yellow-400" />
);
break;
case "attesa_conferma":
badgeClass = "border-yellow-500 bg-yellow-500/10 text-yellow-600";
icon = (
<ClockFading className="stroke-yellow-500 dark:stroke-yellow-400" />
);
break;
case "attivo":
badgeClass = "border-green-500 bg-green-500/10 text-green-500";
icon = (
<CircleCheck className="stroke-green-500 dark:stroke-green-400" />
);
break;
}
return (
<Badge
className={cn(
"flex items-center px-1.5 text-muted-foreground [&>svg]:size-4",
badgeClass,
)}
variant="outline"
>
{icon}
{statusText}
</Badge>
);
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
header: ({ column }) => (
<DataTableColumnHeader column={column} title={columns_titles.status} />
),
},
{
accessorKey: "interruzione_expires_at",
cell: ({ row }) => {
if (row.original.isInterrotto) return "---";
2026-05-08 14:16:29 +02:00
if (row.original.isDisabled) return "---";
if (!row.original.decorrenza) return "---";
const expiresAt = add(row.original.decorrenza, {
days: row.original.interruzioneDays,
});
const timeLeft = intervalToDuration({
start: new Date(),
end: expiresAt,
});
const daysLeft = timeLeft.days || 0;
let badgeColor = "";
if (daysLeft > 5) {
badgeColor = "border-green-500 bg-green-500/10 text-green-500";
} else if (daysLeft > 0) {
badgeColor = "border-yellow-500 bg-yellow-500/10 text-yellow-500";
} else {
badgeColor = "border-red-500 bg-red-500/10 text-red-500";
}
return (
<Tooltip>
<TooltipTrigger>
<Badge className={cn("px-1.5", badgeColor)} variant="outline">
{format(expiresAt, "dd/MM")}
</Badge>
</TooltipTrigger>
<TooltipContent>
{daysLeft > 0 ? (
<p>
{formatDuration(timeLeft, { locale: it, format: ["days"] })}{" "}
rimanenti
</p>
) : (
<p>Interruzione scaduta</p>
)}
</TooltipContent>
</Tooltip>
);
},
enableSorting: false,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={columns_titles.interruzione_expires_at}
/>
),
},
{
accessorKey: "annunci_servizio",
cell: ({ row }) => {
const annunci = row.original.annunci_servizio;
return (
<Sheet>
<SheetTrigger asChild>
<Button
className="data-[state=open]:border-primary data-[state=open]:bg-primary/10"
size="sm"
variant="outline"
>
{annunci.length}
</Button>
</SheetTrigger>
<SheetContent className="w-4/5 sm:max-w-3xl">
<SheetHeader>
<SheetTitle>Annunci Inseriti</SheetTitle>
<SheetDescription className="sr-only">Desc</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-4 divide-y overflow-y-auto">
{annunci.map((a) => (
<AnnuncioCardRichieste
data={a}
key={a.annunci_id}
tableRef={tableRef}
/>
))}
</div>
</SheetContent>
</Sheet>
);
},
header: ({ column }) => (
<DataTableColumnHeader column={column} title={columns_titles.annunci} />
),
enableSorting: false,
},
{
accessorKey: "ordini",
cell: ({ row }) => {
//paidOrdiniCount
const paidOrders = row.original.ordini_servizio.filter(
(o) => o.isActive,
).length;
//unpaidOrdiniCount
const unpaidOrders = row.original.ordini_servizio.length - paidOrders;
return (
<OrdersModal isAdmin={true} ordini={row.original.ordini_servizio}>
<div className="relative inline-flex">
<Button className="flex-1" size="sm" variant="outline">
<Logs />
</Button>
{unpaidOrders > 0 && (
<span className="absolute -top-1.5 -right-1.5 flex size-3.5 items-center justify-center rounded-full bg-red-600 text-white text-xs">
{unpaidOrders}
</span>
)}
{paidOrders > 0 && (
<span className="absolute -top-1.5 -left-1.5 flex size-3.5 items-center justify-center rounded-full bg-green-600 text-white text-xs">
{paidOrders}
</span>
)}
</div>
</OrdersModal>
);
},
header: ({ column }) => (
<DataTableColumnHeader column={column} title={columns_titles.ordini} />
),
enableSorting: false,
},
];
const pinnedFiltri: PinnedFiltro[] = [
{
columnName: "status",
options: statusOptions,
title: "Stato Ordine",
},
{
columnName: "tipologia",
options: type_options,
title: "Tipo Ordine",
},
];
return (
<div className="mx-auto w-full">
<DataTable
columns={columns}
columns_titles={columns_titles}
data={tabledata}
2026-05-06 09:42:34 +02:00
defaultSort={[{ desc: true, id: "created_at" }]}
onTableInit={(t) => (tableRef.current = t)}
pinnedFiltri={pinnedFiltri}
searchColumn={searchFiltro}
/>
</div>
);
};