From 594c780f42cbaac25f3ee033605e6bf415862067 Mon Sep 17 00:00:00 2001 From: valentinkolb Date: Mon, 17 Jun 2024 17:11:28 +0200 Subject: [PATCH] feat(announcements): you can now send announcements to entries --- package.json | 7 +- src/components/layout/nav/MenuItems.tsx | 2 +- src/components/users/modals/LoginModal.tsx | 10 +- src/lib/csv.ts | 61 +++++++ src/models/MessageTypes.ts | 8 +- src/pages/chat/components/Announcement.tsx | 54 +++++- .../EventLists/Search/DownloadDataModal.tsx | 159 +++++++++--------- .../EventLists/Search/MessageEntriesModal.tsx | 153 +++++++++++++++++ .../e/:eventId/EventLists/Search/index.tsx | 22 ++- src/pages/test/DebugPage.tsx | 5 +- tsconfig.json | 3 +- yarn.lock | 34 ++++ 12 files changed, 424 insertions(+), 94 deletions(-) create mode 100644 src/lib/csv.ts create mode 100644 src/pages/events/e/:eventId/EventLists/Search/MessageEntriesModal.tsx diff --git a/package.json b/package.json index e1ffa79..5ddea36 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/components/layout/nav/MenuItems.tsx b/src/components/layout/nav/MenuItems.tsx index c50ffd2..a42d935 100644 --- a/src/components/layout/nav/MenuItems.tsx +++ b/src/components/layout/nav/MenuItems.tsx @@ -43,7 +43,7 @@ const NavItems = [ items: [ { - title: "Übersicht", + title: "Alle Events", icon: IconConfetti, description: "Übersicht über alle Events.", link: "/events" diff --git a/src/components/users/modals/LoginModal.tsx b/src/components/users/modals/LoginModal.tsx index 00ddcd6..6bb5572 100644 --- a/src/components/users/modals/LoginModal.tsx +++ b/src/components/users/modals/LoginModal.tsx @@ -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 <> - +
loginMutation.mutate())}> StuVe IT Login diff --git a/src/lib/csv.ts b/src/lib/csv.ts new file mode 100644 index 0000000..3d1de5b --- /dev/null +++ b/src/lib/csv.ts @@ -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) + } +} \ No newline at end of file diff --git a/src/models/MessageTypes.ts b/src/models/MessageTypes.ts index 907182f..c108832 100644 --- a/src/models/MessageTypes.ts +++ b/src/models/MessageTypes.ts @@ -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 } diff --git a/src/pages/chat/components/Announcement.tsx b/src/pages/chat/components/Announcement.tsx index 5611f41..d29d79e 100644 --- a/src/pages/chat/components/Announcement.tsx +++ b/src/pages/chat/components/Announcement.tsx @@ -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
@@ -18,7 +34,7 @@ export default function Announcement({announcement}: {
} - {senderName} • {pprintDate(announcement.created)} • {humanDeltaFromNow(announcement.created).message} + von {senderName} • {pprintDate(announcement.created)} • {humanDeltaFromNow(announcement.created).message}
@@ -32,6 +48,38 @@ export default function Announcement({announcement}: { + {(eventId && entriesQuery.data?.totalItems !== 0) && ( + entriesQuery.isFetching ?
: + + + Du hast bei dem Event + {" "} + { + entriesQuery.data?.items[0].eventName + } + {" "} + { + entriesQuery.data?.totalItems === 1 ? "einen Eintrag" : `${entriesQuery.data?.totalItems} Einträge` + } + {"."} + + + {entriesQuery.data?.items.map((entry) => ( + + {entry.listName} + {", Zeitslot "} + {pprintDateRange(entry.slotStartDate, entry.slotEndDate)} + {", "} + {humanDeltaFromNow(entry.created).message} + + ))} + + + Hier kannst du + alle deine Einträge einsehen und bearbeiten. + + )} + } \ 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 index f1b2d05..cc898a9 100644 --- a/src/pages/events/e/:eventId/EventLists/Search/DownloadDataModal.tsx +++ b/src/pages/events/e/:eventId/EventLists/Search/DownloadDataModal.tsx @@ -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 In der CSV sind automatisch die Felder Person, Anmeldezeitpunkt, - und Anmelde-Liste enthalten. Diese + Anmelde-Liste und Zeitslot enthalten. Diese Felder können nicht abgewählt werden.
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}