feat(announcements): you can now send announcements to entries
Build and Push Docker image / build-and-push (push) Successful in 4m35s
Details
Build and Push Docker image / build-and-push (push) Successful in 4m35s
Details
This commit is contained in:
parent
d6b7957640
commit
594c780f42
|
@ -38,9 +38,11 @@
|
|||
"@yudiel/react-qr-scanner": "^2.0.2",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.10",
|
||||
"file-saver": "^2.0.5",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"ms": "^2.1.3",
|
||||
"nanoid": "^5.0.7",
|
||||
"papaparse": "^5.4.1",
|
||||
"pocketbase": "^0.19.0",
|
||||
"react": "^18.2.0",
|
||||
"react-big-calendar": "^1.11.3",
|
||||
|
@ -58,11 +60,14 @@
|
|||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/ms": "^0.7.33",
|
||||
"@types/node": "^20.12.10",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/sanitize-html": "^2.11.0",
|
||||
"@types/wicg-file-system-access": "^2023.10.5",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
|
@ -74,7 +79,7 @@
|
|||
"postcss-simple-vars": "^7.0.1",
|
||||
"sass": "^1.75.0",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.5",
|
||||
"vite": "5.1.0-beta.2",
|
||||
"vite-plugin-svgr": "^4.2.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,7 +43,7 @@ const NavItems = [
|
|||
items: [
|
||||
|
||||
{
|
||||
title: "Übersicht",
|
||||
title: "Alle Events",
|
||||
icon: IconConfetti,
|
||||
description: "Übersicht über alle Events.",
|
||||
link: "/events"
|
||||
|
|
|
@ -19,6 +19,7 @@ import {
|
|||
import LoginSVG from "@/illustrations/boy-with-key.svg?react"
|
||||
import {useForgotPassword, useLogin, useRegister} from "@/components/users/modals/hooks.ts";
|
||||
import {showSuccessNotification} from "@/components/util.tsx";
|
||||
import {useEffect} from "react";
|
||||
|
||||
|
||||
export default function LoginModal() {
|
||||
|
@ -55,8 +56,15 @@ export default function LoginModal() {
|
|||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
handler.close()
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
}, [user])
|
||||
|
||||
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())}>
|
||||
<Title ta={"center"} order={3}>StuVe IT Login</Title>
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@ import {EventListModel} from "@/models/EventTypes.ts";
|
|||
|
||||
export type MessagesModel = {
|
||||
sender: string
|
||||
recipient: string | null
|
||||
recipients: string[]
|
||||
eventList: string | null
|
||||
|
||||
subject: string | null
|
||||
|
@ -15,9 +15,13 @@ export type MessagesModel = {
|
|||
|
||||
isAnnouncement: boolean | null
|
||||
|
||||
meta: {
|
||||
event?: string
|
||||
} | null
|
||||
|
||||
expand: {
|
||||
sender: UserModal
|
||||
recipient: UserModal
|
||||
recipients: UserModal[]
|
||||
eventList: EventListModel | null
|
||||
repliedTo: MessagesModel | null
|
||||
}
|
||||
|
|
|
@ -1,15 +1,31 @@
|
|||
import InnerHtml from "@/components/InnerHtml";
|
||||
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 {MessagesModel} from "@/models/MessageTypes.ts";
|
||||
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}: {
|
||||
announcement: MessagesModel
|
||||
}) {
|
||||
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}>
|
||||
<Group justify={"space-between"} wrap={"nowrap"} align={"top"} mb={"md"}>
|
||||
<div className={classes.subjectStack}>
|
||||
|
@ -18,7 +34,7 @@ export default function Announcement({announcement}: {
|
|||
</div>}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
@ -32,6 +48,38 @@ export default function Announcement({announcement}: {
|
|||
</Tooltip>
|
||||
</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}/>
|
||||
</div>
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import {EventListModel, EventListSlotEntriesWithUserModel, EventModel} from "@/models/EventTypes.ts";
|
||||
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 ShowHelp from "@/components/ShowHelp.tsx";
|
||||
import {useForm} from "@mantine/form";
|
||||
|
@ -9,16 +9,15 @@ import {FormSchema} from "@/components/formUtil/formBuilder/types.ts";
|
|||
import {IconDownload} from "@tabler/icons-react";
|
||||
import {useShowDebug} from "@/components/ShowDebug.tsx";
|
||||
import {onlyUnique} from "@/lib/util.ts";
|
||||
|
||||
const CHARSETS = [
|
||||
"utf-8", "ISO-8859-1", "windows-1252", "utf-16be", "utf-16le", "us-ascii"
|
||||
] as const
|
||||
import {CSV_CHARSETS, downloadCsv} from "@/lib/csv.ts";
|
||||
import {showSuccessNotification} from "@/components/util.tsx";
|
||||
import {PocketBaseErrorAlert} from "@/lib/pocketbase.tsx";
|
||||
|
||||
type FormValues = {
|
||||
questionSchemaFields: string[]
|
||||
statusSchemaFields: string[],
|
||||
separator: "," | ";" | "\t",
|
||||
charset: typeof CHARSETS[number]
|
||||
charset: typeof CSV_CHARSETS[number]
|
||||
}
|
||||
|
||||
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 currentPage = query.data?.pages?.length || 0
|
||||
|
||||
const progress = currentPage / totalPages * 100
|
||||
const progress = (currentPage / totalPages * 100) || 0
|
||||
const dataIsFetching = query.isFetching || query.isPending
|
||||
|
||||
const createCSV = () => {
|
||||
// all loaded entries
|
||||
const entries = query.data?.pages.flatMap(p => p.items) ?? []
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// all loaded entries
|
||||
const entries = query.data?.pages.flatMap(p => p.items) ?? []
|
||||
|
||||
const selectedFields = {
|
||||
questionSchemaFields: formValues.values.questionSchemaFields.filter(onlyUnique),
|
||||
statusSchemaFields: formValues.values.statusSchemaFields.filter(onlyUnique)
|
||||
}
|
||||
const selectedFields = {
|
||||
questionSchemaFields: formValues.values.questionSchemaFields.filter(onlyUnique),
|
||||
statusSchemaFields: formValues.values.statusSchemaFields.filter(onlyUnique)
|
||||
}
|
||||
|
||||
// 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)) ?? [])
|
||||
].filter((v, i, arr) => arr.findIndex(t => (t.id === v.id)) === i)
|
||||
}
|
||||
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)) ?? [])
|
||||
].filter((v, i, arr) => arr.findIndex(t => (t.id === v.id)) === i)
|
||||
}
|
||||
// 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)) ?? [])
|
||||
].filter((v, i, arr) => arr.findIndex(t => (t.id === v.id)) === i)
|
||||
}
|
||||
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)) ?? [])
|
||||
].filter((v, i, arr) => arr.findIndex(t => (t.id === v.id)) === i)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()}"`
|
||||
/**
|
||||
* 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 = [
|
||||
...(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
|
||||
opened={opened}
|
||||
|
@ -156,7 +153,7 @@ export default function DownloadDataModal({opened, onClose, lists, event, query}
|
|||
<div className={"stack"}>
|
||||
<ShowHelp>
|
||||
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.
|
||||
<br/>
|
||||
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
|
||||
label={"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")}
|
||||
/>
|
||||
|
||||
<PocketBaseErrorAlert error={mutation.error}/>
|
||||
|
||||
<Group>
|
||||
<Button onClick={onClose} variant={"light"} color={"orange"}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={createCSV}
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={dataIsFetching}
|
||||
loading={dataIsFetching}
|
||||
leftSection={<IconDownload/>}
|
||||
|
|
|
@ -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>
|
||||
}
|
|
@ -34,6 +34,7 @@ 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";
|
||||
import MessageEntriesModal from "@/pages/events/e/:eventId/EventLists/Search/MessageEntriesModal.tsx";
|
||||
|
||||
export default function ListSearch({event}: { event: EventModel }) {
|
||||
|
||||
|
@ -43,6 +44,7 @@ export default function ListSearch({event}: { event: EventModel }) {
|
|||
|
||||
const [showFilter, showFilterHandler] = useDisclosure(true)
|
||||
const [showDownloadModal, showDownloadModalHandler] = useDisclosure(false)
|
||||
const [showMessageModal, showMessageModalHandler] = useDisclosure(false)
|
||||
|
||||
const formValues = useForm({
|
||||
initialValues: {
|
||||
|
@ -73,10 +75,14 @@ export default function ListSearch({event}: { event: EventModel }) {
|
|||
|
||||
const nameFilter = [] as string[]
|
||||
|
||||
// search for username, givenName or surname
|
||||
usernameQuery && nameFilter.push(`user.username~'${usernameQuery}'`)
|
||||
surname && nameFilter.push(`user.sn~'${surname}'`)
|
||||
givenName && nameFilter.push(`user.givenName~'${givenName}'`)
|
||||
|
||||
// search for user id
|
||||
nameFilter.push(`user.id~'${usernameQuery}'`)
|
||||
|
||||
filter.push(`(${nameFilter.join("||")})`)
|
||||
}
|
||||
|
||||
|
@ -102,7 +108,7 @@ export default function ListSearch({event}: { event: EventModel }) {
|
|||
const query = useInfiniteQuery({
|
||||
queryKey: ["eventListSearch", {event: event.id}, {filter: filterString(debouncedFormValues)}],
|
||||
queryFn: async ({pageParam}) => {
|
||||
return await pb.collection("eventListSlotEntriesWithUser").getList(pageParam, 50, {
|
||||
return await pb.collection("eventListSlotEntriesWithUser").getList(pageParam, 100, {
|
||||
filter: filterString(debouncedFormValues),
|
||||
expand: "user"
|
||||
})
|
||||
|
@ -210,7 +216,12 @@ export default function ListSearch({event}: { event: EventModel }) {
|
|||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
<Button size={"xs"} leftSection={<IconSend size={16}/>} disabled>
|
||||
<Button
|
||||
size={"xs"}
|
||||
leftSection={<IconSend size={16}/>}
|
||||
disabled={!canEditEvent}
|
||||
onClick={showMessageModalHandler.toggle}
|
||||
>
|
||||
{entriesCount} Personen benachrichtigen
|
||||
</Button>
|
||||
|
||||
|
@ -250,6 +261,13 @@ export default function ListSearch({event}: { event: EventModel }) {
|
|||
lists={formValues.values.selectedLists}
|
||||
/>
|
||||
|
||||
<MessageEntriesModal
|
||||
opened={showMessageModal}
|
||||
onClose={showMessageModalHandler.toggle}
|
||||
query={query}
|
||||
event={event}
|
||||
/>
|
||||
|
||||
{query.hasNextPage && (
|
||||
<Center p={"xs"}>
|
||||
<Button
|
||||
|
|
|
@ -10,7 +10,6 @@ import {useState} from "react";
|
|||
|
||||
export default function DebugPage() {
|
||||
|
||||
|
||||
const {showDebug} = useShowDebug()
|
||||
|
||||
const {pb} = usePB()
|
||||
|
@ -38,7 +37,7 @@ export default function DebugPage() {
|
|||
|
||||
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 !== ""
|
||||
})
|
||||
|
@ -113,7 +112,7 @@ export default function DebugPage() {
|
|||
variant={"transparent"}
|
||||
size={"sm"}
|
||||
aria-label={"Refetch"}
|
||||
disabled={debugQuery.isLoading}
|
||||
disabled={debugQuery.isFetching}
|
||||
>
|
||||
<IconRefresh/>
|
||||
</ActionIcon>
|
||||
|
|
|
@ -32,7 +32,8 @@
|
|||
]
|
||||
},
|
||||
"types": [
|
||||
"vite-plugin-svgr/client"
|
||||
"vite-plugin-svgr/client",
|
||||
"@types/wicg-file-system-access"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
|
|
34
yarn.lock
34
yarn.lock
|
@ -1011,6 +1011,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4"
|
||||
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":
|
||||
version "3.0.4"
|
||||
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"
|
||||
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":
|
||||
version "20.12.10"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.10.tgz#8f0c3f12b0f075eee1fe20c1afb417e9765bef76"
|
||||
|
@ -1055,6 +1067,13 @@
|
|||
dependencies:
|
||||
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@*":
|
||||
version "15.7.9"
|
||||
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"
|
||||
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":
|
||||
version "6.9.0"
|
||||
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:
|
||||
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:
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.6.0.tgz#fa0a8d9007b829504db4d07dd4de0310b65287dc"
|
||||
|
@ -2896,6 +2925,11 @@ p-locate@^5.0.0:
|
|||
dependencies:
|
||||
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:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
|
||||
|
|
Loading…
Reference in New Issue