feat: implement user labels management with CRUD operations and UI integration
This commit is contained in:
parent
eedaab6e9b
commit
a04e3c2fd3
8 changed files with 322 additions and 11 deletions
34
apps/db/migrations/36_user_etichette.up.sql
Normal file
34
apps/db/migrations/36_user_etichette.up.sql
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
CREATE TABLE IF NOT EXISTS public.user_etichette (
|
||||
"userId" UUID NOT NULL,
|
||||
"etichettaId" integer NOT NULL,
|
||||
);
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE public.user_etichette
|
||||
ADD CONSTRAINT user_etichette_pkey PRIMARY KEY ("userId", "etichettaId");
|
||||
EXCEPTION
|
||||
WHEN invalid_table_definition THEN
|
||||
RAISE NOTICE 'Primary key already exists. Ignoring...';
|
||||
END $$;
|
||||
-- Foreign Key to Chats
|
||||
DO $$ BEGIN IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'user_etichetta_fkey'
|
||||
) THEN
|
||||
ALTER TABLE public.user_etichette
|
||||
ADD CONSTRAINT user_etichetta_fkey
|
||||
FOREIGN KEY ("userId") REFERENCES public.users (id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
-- Foreign Key to Etichette
|
||||
DO $$ BEGIN IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'etichetta_user_etichetta_fkey'
|
||||
) THEN
|
||||
ALTER TABLE public.user_etichette
|
||||
ADD CONSTRAINT etichetta_user_etichetta_fkey
|
||||
FOREIGN KEY ("etichettaId") REFERENCES public.etichette (id_etichetta)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
|
@ -113,7 +113,7 @@ export const EtichetteModal = ({
|
|||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
remove({ chatId, etichettaId: etichetta.id_etichetta });
|
||||
remove({ chatId, etichettaId: etichetta.id_etichetta });
|
||||
}}
|
||||
>
|
||||
Rimuovi
|
||||
|
|
|
|||
|
|
@ -4,13 +4,17 @@ import {
|
|||
Mail,
|
||||
MessagesSquare,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Search,
|
||||
ShoppingBag,
|
||||
Tags,
|
||||
User,
|
||||
UserCog,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { DataTable } from "~/components/custom_ui/data-table";
|
||||
import { DataTableColumnHeader } from "~/components/custom_ui/dataTable-header";
|
||||
|
|
@ -20,15 +24,26 @@ import type {
|
|||
} from "~/components/custom_ui/dataTable-toolbar";
|
||||
import { UserAvatar } from "~/components/user_avatar";
|
||||
import { cn } from "~/lib/utils";
|
||||
import type { Users } from "~/schemas/public/Users";
|
||||
import type { Etichette } from "~/schemas/public/Etichette";
|
||||
import type { Users, UsersId } from "~/schemas/public/Users";
|
||||
import { api } from "~/utils/api";
|
||||
import { Etichetta } from "../etichette";
|
||||
import { WhatsAppIcon2 } from "../svgs";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
} from "../ui/context-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
import { Separator } from "../ui/separator";
|
||||
|
||||
type UserWChatInfo = Pick<
|
||||
Users,
|
||||
|
|
@ -39,7 +54,7 @@ type UserWChatInfo = Pick<
|
|||
| "isAdmin"
|
||||
| "isBlocked"
|
||||
| "created_at"
|
||||
> & { chatid: string | null };
|
||||
> & { chatid: string | null; etichette: Etichette[] };
|
||||
|
||||
export const UsersTable = (props: { data: UserWChatInfo[] }) => {
|
||||
const router = useRouter();
|
||||
|
|
@ -50,6 +65,11 @@ export const UsersTable = (props: { data: UserWChatInfo[] }) => {
|
|||
},
|
||||
});
|
||||
|
||||
const [selected, setSelected] = useState<{
|
||||
id: UsersId;
|
||||
etichette: Etichette[];
|
||||
} | null>(null);
|
||||
|
||||
const { data } = props;
|
||||
|
||||
const tabledata = data.map((user) => {
|
||||
|
|
@ -60,8 +80,9 @@ export const UsersTable = (props: { data: UserWChatInfo[] }) => {
|
|||
telefono: user.telefono,
|
||||
id: user.id,
|
||||
isAdmin: user.isAdmin ? "Admin" : "Utente",
|
||||
isBanned: user.isBlocked ? "Bloccato" : "Attivo",
|
||||
isBlocked: user.isBlocked ? "Bloccato" : "Attivo",
|
||||
username: user.username,
|
||||
etichette: user.etichette,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -75,12 +96,37 @@ export const UsersTable = (props: { data: UserWChatInfo[] }) => {
|
|||
email: "Email",
|
||||
telefono: "Telefono",
|
||||
isAdmin: "Ruolo",
|
||||
isBanned: "Status",
|
||||
isBlocked: "Status",
|
||||
username: "User",
|
||||
etichette: "📎",
|
||||
};
|
||||
type Column = (typeof tabledata)[number];
|
||||
|
||||
const columns: ColumnDef<Column>[] = [
|
||||
{
|
||||
accessorKey: "etichette",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={columns_titles.etichette}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex max-w-32 flex-wrap items-center gap-2">
|
||||
{row.original.etichette.map((e) => (
|
||||
<Etichetta
|
||||
color_hex={e.color_hex}
|
||||
key={e.id_etichetta}
|
||||
title={e.title}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "username",
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -170,14 +216,14 @@ export const UsersTable = (props: { data: UserWChatInfo[] }) => {
|
|||
},
|
||||
|
||||
{
|
||||
accessorKey: "isBanned",
|
||||
accessorKey: "isBlocked",
|
||||
filterFn: (row, id, value) => {
|
||||
return value.includes(row.getValue(id));
|
||||
},
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={columns_titles.isBanned}
|
||||
title={columns_titles.isBlocked}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
@ -192,7 +238,7 @@ export const UsersTable = (props: { data: UserWChatInfo[] }) => {
|
|||
title: "Ruolo",
|
||||
},
|
||||
{
|
||||
columnName: "isBanned",
|
||||
columnName: "isBlocked",
|
||||
options: [
|
||||
{ label: "Bloccato", value: "Bloccato" },
|
||||
{ label: "Attivo", value: "Attivo" },
|
||||
|
|
@ -307,6 +353,17 @@ export const UsersTable = (props: { data: UserWChatInfo[] }) => {
|
|||
<User /> Login
|
||||
</button>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem>
|
||||
<button
|
||||
className="flex w-full cursor-pointer items-center gap-2"
|
||||
onClick={() => {
|
||||
setSelected({ id: data.id, etichette: data.etichette });
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Tags /> Etichette
|
||||
</button>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
)}
|
||||
data={tabledata}
|
||||
|
|
@ -323,6 +380,140 @@ export const UsersTable = (props: { data: UserWChatInfo[] }) => {
|
|||
searchColumn={searchFiltro}
|
||||
withContextMenu={true}
|
||||
/>
|
||||
<UsersEtichetteModal selected={selected} setSelected={setSelected} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UsersEtichetteModal = ({
|
||||
selected,
|
||||
setSelected,
|
||||
}: {
|
||||
selected: { id: UsersId; etichette: Etichette[] } | null;
|
||||
setSelected: React.Dispatch<
|
||||
React.SetStateAction<{ id: UsersId; etichette: Etichette[] } | null>
|
||||
>;
|
||||
}) => {
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: etichette } = api.etichette.getAllEtichette.useQuery();
|
||||
|
||||
const { mutate: addEtichetta } = api.etichette.addUserEtichetta.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast.success("Etichetta aggiunta con successo");
|
||||
await utils.etichette.getAllEtichette.invalidate();
|
||||
await utils.users.getUsersWChatInfo.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Errore nell'aggiunta dell'etichetta");
|
||||
},
|
||||
});
|
||||
const { mutate: removeEtichetta } =
|
||||
api.etichette.removeUserEtichetta.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast.success("Etichetta rimossa con successo");
|
||||
await utils.etichette.getAllEtichette.invalidate();
|
||||
await utils.users.getUsersWChatInfo.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Errore nella rimozione dell'etichetta");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(v) => {
|
||||
if (!v) setSelected(null);
|
||||
}}
|
||||
open={!!selected}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Etichette</DialogTitle>
|
||||
<DialogDescription className="sr-only">Etichette</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3>Etichette Attive</h3>
|
||||
{selected?.etichette.map((etichetta) => (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
key={`${etichetta.id_etichetta}${selected.id}`}
|
||||
>
|
||||
<Etichetta
|
||||
color_hex={etichetta.color_hex}
|
||||
title={etichetta.title}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
removeEtichetta({
|
||||
userId: selected.id,
|
||||
etichettaId: etichetta.id_etichetta,
|
||||
});
|
||||
setSelected((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
etichette: prev.etichette.filter(
|
||||
(e) => e.id_etichetta !== etichetta.id_etichetta,
|
||||
),
|
||||
};
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3>Aggiungi Etichetta</h3>
|
||||
{etichette
|
||||
?.filter(
|
||||
(e) =>
|
||||
!selected?.etichette.some(
|
||||
(etichetta) => etichetta.id_etichetta === e.id_etichetta,
|
||||
),
|
||||
)
|
||||
.map((e) => (
|
||||
<div className="flex items-center gap-2" key={e.id_etichetta}>
|
||||
<Etichetta color_hex={e.color_hex} title={e.title} />
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (selected) {
|
||||
addEtichetta({
|
||||
userId: selected.id,
|
||||
etichettaId: e.id_etichetta,
|
||||
});
|
||||
setSelected((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
etichette: [
|
||||
...prev.etichette,
|
||||
{
|
||||
id_etichetta: e.id_etichetta,
|
||||
color_hex: e.color_hex,
|
||||
title: e.title,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
variant="success"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { default as AnnunciTable } from './Annunci';
|
|||
import type { default as UsersTable } from './Users';
|
||||
import type { default as ChatsEtichetteTable } from './ChatsEtichette';
|
||||
import type { default as TestiEStringheTable } from './TestiEStringhe';
|
||||
import type { default as UserEtichetteTable } from './UserEtichette';
|
||||
import type { default as UsersStorageTable } from './UsersStorage';
|
||||
import type { default as ServizioAnnunciTable } from './ServizioAnnunci';
|
||||
import type { default as UserInvitesTable } from './UserInvites';
|
||||
|
|
@ -43,6 +44,8 @@ export default interface PublicSchema {
|
|||
|
||||
testi_e_stringhe: TestiEStringheTable;
|
||||
|
||||
user_etichette: UserEtichetteTable;
|
||||
|
||||
users_storage: UsersStorageTable;
|
||||
|
||||
servizio_annunci: ServizioAnnunciTable;
|
||||
|
|
|
|||
19
apps/infoalloggi/src/schemas/public/UserEtichette.ts
Normal file
19
apps/infoalloggi/src/schemas/public/UserEtichette.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// @generated
|
||||
// This file is automatically generated by Kanel. Do not modify manually.
|
||||
|
||||
import type { UsersId } from './Users';
|
||||
import type { EtichetteIdEtichetta } from './Etichette';
|
||||
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
|
||||
|
||||
/** Represents the table public.user_etichette */
|
||||
export default interface UserEtichetteTable {
|
||||
userId: ColumnType<UsersId, UsersId, UsersId>;
|
||||
|
||||
etichettaId: ColumnType<EtichetteIdEtichetta, EtichetteIdEtichetta, EtichetteIdEtichetta>;
|
||||
}
|
||||
|
||||
export type UserEtichette = Selectable<UserEtichetteTable>;
|
||||
|
||||
export type NewUserEtichette = Insertable<UserEtichetteTable>;
|
||||
|
||||
export type UserEtichetteUpdate = Updateable<UserEtichetteTable>;
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
import { adminProcedure, createTRPCRouter } from "~/server/api/trpc";
|
||||
import {
|
||||
|
|
@ -7,7 +8,7 @@ import {
|
|||
updateEtichetta,
|
||||
} from "~/server/controllers/etichette.controller";
|
||||
import { db } from "~/server/db";
|
||||
import { zEtichettaId } from "~/server/utils/zod_types";
|
||||
import { zEtichettaId, zUserId } from "~/server/utils/zod_types";
|
||||
|
||||
export const etichetteRouter = createTRPCRouter({
|
||||
addEtichetta: adminProcedure
|
||||
|
|
@ -51,4 +52,49 @@ export const etichetteRouter = createTRPCRouter({
|
|||
title: input.title,
|
||||
});
|
||||
}),
|
||||
addUserEtichetta: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: zUserId,
|
||||
etichettaId: zEtichettaId,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await db
|
||||
.insertInto("user_etichette")
|
||||
.values({
|
||||
etichettaId: input.etichettaId,
|
||||
userId: input.userId,
|
||||
})
|
||||
.execute();
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Errore aggiunta etichetta all'utente: ${error instanceof Error ? error.message : "Errore sconosciuto"}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
removeUserEtichetta: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: zUserId,
|
||||
etichettaId: zEtichettaId,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await db
|
||||
.deleteFrom("user_etichette")
|
||||
.where("etichettaId", "=", input.etichettaId)
|
||||
.where("userId", "=", input.userId)
|
||||
.execute();
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Errore rimozione etichetta all'utente: ${error instanceof Error ? error.message : "Errore sconosciuto"}`,
|
||||
});
|
||||
}
|
||||
return "ok";
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -100,6 +100,6 @@ export const usersRouter = createTRPCRouter({
|
|||
}),
|
||||
|
||||
getUsersWChatInfo: adminProcedure.query(async () => {
|
||||
return await getUsersWithChatInfoHandler({ db });
|
||||
return await getUsersWithChatInfoHandler();
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { TRPCError } from "@trpc/server";
|
||||
import { jsonArrayFrom } from "kysely/helpers/postgres";
|
||||
import type { UsersId, UsersUpdate } from "~/schemas/public/Users";
|
||||
import type { UsersAnagraficaUpdate } from "~/schemas/public/UsersAnagrafica";
|
||||
import { db, type Querier } from "~/server/db";
|
||||
|
|
@ -167,7 +168,7 @@ export const getActiveUserWithoutChatHandler = async ({
|
|||
});
|
||||
};
|
||||
|
||||
export const getUsersWithChatInfoHandler = async ({ db }: { db: Querier }) => {
|
||||
export const getUsersWithChatInfoHandler = async () => {
|
||||
return await db
|
||||
.selectFrom("users")
|
||||
.select([
|
||||
|
|
@ -181,6 +182,23 @@ export const getUsersWithChatInfoHandler = async ({ db }: { db: Querier }) => {
|
|||
])
|
||||
.leftJoin("chats", "chats.user_ref", "users.id")
|
||||
.select(["chats.chatid"])
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("etichette")
|
||||
.innerJoin(
|
||||
"user_etichette",
|
||||
"user_etichette.etichettaId",
|
||||
"etichette.id_etichetta",
|
||||
)
|
||||
.whereRef("user_etichette.userId", "=", "users.id")
|
||||
.select([
|
||||
"etichette.id_etichetta",
|
||||
"etichette.title",
|
||||
"etichette.color_hex",
|
||||
]),
|
||||
).as("etichette"),
|
||||
)
|
||||
.orderBy("users.id", "desc")
|
||||
.select(["users.id as id"])
|
||||
.execute();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue