diff --git a/apps/admin/src/core/navigation.ts b/apps/admin/src/core/navigation.ts index 1cb5ff15..f7a471c5 100644 --- a/apps/admin/src/core/navigation.ts +++ b/apps/admin/src/core/navigation.ts @@ -203,6 +203,9 @@ export const Routes = { applications: { toString: () => "/downloads/show/applications" as const, }, + exhibitors: { + toString: () => "/downloads/show/exhibitors" as const, + }, }, }, login: { toString: () => "/login" as const }, diff --git a/apps/admin/src/routes/_layout.show.exhibitors.$id.edit.stand-configuration/action.ts b/apps/admin/src/routes/_layout.show.exhibitors.$id.edit.stand-configuration/action.ts index d7f6cf58..6e65491b 100644 --- a/apps/admin/src/routes/_layout.show.exhibitors.$id.edit.stand-configuration/action.ts +++ b/apps/admin/src/routes/_layout.show.exhibitors.$id.edit.stand-configuration/action.ts @@ -14,7 +14,7 @@ export const ActionSchema = zu message: "Veuillez entrer un nombre valide", }) .int({ message: "Veuillez entrer un nombre entier" }) - .min(1, "Veuillez entrer un nombre supérieur ou égal à 1"), + .min(0, "Veuillez entrer un nombre supérieur ou égal à 0"), dividerCount: zu.coerce .number({ diff --git a/apps/admin/src/routes/_layout.show.exhibitors._index/card-list.tsx b/apps/admin/src/routes/_layout.show.exhibitors._index/card-list.tsx index f935a5ec..f671f5b8 100644 --- a/apps/admin/src/routes/_layout.show.exhibitors._index/card-list.tsx +++ b/apps/admin/src/routes/_layout.show.exhibitors._index/card-list.tsx @@ -3,6 +3,7 @@ import { BaseLink } from "#i/core/base-link"; import { Paginator } from "#i/core/controllers/paginator"; import { SimpleEmpty } from "#i/core/data-display/empty"; import { Card } from "#i/core/layout/card"; +import { DownloadExhibitorsTrigger } from "#i/routes/downloads.show.exhibitors/trigger.js"; import { ExhibitorSearchParams } from "#i/show/exhibitors/search-params"; import { useOptimisticSearchParams } from "@animeaux/search-params-io"; import { useLoaderData } from "@remix-run/react"; @@ -10,7 +11,8 @@ import type { loader } from "./loader.server"; import { Row, Rows } from "./rows"; export function CardList() { - const { totalCount, pageCount, exhibitors } = useLoaderData(); + const { totalCount, pageCount, exhibitors, canExport } = + useLoaderData(); const [searchParams] = useOptimisticSearchParams(); return ( @@ -19,6 +21,15 @@ export function CardList() { {totalCount} {totalCount > 1 ? "exposants" : "exposant"} + + {canExport ? ( + + + + Exporter + + + ) : null} diff --git a/apps/admin/src/routes/_layout.show.exhibitors._index/loader.server.ts b/apps/admin/src/routes/_layout.show.exhibitors._index/loader.server.ts index 2f606bad..5aab4e86 100644 --- a/apps/admin/src/routes/_layout.show.exhibitors._index/loader.server.ts +++ b/apps/admin/src/routes/_layout.show.exhibitors._index/loader.server.ts @@ -2,6 +2,7 @@ import { db } from "#i/core/db.server"; import { PageSearchParams } from "#i/core/search-params"; import { assertCurrentUserHasGroups } from "#i/current-user/groups.server"; import { ExhibitorSearchParams } from "#i/show/exhibitors/search-params"; +import { hasGroups } from "#i/users/groups.js"; import { UserGroup } from "@animeaux/prisma/server"; import type { LoaderFunctionArgs } from "@remix-run/node"; import { json } from "@remix-run/node"; @@ -56,5 +57,14 @@ export async function loader({ request }: LoaderFunctionArgs) { const pageCount = Math.ceil(totalCount / EXHIBITOR_COUNT_PER_PAGE); - return json({ totalCount, pageCount, exhibitors, dividerTypes, standSizes }); + const canExport = hasGroups(currentUser, [UserGroup.ADMIN]); + + return json({ + totalCount, + pageCount, + exhibitors, + dividerTypes, + standSizes, + canExport, + }); } diff --git a/apps/admin/src/routes/downloads.show.exhibitors/route.tsx b/apps/admin/src/routes/downloads.show.exhibitors/route.tsx new file mode 100644 index 00000000..4a923180 --- /dev/null +++ b/apps/admin/src/routes/downloads.show.exhibitors/route.tsx @@ -0,0 +1,97 @@ +import { db } from "#i/core/db.server.js"; +import { assertCurrentUserHasGroups } from "#i/current-user/groups.server.js"; +import { ExhibitorSearchParams } from "#i/show/exhibitors/search-params.js"; +import type { Prisma } from "@animeaux/prisma"; +import { UserGroup } from "@animeaux/prisma"; +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { csvFormatRows } from "d3-dsv"; + +const exhibitorSelect = { + appetizerPeopleCount: true, + breakfastPeopleCountSaturday: true, + breakfastPeopleCountSunday: true, + chairCount: true, + dividerCount: true, + dividerType: { select: { label: true } }, + dogs: { select: { id: true } }, + hasTableCloths: true, + name: true, + tableCount: true, +} satisfies Prisma.ShowExhibitorSelect; + +export async function loader({ request }: LoaderFunctionArgs) { + const currentUser = await db.currentUser.get(request, { + select: { groups: true }, + }); + + assertCurrentUserHasGroups(currentUser, [UserGroup.ADMIN]); + + const searchParams = new URL(request.url).searchParams; + + const { exhibitors } = await db.show.exhibitor.findMany({ + searchParams: ExhibitorSearchParams.io.parse(searchParams), + select: exhibitorSelect, + }); + + return new Response( + csvFormatRows([ + columns.map((column) => column.label), + + ...exhibitors.map((exhibitors) => + columns.map((column) => column.accessor(exhibitors)), + ), + ]), + ); +} + +type ColumnDefinition = { + label: string; + accessor: ( + exhibitors: Prisma.ShowExhibitorGetPayload<{ + select: typeof exhibitorSelect; + }>, + ) => string; +}; + +const columns: ColumnDefinition[] = [ + { + label: "Nom", + accessor: (exhibitor) => exhibitor.name, + }, + { + label: "Type de cloisons", + accessor: (exhibitor) => exhibitor.dividerType?.label ?? "", + }, + { + label: "Nombre de cloisons", + accessor: (exhibitor) => String(exhibitor.dividerCount), + }, + { + label: "Nombre de tables", + accessor: (exhibitor) => String(exhibitor.tableCount), + }, + { + label: "Nappage", + accessor: (exhibitor) => (exhibitor.hasTableCloths ? "Oui" : "Non"), + }, + { + label: "Nombre de chaises", + accessor: (exhibitor) => String(exhibitor.chairCount), + }, + { + label: "Chiens sur stand", + accessor: (exhibitor) => (exhibitor.dogs.length > 0 ? "Oui" : "Non"), + }, + { + label: "Nombre de pdj samedi", + accessor: (exhibitor) => String(exhibitor.breakfastPeopleCountSaturday), + }, + { + label: "Nombre de pdj dimanche", + accessor: (exhibitor) => String(exhibitor.breakfastPeopleCountSunday), + }, + { + label: "Nombre d’apéro", + accessor: (exhibitor) => String(exhibitor.appetizerPeopleCount), + }, +]; diff --git a/apps/admin/src/routes/downloads.show.exhibitors/trigger.tsx b/apps/admin/src/routes/downloads.show.exhibitors/trigger.tsx new file mode 100644 index 00000000..ef16a829 --- /dev/null +++ b/apps/admin/src/routes/downloads.show.exhibitors/trigger.tsx @@ -0,0 +1,34 @@ +import { DownloadTrigger } from "#i/core/actions/download-trigger.js"; +import { Routes } from "#i/core/navigation"; +import { ExhibitorSearchParams } from "#i/show/exhibitors/search-params.js"; +import { useOptimisticSearchParams } from "@animeaux/search-params-io"; +import { createPath } from "history"; +import { forwardRef } from "react"; +import type { Except } from "type-fest"; + +export const DownloadExhibitorsTrigger = forwardRef< + React.ComponentRef, + Except< + React.ComponentPropsWithoutRef, + "url" | "fileName" + > +>(function DownloadExhibitorsTrigger(props, ref) { + const [searchParams] = useOptimisticSearchParams(); + + const exhibitorSearchParams = ExhibitorSearchParams.io.copy( + searchParams, + new URLSearchParams(), + ); + + return ( + + ); +}); diff --git a/apps/show/src/routes/_exhibitor.exposants.$token._config.participation.modifier-stand/action-schema.ts b/apps/show/src/routes/_exhibitor.exposants.$token._config.participation.modifier-stand/action-schema.ts index f45f732e..47251466 100644 --- a/apps/show/src/routes/_exhibitor.exposants.$token._config.participation.modifier-stand/action-schema.ts +++ b/apps/show/src/routes/_exhibitor.exposants.$token._config.participation.modifier-stand/action-schema.ts @@ -27,7 +27,7 @@ export function createActionSchema({ message: "Veuillez entrer un nombre valide", }) .int({ message: "Veuillez entrer un nombre entier" }) - .min(1, "Veuillez entrer un nombre supérieur ou égal à 1"), + .min(0, "Veuillez entrer un nombre supérieur ou égal à 0"), dividerCount: zu.coerce .number({ message: "Veuillez entrer un nombre valide" }) .int({ message: "Veuillez entrer un nombre entier" })