>>({});
- const [recipients, setRecipients] = useState([createNewRecipient()]);
+ const [recipients, setRecipients] = useState([]);
+ const [csvImport, setCsvImport] = useState(null);
+ const [csvMappings, setCsvMappings] = useState>({});
+ const csvInputRef = useRef(null);
const [templatesByChannel, setTemplatesByChannel] = useState>({});
const [templatesLoadingByChannel, setTemplatesLoadingByChannel] = useState>({});
const [selectedTemplatesByChannel, setSelectedTemplatesByChannel] = useState>({});
@@ -204,12 +210,10 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps)
}, [selectedChannels, plugins, selectedProviders]);
// Recipient Management
- const addRecipient = () => setRecipients([...recipients, createNewRecipient()]);
+ const addRecipient = () => setRecipients((current) => [...current, createNewRecipient()]);
const removeRecipient = (id: string) => {
- if (recipients.length > 1) {
- setRecipients(recipients.filter(r => r.id !== id));
- }
+ setRecipients((current) => current.filter((recipient) => recipient.id !== id));
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -360,6 +364,11 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps)
return;
}
+ if (recipients.length === 0) {
+ toast.error("Add at least one recipient");
+ return;
+ }
+
setIsLoading(true);
try {
@@ -461,7 +470,7 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps)
onSuccess?.();
// Reset
- setRecipients([createNewRecipient()]);
+ setRecipients([]);
setContentData({});
setScheduledDate(undefined);
setSelectedTemplatesByChannel({});
@@ -507,6 +516,61 @@ export function BatchNotificationForm({ onSuccess }: BatchNotificationFormProps)
)
).sort((left, right) => left.localeCompare(right));
+ const csvRecipientFields: CsvRecipientField[] = [
+ { key: "user_id", label: "User ID", required: true },
+ ...uniqueRecipientFields.map((field) => ({
+ key: field.name,
+ label: field.name,
+ required: field.required,
+ })),
+ ...activeTemplateVariableNames.map((name) => ({
+ key: `variables.${name}`,
+ label: `{{${name}}}`,
+ required: false,
+ variableName: name,
+ })),
+ ];
+
+ const handleCsvSelected = async (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+ if (!file) return;
+
+ try {
+ const parsed = parseCsv(await file.text());
+ if (parsed.headers.length === 0 || parsed.rows.length === 0) {
+ toast.error("CSV must contain headers and at least one data row");
+ return;
+ }
+ const mappings = mapCsvHeaders(parsed.headers, csvRecipientFields);
+ setCsvMappings(mappings);
+ setCsvImport({ fileName: file.name, ...parsed });
+ } catch {
+ toast.error("Failed to read CSV file");
+ }
+ };
+
+ const importCsvRecipients = () => {
+ if (!csvImport) return;
+ const missing = getMissingRequiredCsvFields(csvRecipientFields, csvMappings);
+ if (missing.length > 0) {
+ toast.error(`Map required fields: ${missing.map((field) => field.label).join(", ")}`);
+ return;
+ }
+
+ const { recipients: imported, skipped } = createCsvRecipients(
+ csvImport.rows,
+ csvRecipientFields,
+ csvMappings,
+ generateUUID
+ );
+
+ setRecipients((current) => [...current, ...imported]);
+ setCsvImport(null);
+ setCsvMappings({});
+ toast.success(`Imported ${imported.length} recipient${imported.length === 1 ? "" : "s"}${skipped ? `; skipped ${skipped}` : ""}`);
+ };
+
return (
-
- Add
-
+
+
csvInputRef.current?.click()}>
+ Import from CSV
+
+
+
+ Add
+
+