feat(eventSearch): export to csv is now available
Build and Push Docker image / build-and-push (push) Successful in 2m5s
Details
Build and Push Docker image / build-and-push (push) Successful in 2m5s
Details
This commit is contained in:
parent
4f401f4eda
commit
54057be1f6
|
@ -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"
|
|
@ -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<InfiniteData<ListResult<EventListSlotEntriesWithUserModel>, unknown>, Error>
|
||||
}) {
|
||||
|
||||
const formValues = useForm<FormValues>({
|
||||
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 => (
|
||||
<Checkbox
|
||||
key={field.id}
|
||||
label={field.label}
|
||||
checked={formValues.values[formKey]?.includes(field.id)}
|
||||
onChange={() => {
|
||||
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 <Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Daten herunterladen"
|
||||
size="xl"
|
||||
>
|
||||
<div className={"stack"}>
|
||||
<ShowHelp>
|
||||
In der CSV sind automatisch die Felder <em>Person</em>, <em>Person
|
||||
ID</em>, <em>Anmeldezeitpunkt</em>, <em>Anmelde-ID</em> und <em>Anmelde-Liste</em> enthalten. Diese
|
||||
Felder können nicht abgewählt werden.
|
||||
<br/>
|
||||
Um Listen spezifische Daten herunterzuladen, wähle im vorherigen Schritt eine oder mehrere Listen aus.
|
||||
</ShowHelp>
|
||||
|
||||
{dataIsFetching && <>
|
||||
<Text c={"dimmed"} size={"xs"}>
|
||||
Daten werden gesammelt ...
|
||||
</Text>
|
||||
|
||||
<Progress.Root size="xl">
|
||||
<Progress.Section value={progress} animated>
|
||||
<Progress.Label>{progress.toFixed(0)}%</Progress.Label>
|
||||
</Progress.Section>
|
||||
</Progress.Root>
|
||||
</>}
|
||||
|
||||
<Fieldset legend={event.name}>
|
||||
<div className={"stack"}>
|
||||
<Text c={"dimmed"} size={"xs"}>
|
||||
Formularfelder
|
||||
</Text>
|
||||
<RenderFieldCheckboxes formKey={"questionSchemaFields"} schema={event.defaultEntryQuestionSchema}/>
|
||||
<Text c={"dimmed"} size={"xs"}>
|
||||
Statusfelder
|
||||
</Text>
|
||||
<RenderFieldCheckboxes formKey={"statusSchemaFields"} schema={event.defaultEntryStatusSchema}/>
|
||||
</div>
|
||||
</Fieldset>
|
||||
|
||||
{
|
||||
lists.map(list => <Fieldset key={list.id} legend={list.name}>
|
||||
<div className={"stack"}>
|
||||
<Text c={"dimmed"} size={"xs"}>
|
||||
Formularfelder
|
||||
</Text>
|
||||
<RenderFieldCheckboxes formKey={"questionSchemaFields"} schema={list.entryQuestionSchema}/>
|
||||
<Text c={"dimmed"} size={"xs"}>
|
||||
Statusfelder
|
||||
</Text>
|
||||
<RenderFieldCheckboxes formKey={"statusSchemaFields"} schema={list.entryStatusSchema}/>
|
||||
</div>
|
||||
</Fieldset>)
|
||||
}
|
||||
|
||||
<Select
|
||||
label={"CSV-Trennzeichen"}
|
||||
placeholder={"Wähle ein Trennzeichen"}
|
||||
data={[
|
||||
{value: ",", label: "Komma"},
|
||||
{value: ";", label: "Semikolon"},
|
||||
{value: "\t", label: "Tabulator"}
|
||||
]}
|
||||
{...formValues.getInputProps("separator")}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={"Zeichensatz"}
|
||||
placeholder={"Wähle einen Zeichensatz"}
|
||||
data={CHARSETS.map(c => ({value: c, label: c.toUpperCase()}))}
|
||||
{...formValues.getInputProps("charset")}
|
||||
/>
|
||||
|
||||
<Group>
|
||||
<Button onClick={onClose} variant={"light"} color={"orange"}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={createCSV}
|
||||
disabled={dataIsFetching}
|
||||
loading={dataIsFetching}
|
||||
leftSection={<IconDownload/>}
|
||||
>
|
||||
CSV herunterladen
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
</Modal>
|
||||
}
|
|
@ -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
|
||||
</Button>
|
||||
|
||||
<Button size={"xs"} leftSection={<IconCsv size={16}/>} disabled>
|
||||
<Button
|
||||
size={"xs"}
|
||||
disabled={!canEditEvent}
|
||||
leftSection={<IconCsv size={16}/>}
|
||||
onClick={showDownloadModalHandler.toggle}
|
||||
>
|
||||
Daten exportieren
|
||||
</Button>
|
||||
</Group>
|
||||
|
@ -220,6 +230,14 @@ export default function ListSearch({event}: { event: EventModel }) {
|
|||
|
||||
<EventEntries event={event} entries={entries} refetch={() => query.refetch()}/>
|
||||
|
||||
<DownloadDataModal
|
||||
event={event}
|
||||
opened={showDownloadModal}
|
||||
onClose={showDownloadModalHandler.toggle}
|
||||
query={query}
|
||||
lists={formValues.values.selectedLists}
|
||||
/>
|
||||
|
||||
{query.hasNextPage && (
|
||||
<Center p={"xs"}>
|
||||
<Button
|
||||
|
|
|
@ -13,6 +13,7 @@ import {useNavigate} from "react-router-dom";
|
|||
import {getListSchemas} from "@/pages/events/util.ts";
|
||||
import {RenderDateRange} from "@/pages/events/e/:eventId/EventLists/EventListComponents/RenderDateRange.tsx";
|
||||
import SlotProgress from "@/pages/events/e/:eventId/EventLists/EventListComponents/SlotProgress.tsx";
|
||||
import EventLoginWarning from "@/pages/events/s/EventLoginWarning.tsx";
|
||||
|
||||
export default function EventListSlotView({slot, list, refetch}: {
|
||||
list: EventListModel,
|
||||
|
@ -75,26 +76,26 @@ export default function EventListSlotView({slot, list, refetch}: {
|
|||
}
|
||||
|
||||
{
|
||||
|
||||
list.onlyStuVeAccounts && user?.REALM !== "LDAP" ? <>
|
||||
<Alert color={"red"}>
|
||||
Für diese Liste sind nur StuVe-Accounts zugelassen
|
||||
</Alert>
|
||||
</> : slotIsInPast ? <>
|
||||
<Alert color={"red"}>
|
||||
Dieser Zeitslot ist bereits vorbei
|
||||
</Alert>
|
||||
</> : slotIsFull ? <>
|
||||
<Alert color={"red"} title={"Zeitslot voll"}>
|
||||
Dieser Zeitslot ist bereits voll
|
||||
!user ? <EventLoginWarning/> :
|
||||
list.onlyStuVeAccounts && user?.REALM !== "LDAP" ? <>
|
||||
<Alert color={"red"}>
|
||||
Für diese Liste sind nur StuVe-Accounts zugelassen
|
||||
</Alert>
|
||||
</>
|
||||
:
|
||||
<FormInput
|
||||
disabled={!user || slotIsFull}
|
||||
schema={questionSchema}
|
||||
onSubmit={createEntryMutation.mutateAsync}
|
||||
/>
|
||||
</> : slotIsInPast ? <>
|
||||
<Alert color={"red"}>
|
||||
Dieser Zeitslot ist bereits vorbei
|
||||
</Alert>
|
||||
</> : slotIsFull ? <>
|
||||
<Alert color={"red"} title={"Zeitslot voll"}>
|
||||
Dieser Zeitslot ist bereits voll
|
||||
</Alert>
|
||||
</>
|
||||
:
|
||||
<FormInput
|
||||
disabled={!user || slotIsFull}
|
||||
schema={questionSchema}
|
||||
onSubmit={createEntryMutation.mutateAsync}
|
||||
/>
|
||||
}
|
||||
</Collapse>
|
||||
</>
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
import {usePB} from "@/lib/pocketbase.tsx";
|
||||
import {ActionIcon, Alert, Text} from "@mantine/core";
|
||||
import {IconLogin} from "@tabler/icons-react";
|
||||
import {useLogin} from "@/components/users/modals/hooks.ts";
|
||||
|
||||
/**
|
||||
* Display a warning if the user is not logged in
|
||||
* @constructor
|
||||
*/
|
||||
export default function EventLoginWarning() {
|
||||
const {user} = usePB()
|
||||
const {handler: loginHandler} = useLogin()
|
||||
|
||||
return <>
|
||||
{!user && <div className={"section-transparent"}>
|
||||
<Alert icon={
|
||||
<ActionIcon
|
||||
color={"green"}
|
||||
variant={"transparent"}
|
||||
onClick={loginHandler.open}
|
||||
>
|
||||
<IconLogin/>
|
||||
</ActionIcon>
|
||||
} color={"orange"}>
|
||||
Um dich in eine Liste für dieses Event einzutragen, musst du dich
|
||||
{" "}
|
||||
<Text
|
||||
onClick={loginHandler.open}
|
||||
size={"sm"}
|
||||
fw={700}
|
||||
span
|
||||
td={"underline"}
|
||||
style={{cursor: "pointer"}}
|
||||
>
|
||||
anmelden oder einen Gastaccount erstellen
|
||||
</Text>
|
||||
</Alert>
|
||||
</div>}
|
||||
</>
|
||||
}
|
|
@ -2,31 +2,19 @@ import {Link, useParams, useSearchParams} from "react-router-dom";
|
|||
import {usePB} from "@/lib/pocketbase.tsx";
|
||||
import {useQuery} from "@tanstack/react-query";
|
||||
import NotFound from "../../not-found/index.page.tsx";
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Text,
|
||||
Title
|
||||
} from "@mantine/core";
|
||||
import {Accordion, Alert, Anchor, Breadcrumbs, Button, Center, Group, Loader, Title} from "@mantine/core";
|
||||
import PBAvatar from "@/components/PBAvatar.tsx";
|
||||
import InnerHtml from "@/components/InnerHtml";
|
||||
import {IconArchive, IconExternalLink, IconEye, IconLogin, IconPencil, IconSectionSign} from "@tabler/icons-react";
|
||||
import {IconArchive, IconExternalLink, IconEye, IconPencil, IconSectionSign} from "@tabler/icons-react";
|
||||
import EventData from "@/pages/events/e/:eventId/EventComponents/EventData.tsx";
|
||||
import EventListView from "@/pages/events/s/EventListView.tsx";
|
||||
import {useEventRights} from "@/pages/events/util.ts";
|
||||
import {useLogin} from "@/components/users/modals/hooks.ts";
|
||||
import EventLoginWarning from "@/pages/events/s/EventLoginWarning.tsx";
|
||||
|
||||
|
||||
export default function SharedEvent() {
|
||||
|
||||
const {pb, user} = usePB()
|
||||
const {pb} = usePB()
|
||||
|
||||
|
||||
const {eventId} = useParams() as { eventId: string }
|
||||
|
@ -43,9 +31,6 @@ export default function SharedEvent() {
|
|||
|
||||
const {isPrivilegedUser, canEditEvent} = useEventRights(eventQuery.data)
|
||||
|
||||
const {handler: loginHandler} = useLogin()
|
||||
|
||||
|
||||
if (eventQuery.isLoading) {
|
||||
return <Center h={"100%"}><Loader/></Center>
|
||||
}
|
||||
|
@ -81,30 +66,7 @@ export default function SharedEvent() {
|
|||
))}</Breadcrumbs>
|
||||
</div>
|
||||
|
||||
{!user && <div className={"section-transparent"}>
|
||||
<Alert icon={
|
||||
<ActionIcon
|
||||
color={"green"}
|
||||
variant={"transparent"}
|
||||
onClick={loginHandler.open}
|
||||
>
|
||||
<IconLogin/>
|
||||
</ActionIcon>
|
||||
} color={"orange"}>
|
||||
Um dich in eine Liste für dieses Event einzutragen, musst du dich
|
||||
{" "}
|
||||
<Text
|
||||
onClick={loginHandler.open}
|
||||
size={"sm"}
|
||||
fw={700}
|
||||
span
|
||||
td={"underline"}
|
||||
style={{cursor: "pointer"}}
|
||||
>
|
||||
anmelden oder einen Gastaccount erstellen
|
||||
</Text>
|
||||
</Alert>
|
||||
</div>}
|
||||
<EventLoginWarning/>
|
||||
|
||||
{eventIsArchived && <div className={"section-transparent"}>
|
||||
<Alert color={"orange"} icon={<IconArchive/>}>
|
||||
|
|
Loading…
Reference in New Issue