feat(announcements): you can now send announcements to entries
Build and Push Docker image / build-and-push (push) Successful in 4m35s Details

This commit is contained in:
Valentin Kolb 2024-06-17 17:11:28 +02:00
parent d6b7957640
commit 594c780f42
12 changed files with 424 additions and 94 deletions

View File

@ -38,9 +38,11 @@
"@yudiel/react-qr-scanner": "^2.0.2", "@yudiel/react-qr-scanner": "^2.0.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"file-saver": "^2.0.5",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"ms": "^2.1.3", "ms": "^2.1.3",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"papaparse": "^5.4.1",
"pocketbase": "^0.19.0", "pocketbase": "^0.19.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-big-calendar": "^1.11.3", "react-big-calendar": "^1.11.3",
@ -58,11 +60,14 @@
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@types/file-saver": "^2.0.7",
"@types/ms": "^0.7.33", "@types/ms": "^0.7.33",
"@types/node": "^20.12.10", "@types/node": "^20.12.10",
"@types/papaparse": "^5.3.14",
"@types/react": "^18.2.15", "@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"@types/sanitize-html": "^2.11.0", "@types/sanitize-html": "^2.11.0",
"@types/wicg-file-system-access": "^2023.10.5",
"@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0", "@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react-swc": "^3.3.2", "@vitejs/plugin-react-swc": "^3.3.2",
@ -74,7 +79,7 @@
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"sass": "^1.75.0", "sass": "^1.75.0",
"typescript": "^5.0.2", "typescript": "^5.0.2",
"vite": "^4.4.5", "vite": "5.1.0-beta.2",
"vite-plugin-svgr": "^4.2.0" "vite-plugin-svgr": "^4.2.0"
} }
} }

View File

@ -43,7 +43,7 @@ const NavItems = [
items: [ items: [
{ {
title: "Übersicht", title: "Alle Events",
icon: IconConfetti, icon: IconConfetti,
description: "Übersicht über alle Events.", description: "Übersicht über alle Events.",
link: "/events" link: "/events"

View File

@ -19,6 +19,7 @@ import {
import LoginSVG from "@/illustrations/boy-with-key.svg?react" import LoginSVG from "@/illustrations/boy-with-key.svg?react"
import {useForgotPassword, useLogin, useRegister} from "@/components/users/modals/hooks.ts"; import {useForgotPassword, useLogin, useRegister} from "@/components/users/modals/hooks.ts";
import {showSuccessNotification} from "@/components/util.tsx"; import {showSuccessNotification} from "@/components/util.tsx";
import {useEffect} from "react";
export default function LoginModal() { export default function LoginModal() {
@ -55,8 +56,15 @@ export default function LoginModal() {
} }
}) })
useEffect(() => {
if (user) {
handler.close()
}
// eslint-disable-next-line
}, [user])
return <> return <>
<Modal opened={value && !user} onClose={handler.close} withCloseButton={false} size={"sm"}> <Modal opened={value} onClose={handler.close} withCloseButton={false} size={"sm"}>
<form className={"stack"} onSubmit={formValues.onSubmit(() => loginMutation.mutate())}> <form className={"stack"} onSubmit={formValues.onSubmit(() => loginMutation.mutate())}>
<Title ta={"center"} order={3}>StuVe IT Login</Title> <Title ta={"center"} order={3}>StuVe IT Login</Title>

61
src/lib/csv.ts Normal file
View File

@ -0,0 +1,61 @@
import Papa from 'papaparse';
export const CSV_CHARSETS = [
"utf-8", "ISO-8859-1", "windows-1252", "utf-16be", "utf-16le", "us-ascii"
] as const
/**
* Asynchronously downloads a CSV file.
*
* @param {Object} params - The parameters for the CSV file.
* @param {unknown[]} [params.headers] - The headers for the CSV file.
* @param {unknown[][]} params.data - The data for the CSV file.
* @param {("utf-8"|"ISO-8859-1"|"windows-1252"|"utf-16be"|"utf-16le"|"us-ascii")} [params.chatset] - The character set for the CSV file. Defaults to 'utf-8'.
* @param {string} [params.filename] - The filename for the CSV file. Defaults to 'data.csv'.
*
* @throws {Error} Will throw an error if the File System Access API is not available in the browser or if an error occurs while saving the file.
*/
export const downloadCsv = async ({headers, data, chatset, filename, delimiter}: {
headers?: unknown[],
data: unknown[][],
chatset?: string,
filename?: string,
delimiter?: string
}) => {
// Combine headers and data
const csvData = [(headers ?? []), ...data]
console.log(csvData)
// Convert to CSV string
const csvString = Papa.unparse(csvData, {delimiter})
// Create a blob from the CSV string
const blob = new Blob([csvString], {
type: `text/csv;charset=${chatset ?? 'utf-8'};`
})
// Check if the File System Access API is available
if (window.showSaveFilePicker) {
// Show file save dialog and write to file
const handle = await window.showSaveFilePicker({
suggestedName: filename ?? 'data.csv',
types: [
{
description: 'CSV File',
accept: {'text/csv': ['.csv']},
},
],
})
const writable = await handle.createWritable()
await writable.write(blob)
await writable.close()
} else {
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename ?? 'data.csv'
a.style.display = "hidden"
a.click()
URL.revokeObjectURL(url)
}
}

View File

@ -4,7 +4,7 @@ import {EventListModel} from "@/models/EventTypes.ts";
export type MessagesModel = { export type MessagesModel = {
sender: string sender: string
recipient: string | null recipients: string[]
eventList: string | null eventList: string | null
subject: string | null subject: string | null
@ -15,9 +15,13 @@ export type MessagesModel = {
isAnnouncement: boolean | null isAnnouncement: boolean | null
meta: {
event?: string
} | null
expand: { expand: {
sender: UserModal sender: UserModal
recipient: UserModal recipients: UserModal[]
eventList: EventListModel | null eventList: EventListModel | null
repliedTo: MessagesModel | null repliedTo: MessagesModel | null
} }

View File

@ -1,15 +1,31 @@
import InnerHtml from "@/components/InnerHtml"; import InnerHtml from "@/components/InnerHtml";
import classes from './Announcement.module.css' import classes from './Announcement.module.css'
import {Group, Text, ThemeIcon, Tooltip} from "@mantine/core"; import {Alert, Center, Group, List, Loader, Text, ThemeIcon, Tooltip} from "@mantine/core";
import {IconSpeakerphone} from "@tabler/icons-react"; import {IconSpeakerphone} from "@tabler/icons-react";
import {MessagesModel} from "@/models/MessageTypes.ts"; import {MessagesModel} from "@/models/MessageTypes.ts";
import {getUserName} from "@/components/users/modals/util.tsx"; import {getUserName} from "@/components/users/modals/util.tsx";
import {humanDeltaFromNow, pprintDate} from "@/lib/datetime.ts"; import {humanDeltaFromNow, pprintDate, pprintDateRange} from "@/lib/datetime.ts";
import {useQuery} from "@tanstack/react-query";
import {usePB} from "@/lib/pocketbase.tsx";
import {Link} from "react-router-dom";
export default function Announcement({announcement}: { export default function Announcement({announcement}: {
announcement: MessagesModel announcement: MessagesModel
}) { }) {
const senderName = getUserName(announcement.expand.sender) const senderName = getUserName(announcement.expand.sender)
const eventId = announcement.meta?.event
const {pb, user} = usePB()
const entriesQuery = useQuery({
queryKey: ["eventListSlotEntries", "announcement", eventId],
queryFn: async () => {
return await pb.collection("eventListSlotEntriesWithUser").getList(1, 10, {
filter: `event='${eventId}'&&user='${user!.id}'`,
sort: "-created"
})
},
enabled: !!eventId
})
return <div className={classes.announcement}> return <div className={classes.announcement}>
<Group justify={"space-between"} wrap={"nowrap"} align={"top"} mb={"md"}> <Group justify={"space-between"} wrap={"nowrap"} align={"top"} mb={"md"}>
<div className={classes.subjectStack}> <div className={classes.subjectStack}>
@ -18,7 +34,7 @@ export default function Announcement({announcement}: {
</div>} </div>}
<Text size={"xs"} c={"dimmed"} className={"wrapWords"}> <Text size={"xs"} c={"dimmed"} className={"wrapWords"}>
{senderName} {pprintDate(announcement.created)} {humanDeltaFromNow(announcement.created).message} von {senderName} {pprintDate(announcement.created)} {humanDeltaFromNow(announcement.created).message}
</Text> </Text>
</div> </div>
@ -32,6 +48,38 @@ export default function Announcement({announcement}: {
</Tooltip> </Tooltip>
</Group> </Group>
{(eventId && entriesQuery.data?.totalItems !== 0) && (
entriesQuery.isFetching ? <Center><Loader size={"xs"}/></Center> :
<Alert mb="md">
<Text>
Du hast bei dem Event
{" "}
<Text td={"underline"} component={Link} to={`/events/e/${eventId}`}>{
entriesQuery.data?.items[0].eventName
}</Text>
{" "}
{
entriesQuery.data?.totalItems === 1 ? "einen Eintrag" : `${entriesQuery.data?.totalItems} Einträge`
}
{"."}
</Text>
<List size={"sm"} c={"dimmed"}>
{entriesQuery.data?.items.map((entry) => (
<List.Item key={entry.id}>
{entry.listName}
{", Zeitslot "}
{pprintDateRange(entry.slotStartDate, entry.slotEndDate)}
{", "}
{humanDeltaFromNow(entry.created).message}
</List.Item>
))}
</List>
<Text size={"sm"} td={"underline"} component={Link} to={"/events/entries"}>Hier</Text> kannst du
alle deine Einträge einsehen und bearbeiten.
</Alert>
)}
<InnerHtml html={announcement.content}/> <InnerHtml html={announcement.content}/>
</div> </div>
} }

View File

@ -1,6 +1,6 @@
import {EventListModel, EventListSlotEntriesWithUserModel, EventModel} from "@/models/EventTypes.ts"; import {EventListModel, EventListSlotEntriesWithUserModel, EventModel} from "@/models/EventTypes.ts";
import {ListResult} from "pocketbase"; import {ListResult} from "pocketbase";
import {InfiniteData, UseInfiniteQueryResult} from "@tanstack/react-query"; import {InfiniteData, UseInfiniteQueryResult, useMutation} from "@tanstack/react-query";
import {Button, Checkbox, Fieldset, Group, Modal, Progress, Select, Text} from "@mantine/core"; import {Button, Checkbox, Fieldset, Group, Modal, Progress, Select, Text} from "@mantine/core";
import ShowHelp from "@/components/ShowHelp.tsx"; import ShowHelp from "@/components/ShowHelp.tsx";
import {useForm} from "@mantine/form"; import {useForm} from "@mantine/form";
@ -9,16 +9,15 @@ import {FormSchema} from "@/components/formUtil/formBuilder/types.ts";
import {IconDownload} from "@tabler/icons-react"; import {IconDownload} from "@tabler/icons-react";
import {useShowDebug} from "@/components/ShowDebug.tsx"; import {useShowDebug} from "@/components/ShowDebug.tsx";
import {onlyUnique} from "@/lib/util.ts"; import {onlyUnique} from "@/lib/util.ts";
import {CSV_CHARSETS, downloadCsv} from "@/lib/csv.ts";
const CHARSETS = [ import {showSuccessNotification} from "@/components/util.tsx";
"utf-8", "ISO-8859-1", "windows-1252", "utf-16be", "utf-16le", "us-ascii" import {PocketBaseErrorAlert} from "@/lib/pocketbase.tsx";
] as const
type FormValues = { type FormValues = {
questionSchemaFields: string[] questionSchemaFields: string[]
statusSchemaFields: string[], statusSchemaFields: string[],
separator: "," | ";" | "\t", separator: "," | ";" | "\t",
charset: typeof CHARSETS[number] charset: typeof CSV_CHARSETS[number]
} }
export default function DownloadDataModal({opened, onClose, lists, event, query}: { export default function DownloadDataModal({opened, onClose, lists, event, query}: {
@ -68,84 +67,82 @@ export default function DownloadDataModal({opened, onClose, lists, event, query}
const totalPages = query.data?.pages?.[0].totalPages || 0 const totalPages = query.data?.pages?.[0].totalPages || 0
const currentPage = query.data?.pages?.length || 0 const currentPage = query.data?.pages?.length || 0
const progress = currentPage / totalPages * 100 const progress = (currentPage / totalPages * 100) || 0
const dataIsFetching = query.isFetching || query.isPending const dataIsFetching = query.isFetching || query.isPending
const createCSV = () => { const mutation = useMutation({
// all loaded entries mutationFn: async () => {
const entries = query.data?.pages.flatMap(p => p.items) ?? [] // all loaded entries
const entries = query.data?.pages.flatMap(p => p.items) ?? []
const selectedFields = { const selectedFields = {
questionSchemaFields: formValues.values.questionSchemaFields.filter(onlyUnique), questionSchemaFields: formValues.values.questionSchemaFields.filter(onlyUnique),
statusSchemaFields: formValues.values.statusSchemaFields.filter(onlyUnique) statusSchemaFields: formValues.values.statusSchemaFields.filter(onlyUnique)
} }
// which question and status fields to include in the CSV // which question and status fields to include in the CSV
const questionSchema = { const questionSchema = {
fields: [ fields: [
...event.defaultEntryQuestionSchema?.fields.filter(f => selectedFields.questionSchemaFields.includes(f.id)) ?? [], ...event.defaultEntryQuestionSchema?.fields.filter(f => selectedFields.questionSchemaFields.includes(f.id)) ?? [],
...lists.flatMap(l => l.entryQuestionSchema?.fields.filter(f => selectedFields.questionSchemaFields.includes(f.id)) ?? []) ...lists.flatMap(l => l.entryQuestionSchema?.fields.filter(f => selectedFields.questionSchemaFields.includes(f.id)) ?? [])
].filter((v, i, arr) => arr.findIndex(t => (t.id === v.id)) === i) ].filter((v, i, arr) => arr.findIndex(t => (t.id === v.id)) === i)
} }
const statusSchema = { const statusSchema = {
fields: [ fields: [
...event.defaultEntryStatusSchema?.fields.filter(f => selectedFields.statusSchemaFields.includes(f.id)) ?? [], ...event.defaultEntryStatusSchema?.fields.filter(f => selectedFields.statusSchemaFields.includes(f.id)) ?? [],
...lists.flatMap(l => l.entryStatusSchema?.fields.filter(f => selectedFields.statusSchemaFields.includes(f.id)) ?? []) ...lists.flatMap(l => l.entryStatusSchema?.fields.filter(f => selectedFields.statusSchemaFields.includes(f.id)) ?? [])
].filter((v, i, arr) => arr.findIndex(t => (t.id === v.id)) === i) ].filter((v, i, arr) => arr.findIndex(t => (t.id === v.id)) === i)
} }
/** /**
* Escapes the csv separator if it is present in the string * Escapes the csv separator if it is present in the string
* *
* trims the string in any case * trims the string in any case
* @param str the string to escape * @param str the string to escape
*/ */
const escapeSeparator = (str: string) => { const escapeSeparator = (str: string) => {
if (str.includes(formValues.values.separator)) { if (str.includes(formValues.values.separator)) {
return `"${str.trim()}"` return `"${str.trim()}"`
}
return str.trim()
}
// assemble the header (first question fields, then status fields)
const header = [
...(showDebug ? ["Person ID", "Eintrags ID"] : []),
"Person", "Anmeldezeitpunkt", "Anmelde-Liste", "Slot Start", "Slot End",
...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 [
...(showDebug ? [e.user, e.id] : []),
e.expand?.user.username ?? "N/A", e.created, e.listName, e.slotStartDate, e.slotEndDate,
...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)
]
})
const csvName = `${event.name.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_export.csv`
await downloadCsv({
headers: header,
data,
chatset: formValues.values.charset,
filename: csvName,
delimiter: formValues.values.separator
})
},
onSuccess: () => {
showSuccessNotification("Daten wurden heruntergeladen")
onClose()
} }
return str.trim()
} }
)
// assemble the header (first question fields, then status fields)
const header = [
...(showDebug ? ["Person ID", "Eintrags ID"] : []),
"Person", "Anmeldezeitpunkt", "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 [
...(showDebug ? [e.user, e.id] : []),
e.expand?.user.username ?? "N/A", e.created, 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 return <Modal
opened={opened} opened={opened}
@ -156,7 +153,7 @@ export default function DownloadDataModal({opened, onClose, lists, event, query}
<div className={"stack"}> <div className={"stack"}>
<ShowHelp> <ShowHelp>
In der CSV sind automatisch die Felder <em>Person</em>, <em>Anmeldezeitpunkt</em>, In der CSV sind automatisch die Felder <em>Person</em>, <em>Anmeldezeitpunkt</em>,
und <em>Anmelde-Liste</em> enthalten. Diese <em>Anmelde-Liste</em> und <em>Zeitslot</em> enthalten. Diese
Felder können nicht abgewählt werden. Felder können nicht abgewählt werden.
<br/> <br/>
Um Listen spezifische Daten herunterzuladen, wähle im vorherigen Schritt eine oder mehrere Listen aus. Um Listen spezifische Daten herunterzuladen, wähle im vorherigen Schritt eine oder mehrere Listen aus.
@ -216,16 +213,18 @@ export default function DownloadDataModal({opened, onClose, lists, event, query}
<Select <Select
label={"Zeichensatz"} label={"Zeichensatz"}
placeholder={"Wähle einen Zeichensatz"} placeholder={"Wähle einen Zeichensatz"}
data={CHARSETS.map(c => ({value: c, label: c.toUpperCase()}))} data={CSV_CHARSETS.map(c => ({value: c, label: c.toUpperCase()}))}
{...formValues.getInputProps("charset")} {...formValues.getInputProps("charset")}
/> />
<PocketBaseErrorAlert error={mutation.error}/>
<Group> <Group>
<Button onClick={onClose} variant={"light"} color={"orange"}> <Button onClick={onClose} variant={"light"} color={"orange"}>
Abbrechen Abbrechen
</Button> </Button>
<Button <Button
onClick={createCSV} onClick={() => mutation.mutate()}
disabled={dataIsFetching} disabled={dataIsFetching}
loading={dataIsFetching} loading={dataIsFetching}
leftSection={<IconDownload/>} leftSection={<IconDownload/>}

View File

@ -0,0 +1,153 @@
import {EventListModel, EventListSlotEntriesWithUserModel, EventModel} from "@/models/EventTypes.ts";
import {ListResult} from "pocketbase";
import {InfiniteData, UseInfiniteQueryResult, useMutation} from "@tanstack/react-query";
import {Button, Group, Modal, Progress, Text, Textarea, TextInput, Title} from "@mantine/core";
import {IconSend} from "@tabler/icons-react";
import {hasLength, useForm} from "@mantine/form";
import ShowHelp from "@/components/ShowHelp.tsx";
import TextEditor from "@/components/input/Editor";
import ListSelect from "@/pages/events/e/:eventId/EventLists/ListSelect.tsx";
import {showSuccessNotification} from "@/components/util.tsx";
import {usePB} from "@/lib/pocketbase.tsx";
export default function MessageEntriesModal({opened, event, onClose, query}: {
opened: boolean
onClose: () => void,
event: EventModel,
query: UseInfiniteQueryResult<InfiniteData<ListResult<EventListSlotEntriesWithUserModel>, unknown>, Error>
}) {
const {pb} = usePB()
const totalPages = query.data?.pages?.[0].totalPages || 0
const currentPage = query.data?.pages?.length || 0
const progress = (currentPage / totalPages * 100) || 0
const dataIsFetching = query.isFetching || query.isPending
const entries = query.data?.pages.flatMap(p => p.items) ?? []
const formValues = useForm({
initialValues: {
subject: "",
content: "",
comment: "",
selectedLists: [] as EventListModel[],
},
validate: {
subject: hasLength({max: 255}, "Der Betreff ist zu lang"),
content: hasLength({min: 10, max: 5000}, "Die Nachricht muss zwischen 10 und 5000 Zeichen lang sein"),
}
})
const mutation = useMutation({
mutationFn: async () => {
await pb.collection("messages").create({
subject: formValues.values.subject ?? undefined,
content: formValues.values.content,
recipients: entries.map(e => e.user),
isAnnouncement: true,
meta: {
event: event.id
}
})
for (const l of formValues.values.selectedLists) {
await pb.collection("messages").create({
content: formValues.values.content,
comment: formValues.values.comment ?? undefined,
eventList: l.id,
})
}
},
onSuccess: () => {
showSuccessNotification("Nachrichten wurden versendet")
onClose()
}
})
return <Modal
opened={opened}
onClose={onClose}
title="Personen benachrichtigen"
size="xl"
>
<form
className={"stack"}
onSubmit={formValues.onSubmit(() => {
mutation.mutate()
})}
>
{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>
</>}
<ShowHelp>
Mit dieser Funktion kannst du eine Ankündigung an alle Personen senden,
die du in der vorherigen Suche gefunden hast.
<br/>
Die Ankündigung enthält als zusätzliche Information den Namen des Events und
alle Anmeldungen der jeweiligen Person.
<br/>
Wenn du eine Liste (z.B. Orga-Team) darüber informieren möchtest, dass du
die Ankündigung verschickt hast, kannst du diese unter BCC auswählen. Über
den Kommentar kannst du dieser Liste zusätzliche Informationen mitteilen, z.B.
welchem Personenkreis die Nachricht gesendet wurde.
</ShowHelp>
<ListSelect
event={event}
selectedRecords={formValues.values.selectedLists}
setSelectedRecords={(ls) => formValues.setFieldValue("selectedLists", ls)}
placeholder={"BCC"}
/>
{
formValues.values.selectedLists.length > 0 && <>
<Textarea
placeholder={"Kommentar an die BCC-Listen"}
{...formValues.getInputProps("comment")}
/>
<Title order={3}>
Nachricht
</Title>
</>
}
<TextInput
placeholder={"Betreff"}
{...formValues.getInputProps("subject")}
/>
<TextEditor
placeholder={"Nachricht"}
value={formValues.values.content}
fullToolbar
onChange={content => formValues.setFieldValue("content", content)}
error={formValues.errors.content}
/>
<Group>
<Button onClick={onClose} variant={"light"} color={"orange"}>
Abbrechen
</Button>
<Button
type={"submit"}
disabled={dataIsFetching}
loading={dataIsFetching}
leftSection={<IconSend/>}
>
{entries.length ?? 0} Personen benachrichtigen
</Button>
</Group>
</form>
</Modal>
}

View File

@ -34,6 +34,7 @@ import {DateTimePicker} from "@mantine/dates";
import EventEntries from "@/pages/events/e/:eventId/EventLists/Search/EventEntries.tsx"; import EventEntries from "@/pages/events/e/:eventId/EventLists/Search/EventEntries.tsx";
import {useEventRights} from "@/pages/events/util.ts"; import {useEventRights} from "@/pages/events/util.ts";
import DownloadDataModal from "@/pages/events/e/:eventId/EventLists/Search/DownloadDataModal.tsx"; import DownloadDataModal from "@/pages/events/e/:eventId/EventLists/Search/DownloadDataModal.tsx";
import MessageEntriesModal from "@/pages/events/e/:eventId/EventLists/Search/MessageEntriesModal.tsx";
export default function ListSearch({event}: { event: EventModel }) { export default function ListSearch({event}: { event: EventModel }) {
@ -43,6 +44,7 @@ export default function ListSearch({event}: { event: EventModel }) {
const [showFilter, showFilterHandler] = useDisclosure(true) const [showFilter, showFilterHandler] = useDisclosure(true)
const [showDownloadModal, showDownloadModalHandler] = useDisclosure(false) const [showDownloadModal, showDownloadModalHandler] = useDisclosure(false)
const [showMessageModal, showMessageModalHandler] = useDisclosure(false)
const formValues = useForm({ const formValues = useForm({
initialValues: { initialValues: {
@ -73,10 +75,14 @@ export default function ListSearch({event}: { event: EventModel }) {
const nameFilter = [] as string[] const nameFilter = [] as string[]
// search for username, givenName or surname
usernameQuery && nameFilter.push(`user.username~'${usernameQuery}'`) usernameQuery && nameFilter.push(`user.username~'${usernameQuery}'`)
surname && nameFilter.push(`user.sn~'${surname}'`) surname && nameFilter.push(`user.sn~'${surname}'`)
givenName && nameFilter.push(`user.givenName~'${givenName}'`) givenName && nameFilter.push(`user.givenName~'${givenName}'`)
// search for user id
nameFilter.push(`user.id~'${usernameQuery}'`)
filter.push(`(${nameFilter.join("||")})`) filter.push(`(${nameFilter.join("||")})`)
} }
@ -102,7 +108,7 @@ export default function ListSearch({event}: { event: EventModel }) {
const query = useInfiniteQuery({ const query = useInfiniteQuery({
queryKey: ["eventListSearch", {event: event.id}, {filter: filterString(debouncedFormValues)}], queryKey: ["eventListSearch", {event: event.id}, {filter: filterString(debouncedFormValues)}],
queryFn: async ({pageParam}) => { queryFn: async ({pageParam}) => {
return await pb.collection("eventListSlotEntriesWithUser").getList(pageParam, 50, { return await pb.collection("eventListSlotEntriesWithUser").getList(pageParam, 100, {
filter: filterString(debouncedFormValues), filter: filterString(debouncedFormValues),
expand: "user" expand: "user"
}) })
@ -210,7 +216,12 @@ export default function ListSearch({event}: { event: EventModel }) {
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
<Button size={"xs"} leftSection={<IconSend size={16}/>} disabled> <Button
size={"xs"}
leftSection={<IconSend size={16}/>}
disabled={!canEditEvent}
onClick={showMessageModalHandler.toggle}
>
{entriesCount} Personen benachrichtigen {entriesCount} Personen benachrichtigen
</Button> </Button>
@ -250,6 +261,13 @@ export default function ListSearch({event}: { event: EventModel }) {
lists={formValues.values.selectedLists} lists={formValues.values.selectedLists}
/> />
<MessageEntriesModal
opened={showMessageModal}
onClose={showMessageModalHandler.toggle}
query={query}
event={event}
/>
{query.hasNextPage && ( {query.hasNextPage && (
<Center p={"xs"}> <Center p={"xs"}>
<Button <Button

View File

@ -10,7 +10,6 @@ import {useState} from "react";
export default function DebugPage() { export default function DebugPage() {
const {showDebug} = useShowDebug() const {showDebug} = useShowDebug()
const {pb} = usePB() const {pb} = usePB()
@ -38,7 +37,7 @@ export default function DebugPage() {
if (formValues.values.expand) options["expand"] = formValues.values.expand if (formValues.values.expand) options["expand"] = formValues.values.expand
return await pb.collection(formValues.values.collectionName).getList(page, 10, options) return await pb.collection(formValues.values.collectionName).getList(page, 100, options)
}, },
enabled: formValues.values.collectionName !== "" enabled: formValues.values.collectionName !== ""
}) })
@ -113,7 +112,7 @@ export default function DebugPage() {
variant={"transparent"} variant={"transparent"}
size={"sm"} size={"sm"}
aria-label={"Refetch"} aria-label={"Refetch"}
disabled={debugQuery.isLoading} disabled={debugQuery.isFetching}
> >
<IconRefresh/> <IconRefresh/>
</ActionIcon> </ActionIcon>

View File

@ -32,7 +32,8 @@
] ]
}, },
"types": [ "types": [
"vite-plugin-svgr/client" "vite-plugin-svgr/client",
"@types/wicg-file-system-access"
] ]
}, },
"include": [ "include": [

View File

@ -1011,6 +1011,11 @@
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4"
integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==
"@types/file-saver@^2.0.7":
version "2.0.7"
resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-2.0.7.tgz#8dbb2f24bdc7486c54aa854eb414940bbd056f7d"
integrity sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==
"@types/hast@^3.0.0": "@types/hast@^3.0.0":
version "3.0.4" version "3.0.4"
resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa" resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa"
@ -1048,6 +1053,13 @@
resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.33.tgz#80bf1da64b15f21fd8c1dc387c31929317d99ee9" resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.33.tgz#80bf1da64b15f21fd8c1dc387c31929317d99ee9"
integrity sha512-AuHIyzR5Hea7ij0P9q7vx7xu4z0C28ucwjAZC0ja7JhINyCnOw8/DnvAPQQ9TfOlCtZAmCERKQX9+o1mgQhuOQ== integrity sha512-AuHIyzR5Hea7ij0P9q7vx7xu4z0C28ucwjAZC0ja7JhINyCnOw8/DnvAPQQ9TfOlCtZAmCERKQX9+o1mgQhuOQ==
"@types/node@*":
version "20.14.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.2.tgz#a5f4d2bcb4b6a87bffcaa717718c5a0f208f4a18"
integrity sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==
dependencies:
undici-types "~5.26.4"
"@types/node@^20.12.10": "@types/node@^20.12.10":
version "20.12.10" version "20.12.10"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.10.tgz#8f0c3f12b0f075eee1fe20c1afb417e9765bef76" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.10.tgz#8f0c3f12b0f075eee1fe20c1afb417e9765bef76"
@ -1055,6 +1067,13 @@
dependencies: dependencies:
undici-types "~5.26.4" undici-types "~5.26.4"
"@types/papaparse@^5.3.14":
version "5.3.14"
resolved "https://registry.yarnpkg.com/@types/papaparse/-/papaparse-5.3.14.tgz#345cc2a675a90106ff1dc33b95500dfb30748031"
integrity sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==
dependencies:
"@types/node" "*"
"@types/prop-types@*": "@types/prop-types@*":
version "15.7.9" version "15.7.9"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.9.tgz#b6f785caa7ea1fe4414d9df42ee0ab67f23d8a6d" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.9.tgz#b6f785caa7ea1fe4414d9df42ee0ab67f23d8a6d"
@ -1130,6 +1149,11 @@
resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.3.tgz#d1884c8cc4a426d1ac117ca2611bf333834c6798" resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.3.tgz#d1884c8cc4a426d1ac117ca2611bf333834c6798"
integrity sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q== integrity sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==
"@types/wicg-file-system-access@^2023.10.5":
version "2023.10.5"
resolved "https://registry.yarnpkg.com/@types/wicg-file-system-access/-/wicg-file-system-access-2023.10.5.tgz#14b3c25eb4d914b5734795bdea71da229f918b9d"
integrity sha512-e9kZO9kCdLqT2h9Tw38oGv9UNzBBWaR1MzuAavxPcsV/7FJ3tWbU6RI3uB+yKIDPGLkGVbplS52ub0AcRLvrhA==
"@typescript-eslint/eslint-plugin@^6.0.0": "@typescript-eslint/eslint-plugin@^6.0.0":
version "6.9.0" version "6.9.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.9.0.tgz#fdb6f3821c0167e3356e9d89c80e8230b2e401f4" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.9.0.tgz#fdb6f3821c0167e3356e9d89c80e8230b2e401f4"
@ -1843,6 +1867,11 @@ file-entry-cache@^6.0.1:
dependencies: dependencies:
flat-cache "^3.0.4" flat-cache "^3.0.4"
file-saver@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-2.0.5.tgz#d61cfe2ce059f414d899e9dd6d4107ee25670c38"
integrity sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==
file-selector@^0.6.0: file-selector@^0.6.0:
version "0.6.0" version "0.6.0"
resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.6.0.tgz#fa0a8d9007b829504db4d07dd4de0310b65287dc" resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.6.0.tgz#fa0a8d9007b829504db4d07dd4de0310b65287dc"
@ -2896,6 +2925,11 @@ p-locate@^5.0.0:
dependencies: dependencies:
p-limit "^3.0.2" p-limit "^3.0.2"
papaparse@^5.4.1:
version "5.4.1"
resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.4.1.tgz#f45c0f871853578bd3a30f92d96fdcfb6ebea127"
integrity sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==
parent-module@^1.0.0: parent-module@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"