From 54057be1f6c1059e1d10da7a0a21b1040f2f28d4 Mon Sep 17 00:00:00 2001 From: valentinkolb Date: Sat, 1 Jun 2024 01:50:04 +0200 Subject: [PATCH] feat(eventSearch): export to csv is now available --- config.ts | 2 +- .../EventLists/Search/DownloadDataModal.tsx | 232 ++++++++++++++++++ .../e/:eventId/EventLists/Search/index.tsx | 20 +- src/pages/events/s/EventListSlotView.tsx | 39 +-- src/pages/events/s/EventLoginWarning.tsx | 40 +++ src/pages/events/s/EventView.tsx | 48 +--- 6 files changed, 317 insertions(+), 64 deletions(-) create mode 100644 src/pages/events/e/:eventId/EventLists/Search/DownloadDataModal.tsx create mode 100644 src/pages/events/s/EventLoginWarning.tsx diff --git a/config.ts b/config.ts index 78255fb..1d07d63 100644 --- a/config.ts +++ b/config.ts @@ -9,5 +9,5 @@ export const PB_STORAGE_KEY = "stuve-it-login-record" // general export const APP_NAME = "StuVe IT" -export const APP_VERSION = "0.8.5 (beta)" +export const APP_VERSION = "0.8.9 (beta)" export const APP_URL = "https://it.stuve.uni-ulm.de" \ No newline at end of file diff --git a/src/pages/events/e/:eventId/EventLists/Search/DownloadDataModal.tsx b/src/pages/events/e/:eventId/EventLists/Search/DownloadDataModal.tsx new file mode 100644 index 0000000..f2a357b --- /dev/null +++ b/src/pages/events/e/:eventId/EventLists/Search/DownloadDataModal.tsx @@ -0,0 +1,232 @@ +import {EventListModel, EventListSlotEntriesWithUserModel, EventModel} from "@/models/EventTypes.ts"; +import {ListResult} from "pocketbase"; +import {InfiniteData, UseInfiniteQueryResult} from "@tanstack/react-query"; +import {Button, Checkbox, Fieldset, Group, Modal, Progress, Select, Text} from "@mantine/core"; +import ShowHelp from "@/components/ShowHelp.tsx"; +import {useForm} from "@mantine/form"; +import {useEffect} from "react"; +import {FormSchema} from "@/components/formUtil/formBuilder/types.ts"; +import {IconDownload} from "@tabler/icons-react"; + +const CHARSETS = [ + "utf-8", "ISO-8859-1", "windows-1252", "utf-16be", "utf-16le", "us-ascii" +] as const + +type FormValues = { + questionSchemaFields: string[] + statusSchemaFields: string[], + separator: "," | ";" | "\t", + charset: typeof CHARSETS[number] +} + +export default function DownloadDataModal({opened, onClose, lists, event, query}: { + opened: boolean + onClose: () => void + event: EventModel + lists: EventListModel[] + query: UseInfiniteQueryResult, unknown>, Error> +}) { + + const formValues = useForm({ + initialValues: { + questionSchemaFields: [], + statusSchemaFields: [], + separator: ",", + charset: "utf-8" + } + }) + + // Fetch all pages + useEffect(() => { + if (opened && query.hasNextPage && !query.isFetchingNextPage) { + query.fetchNextPage() + } + }, [opened, query]) + + const RenderFieldCheckboxes = ({schema, formKey}: { + schema: FormSchema | null, + formKey: 'questionSchemaFields' | 'statusSchemaFields' + }) => ( + schema?.fields.map(field => ( + { + formValues.setFieldValue(formKey, formValues.values[formKey]?.includes(field.id) ? + formValues.values[formKey]?.filter(f => f !== field.id) : + [...formValues.values[formKey], field.id]) + }} + /> + )) + ) + + const totalPages = query.data?.pages?.[0].totalPages || 0 + const currentPage = query.data?.pages?.length || 0 + + const progress = currentPage / totalPages * 100 + const dataIsFetching = query.isFetching || query.isPending + + const createCSV = () => { + // all loaded entries + const entries = query.data?.pages.flatMap(p => p.items) ?? [] + + const selectedFields = { + questionSchemaFields: formValues.values.questionSchemaFields, + statusSchemaFields: formValues.values.statusSchemaFields + } + + // which question and status fields to include in the CSV + const questionSchema = { + fields: [ + ...event.defaultEntryQuestionSchema?.fields.filter(f => selectedFields.questionSchemaFields.includes(f.id)) ?? [], + ...lists.flatMap(l => l.entryQuestionSchema?.fields.filter(f => selectedFields.questionSchemaFields.includes(f.id)) ?? []) + ] + } + const statusSchema = { + fields: [ + ...event.defaultEntryStatusSchema?.fields.filter(f => selectedFields.statusSchemaFields.includes(f.id)) ?? [], + ...lists.flatMap(l => l.entryStatusSchema?.fields.filter(f => selectedFields.statusSchemaFields.includes(f.id)) ?? []) + ] + } + + /** + * Escapes the csv separator if it is present in the string + * + * trims the string in any case + * @param str the string to escape + */ + const escapeSeparator = (str: string) => { + if (str.includes(formValues.values.separator)) { + return `"${str.trim()}"` + } + return str.trim() + } + + // assemble the header (first question fields, then status fields) + const header = [ + "Person", "Person ID", "Anmeldezeitpunkt", "Anmelde-ID", "Anmelde-Liste", + ...questionSchema.fields.map(f => f.label).map(escapeSeparator), + ...statusSchema.fields.map(f => f.label).map(escapeSeparator) + ] + + // assemble the data + const data = entries.map(e => { + const questionData = e.entryQuestionData || {} + const statusData = e.entryStatusData || {} + return [ + e.expand?.user.username ?? "N/A", e.user, e.created, e.id, e.listName, + ...questionSchema.fields.map(f => questionData[f.id]?.value?.toString() ?? "N/A").map(escapeSeparator), + ...statusSchema.fields.map(f => statusData[f.id]?.value?.toString() ?? "N/A").map(escapeSeparator) + ] + }) + + // assemble CSV + const csv = [ + header.join(formValues.values.separator), + ...data.map(row => row.join(formValues.values.separator)) + ].join("\r\n") + + const csvName = `${event.name.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_export.csv` + + // download + const blob = new Blob([csv], {type: "text/csv;charset=" + formValues.values.charset}) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = csvName + a.style.display = "hidden" + a.click() + URL.revokeObjectURL(url) + onClose() + } + + return +
+ + In der CSV sind automatisch die Felder Person, Person + ID, Anmeldezeitpunkt, Anmelde-ID und Anmelde-Liste enthalten. Diese + Felder können nicht abgewählt werden. +
+ Um Listen spezifische Daten herunterzuladen, wähle im vorherigen Schritt eine oder mehrere Listen aus. +
+ + {dataIsFetching && <> + + Daten werden gesammelt ... + + + + + {progress.toFixed(0)}% + + + } + +
+
+ + Formularfelder + + + + Statusfelder + + +
+
+ + { + lists.map(list =>
+
+ + Formularfelder + + + + Statusfelder + + +
+
) + } + + ({value: c, label: c.toUpperCase()}))} + {...formValues.getInputProps("charset")} + /> + + + + + +
+
+} \ No newline at end of file diff --git a/src/pages/events/e/:eventId/EventLists/Search/index.tsx b/src/pages/events/e/:eventId/EventLists/Search/index.tsx index 4965a1d..3123d81 100644 --- a/src/pages/events/e/:eventId/EventLists/Search/index.tsx +++ b/src/pages/events/e/:eventId/EventLists/Search/index.tsx @@ -32,12 +32,17 @@ import {FieldEntriesFilter} from "@/components/formUtil/FromInput/types.ts"; import {assembleFilter} from "@/components/formUtil/FormFilter/util.ts"; import {DateTimePicker} from "@mantine/dates"; import EventEntries from "@/pages/events/e/:eventId/EventLists/Search/EventEntries.tsx"; +import {useEventRights} from "@/pages/events/util.ts"; +import DownloadDataModal from "@/pages/events/e/:eventId/EventLists/Search/DownloadDataModal.tsx"; export default function ListSearch({event}: { event: EventModel }) { const {pb} = usePB() + const {canEditEvent} = useEventRights(event) + const [showFilter, showFilterHandler] = useDisclosure(true) + const [showDownloadModal, showDownloadModalHandler] = useDisclosure(false) const formValues = useForm({ initialValues: { @@ -197,7 +202,12 @@ export default function ListSearch({event}: { event: EventModel }) { {entriesCount} Personen benachrichtigen - @@ -220,6 +230,14 @@ export default function ListSearch({event}: { event: EventModel }) { query.refetch()}/> + + {query.hasNextPage && (