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",
|
"@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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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 = {
|
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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
}
|
}
|
|
@ -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,10 +67,11 @@ 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({
|
||||||
|
mutationFn: async () => {
|
||||||
// all loaded entries
|
// all loaded entries
|
||||||
const entries = query.data?.pages.flatMap(p => p.items) ?? []
|
const entries = query.data?.pages.flatMap(p => p.items) ?? []
|
||||||
|
|
||||||
|
@ -110,7 +110,7 @@ export default function DownloadDataModal({opened, onClose, lists, event, query}
|
||||||
// assemble the header (first question fields, then status fields)
|
// assemble the header (first question fields, then status fields)
|
||||||
const header = [
|
const header = [
|
||||||
...(showDebug ? ["Person ID", "Eintrags ID"] : []),
|
...(showDebug ? ["Person ID", "Eintrags ID"] : []),
|
||||||
"Person", "Anmeldezeitpunkt", "Anmelde-Liste",
|
"Person", "Anmeldezeitpunkt", "Anmelde-Liste", "Slot Start", "Slot End",
|
||||||
...questionSchema.fields.map(f => f.label).map(escapeSeparator),
|
...questionSchema.fields.map(f => f.label).map(escapeSeparator),
|
||||||
...statusSchema.fields.map(f => f.label).map(escapeSeparator)
|
...statusSchema.fields.map(f => f.label).map(escapeSeparator)
|
||||||
]
|
]
|
||||||
|
@ -121,31 +121,28 @@ export default function DownloadDataModal({opened, onClose, lists, event, query}
|
||||||
const statusData = e.entryStatusData || {}
|
const statusData = e.entryStatusData || {}
|
||||||
return [
|
return [
|
||||||
...(showDebug ? [e.user, e.id] : []),
|
...(showDebug ? [e.user, e.id] : []),
|
||||||
e.expand?.user.username ?? "N/A", e.created, e.listName,
|
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),
|
...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)
|
...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`
|
const csvName = `${event.name.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_export.csv`
|
||||||
|
|
||||||
// download
|
await downloadCsv({
|
||||||
const blob = new Blob([csv], {type: "text/csv;charset=" + formValues.values.charset})
|
headers: header,
|
||||||
const url = URL.createObjectURL(blob)
|
data,
|
||||||
const a = document.createElement("a")
|
chatset: formValues.values.charset,
|
||||||
a.href = url
|
filename: csvName,
|
||||||
a.download = csvName
|
delimiter: formValues.values.separator
|
||||||
a.style.display = "hidden"
|
})
|
||||||
a.click()
|
},
|
||||||
URL.revokeObjectURL(url)
|
onSuccess: () => {
|
||||||
|
showSuccessNotification("Daten wurden heruntergeladen")
|
||||||
onClose()
|
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/>}
|
||||||
|
|
|
@ -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 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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -32,7 +32,8 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"types": [
|
"types": [
|
||||||
"vite-plugin-svgr/client"
|
"vite-plugin-svgr/client",
|
||||||
|
"@types/wicg-file-system-access"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|
34
yarn.lock
34
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue