David Headrick f5b1fe6397 Update mailing and target management features
- Added new methods for creating mailings and testing targets.
- Updated configuration files for JWT settings and connection strings.
- Introduced new DTOs for target column updates and test targets.
- Enhanced MailingStatistic with a new SentDate property.
- Created new components for handling cancelled mailings and target samples.
- Refactored authentication in Login.tsx to use fetch API.
- Updated various services and repositories to support new functionalities.
2025-04-07 12:13:44 -05:00

584 lines
26 KiB
TypeScript

import { useState, useEffect } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
TextField,
Autocomplete,
DialogActions,
Button,
Checkbox,
FormControl,
FormControlLabel,
Box,
MenuItem,
Select,
InputLabel
} from "@mui/material";
import VisibilityIcon from '@mui/icons-material/Visibility';
import Template from "@/types/template";
import Mailing from "@/types/mailing";
import Target from "@/types/target";
import EmailList from "@/components/forms/EmailList";
import TestEmailList from "@/types/testEmailList";
import TemplateViewer from "@/components/modals/TemplateViewer"
import TargetSampleModal from "@/components/modals/TargetSampleModal"
import { useSetupData, SetupData } from "@/context/SetupDataContext";
import { useForm, Controller, Resolver } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import dayjs, { Dayjs } from 'dayjs'; // Import Dayjs for date handling
import utc from 'dayjs/plugin/utc'; // Import the UTC plugin
import { toast } from "react-toastify";
dayjs.extend(utc);
type MailingEditProps = {
open: boolean;
mailing: Mailing | null;
onClose: (reason: 'backdropClick' | 'escapeKeyDown' | 'saved' | 'cancelled') => void;
onSave: (updatedMailing: Mailing) => void;
};
//const statusOptions = [
// { code: 'C', name: 'Cancelled' },
// { code: 'ED', name: 'Editing' },
// { code: 'ER', name: 'Error' },
// { code: 'QE', name: 'Queueing Error' },
// { code: 'S', name: 'Sent' },
// { code: 'SC', name: 'Scheduled' },
// { code: 'SD', name: 'Sending' },
//];
const recurringTypeOptions = [
{ code: 'D', name: 'Daily' },
{ code: 'M', name: 'Monthly' },
{ code: 'W', name: 'Weekly' },
];
const schema = yup.object().shape({
id: yup.number().nullable(),
name: yup.string().required("Name is required")
.test("unique-name", "Name must be unique", async function (value) {
if (value.length === 0)
return true;
return await nameIsAvailable(this.parent.id, value);
}),
description: yup.string().default(""),
templateId: yup.number().typeError("Template is required").required("Template is required").test("valid-template", "Invalid template", function (value) {
const setupData = this.options.context?.setupData as SetupData;
return setupData.templates.some(t => t.id === value);
}),
targetId: yup.number().typeError("Target is required").required("Target is required").test("valid-target", "Invalid target", function (value) {
const setupData = this.options.context?.setupData as SetupData;
return setupData.targets.some(t => t.id === value);
}),
statusCode: yup.string().default("ED"),
scheduleDate: yup.string()
.nullable()
.when("$scheduleForLater", (scheduleForLater, schema) => {
const isScheduledForLater = scheduleForLater[0] ?? false;
return isScheduledForLater
? schema
.required("Schedule date is required when scheduled for later")
.matches(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/,
"Schedule date must be a valid UTC ISO string (e.g., 'YYYY-MM-DDTHH:mm:ssZ')"
)
.test("is-future", "Schedule date must be in the future", (value) => {
if (!value) return true; // Nullable when not required
return dayjs(value).isAfter(dayjs());
})
: schema.nullable();
}),
//scheduleDate: yup.date().nullable(),
// .when("statusCode", {
// is: (value: string) => value === "SC" || value === "SD", // String comparison
// then: (schema) => schema.required("Schedule date is required for scheduled or sending status"),
// otherwise: (schema) => schema.nullable(),
//}),
sentDate: yup.date().nullable().default(null),
sessionActivityId: yup.string().nullable(),
recurringTypeCode: yup
.string()
.nullable()
.when("$recurring", (recurring, schema) => { // Use context variable
const isRecurring = recurring[0] ?? false;
return isRecurring
? schema.oneOf(recurringTypeOptions.map((r) => r.code), "Invalid recurring type")
: schema.nullable();
}),
recurringStartDate: yup.string()
.nullable()
.when("$recurring", (recurring, schema) => {
const isRecurring = recurring[0] ?? false;
return isRecurring
? schema
.required("Recurring start date is required when recurring")
.matches(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/,
"Recurring start date must be a valid UTC ISO string (e.g., 'YYYY-MM-DDTHH:mm:ssZ')"
)
.test("is-future", "Recurring start date must be in the future", (value) => {
if (!value) return true; // Nullable when not required
return dayjs(value).isAfter(dayjs());
})
: schema.nullable();
}),
//.when("recurringTypeCode", {
//is: (value: string) => value !== "" && value !== null, // String comparison for "None"
//then: (schema) => schema.required("Recurring start date is required when recurring type is set"),
//otherwise: (schema) => schema.nullable(),
//}),
});
const nameIsAvailable = async (id: number, name: string) => {
const response = await fetch(`/api/mailings/available?${id > 0 ? "id=" + id + "&" : ""}name=${name}`);
const data = await response.json();
return data.available;
};
const getNextAvailableName = async (id: number, name: string) => {
const response = await fetch(`/api/mailings/nextavailablename?${id > 0 ? "id=" + id + "&" : ""}name=${name}`);
const data = await response.json();
return data.name;
};
const defaultMailing: Mailing = {
id: 0,
name: "",
description: "",
templateId: 0,
targetId: 0,
statusCode: "ED",
scheduleDate: null,
sentDate: null,
sessionActivityId: null,
recurringTypeCode: null,
recurringStartDate: null,
};
const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
const isNew = !mailing || mailing.id === 0;
const setupData: SetupData = useSetupData();
const [approved, setApproved] = useState<boolean>(false);
const [recurring, setRecurring] = useState<boolean>(false);
const [scheduleForLater, setScheduleForLater] = useState<boolean>(false);
const [testEmailListId, setTestEmailListId] = useState<number | null>(null);
const [emails, setEmails] = useState<string[]>([]); // State for email array
const [templateViewerOpen, setTemplateViewerOpen] = useState<boolean>(false);
const [currentTemplate, setCurrentTemplate] = useState<Template | null>(null);
const [TargetSampleModalOpen, setTargetSampleModalOpen] = useState<boolean>(false);
const [currentTarget, setCurrentTarget] = useState<Target | null>(null);
const { register, trigger, control, handleSubmit, reset, formState: { errors } } = useForm<Mailing>({
mode: "onBlur",
defaultValues: {
...(mailing || defaultMailing),
},
resolver: yupResolver(schema) as Resolver<Mailing>,
context: { setupData, scheduleForLater, recurring },
});
const [loading, setLoading] = useState(false);
useEffect(() => {
const initializeMailingEdit = async () => {
if (open) {
if (mailing) {
mailing.scheduleDate = null;
mailing.recurringTypeCode = null;
mailing.recurringStartDate = null;
if (mailing.id == 0) {
mailing.name = await getNextAvailableName(mailing.id, mailing.name);
}
}
setApproved(false);
setRecurring(false);
setScheduleForLater(false);
reset(mailing || defaultMailing, { keepDefaultValues: true });
if (setupData.testEmailLists.length > 0) {
setTestEmailListId(setupData.testEmailLists[0].id);
setEmails(setupData.testEmailLists[0].emails);
} else {
setTestEmailListId(null);
setEmails([]);
}
if (mailing?.templateId) {
const template = setupData.templates.find(t => t.id === mailing.templateId) || null;
setCurrentTemplate(template);
} else {
setCurrentTemplate(null);
}
if (mailing?.targetId) {
const target = setupData.targets.find(t => t.id === mailing.targetId) || null;
setCurrentTarget(target);
} else {
setCurrentTarget(null);
}
}
};
initializeMailingEdit();
}, [open, mailing, reset, setupData.testEmailLists, setupData.targets, setupData.templates]);
const handleSave = async (formData: Mailing) => {
const apiUrl = isNew ? "/api/mailings" : `/api/mailings/${formData.id}`;
const method = isNew ? "POST" : "PUT";
setLoading(true);
if (approved) {
formData.statusCode = "SC";
if (!scheduleForLater) {
formData.scheduleDate = null;
}
if (!recurring) {
formData.recurringTypeCode = null;
formData.recurringStartDate = null;
}
}
else {
formData.statusCode = "ED";
formData.scheduleDate = null;
formData.recurringTypeCode = null;
formData.recurringStartDate = null;
}
try {
const jsonPayload = JSON.stringify(formData);
const response = await fetch(apiUrl, {
method: method,
headers: { "Content-Type": "application/json" },
body: jsonPayload,
});
if (!response.ok) throw new Error(isNew ? "Failed to create" : "Failed to update");
const updatedMailing = await response.json();
onSave(updatedMailing);
onClose('saved');
} catch (error) {
console.error("Update error:", error);
} finally {
setLoading(false);
}
};
const handleEmailsChange = (newEmails: string[]) => {
setEmails(newEmails);
};
const handleTestMailing = async () => {
setLoading(true); // Show loading state
const isValid = await trigger();
if (!isValid) {
console.log("Form is invalid, cannot test mailing");
setLoading(false);
return;
}
try {
// Get the current form data
const formData = control._formValues as Mailing;
// Prepare the payload matching TestMailingDto
const testMailingPayload = {
mailing: formData,
emailAddresses: emails,
};
// Make the API call to /api/mailings/test
const response = await fetch("/api/mailings/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(testMailingPayload),
});
if (!response.ok) {
throw new Error("Failed to test mailing");
}
toast.success("Test mailing sent successfully");
console.log("Test mailing sent successfully");
} catch (error) {
console.error("Test mailing error:", error);
toast.error("Failed to send test mailing");
} finally {
setLoading(false); // Reset loading state
}
};
const handleTestEmailListChange = (list: TestEmailList | null) => {
if (list) {
setTestEmailListId(list.id);
setEmails(list.emails);
}
}
const handleApprovedChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setApproved(event.target.checked);
};
const handleRecurringChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setRecurring(event.target.checked);
};
const handleScheduleForLaterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setScheduleForLater(event.target.checked);
};
const handleTemplateViewerOpen = () => {
setTemplateViewerOpen(!templateViewerOpen);
};
const handleTargetSampleModalOpen = () => {
setTargetSampleModalOpen(!TargetSampleModalOpen);
};
return (
<LocalizationProvider dateAdapter={AdapterDayjs}> {/* Wrap with LocalizationProvider */}
<Dialog open={open} onClose={(_, reason) => { onClose(reason); }} maxWidth="sm" fullWidth disableEscapeKeyDown >
<DialogTitle>{isNew ? "Add Mailing" : "Edit Mailing id=" + mailing?.id}</DialogTitle>
<DialogContent>
<TextField
{...register("name")}
label="Name"
fullWidth
margin="dense"
error={!!errors.name}
helperText={errors.name?.message}
/>
<TextField
{...register("description")}
label="Description"
fullWidth
margin="dense"
error={!!errors.description}
helperText={errors.description?.message}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Controller
name="templateId"
control={control}
render={({ field }) => (
<Autocomplete
{...field}
options={setupData.templates}
getOptionLabel={(option) => option.name}
value={setupData.templates.find(t => t.id === field.value) || null}
onChange={(_, newValue) => {
field.onChange(newValue ? newValue.id : null);
trigger("templateId");
setCurrentTemplate(newValue);
}}
renderInput={(params) => (
<TextField
{...params}
label="Template"
fullWidth
margin="dense"
error={!!errors.templateId}
helperText={errors.templateId?.message}
/>
)}
sx={{ flexGrow: 1 }}
/>
)}
/>
{currentTemplate && (
<Button
onClick={handleTemplateViewerOpen}
variant="outlined"
startIcon={<VisibilityIcon />}
sx={{ height: '100%', alignSelf: 'center' }}
>
View
</Button>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Controller
name="targetId"
control={control}
render={({ field }) => (
<Autocomplete
{...field}
options={setupData.targets}
getOptionLabel={(option) => option.name}
value={setupData.targets.find(t => t.id === field.value) || null}
onChange={(_, newValue) => {
field.onChange(newValue ? newValue.id : null);
trigger("targetId");
setCurrentTarget(newValue);
}}
renderInput={(params) => (
<TextField
{...params}
label="Target"
fullWidth
margin="dense"
error={!!errors.targetId}
helperText={errors.targetId?.message}
/>
)}
sx={{ flexGrow: 1 }}
/>
)}
/>
{currentTarget && (
<Button
onClick={handleTargetSampleModalOpen}
variant="outlined"
startIcon={<VisibilityIcon />}
sx={{ height: '100%', alignSelf: 'center' }}
>
View
</Button>
)}
</Box>
<Autocomplete
options={setupData.testEmailLists}
getOptionLabel={(option) => option.name}
value={setupData.testEmailLists.find(t => t.id === testEmailListId) || null}
onChange={(_, newValue) => handleTestEmailListChange(newValue)}
renderInput={(params) => (
<TextField
{...params}
label="Test Email List"
fullWidth
margin="dense"
/>
)}
/>
<EmailList emails={emails} onEmailTextChange={handleEmailsChange} />
<Button onClick={handleTestMailing} disabled={loading}>Test Mailing</Button>
<Box>
<FormControlLabel control={<Checkbox checked={approved} onChange={handleApprovedChange} />} label="Approved" />
</Box>
{approved && (<>
<Box sx={{ display: 'flex', flexDirection: 'column', ml: 3 }}>
<FormControlLabel control={<Checkbox checked={recurring} onChange={handleRecurringChange} />} label="Recurring Mailing" />
{recurring && (<>
<FormControl fullWidth margin="dense">
<InputLabel id="recurring-type-select-label">Recurring Type</InputLabel>
<Controller
name="recurringTypeCode"
control={control}
render={({ field }) => (
<Select
{...field}
labelId="recurring-type-select-label"
id="recurring-type-select"
label="Recurring Type"
value={field.value || ''}
onChange={(e) => {
field.onChange(e.target.value);
trigger("recurringTypeCode");
}}
error={!!errors.recurringTypeCode}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{recurringTypeOptions.map((option) => (
<MenuItem key={option.code} value={option.code}>
{option.name}
</MenuItem>
))}
</Select>
)}
/>
{errors.recurringTypeCode && (
<span style={{ color: 'red', fontSize: '0.75rem' }}>
{errors.recurringTypeCode.message}
</span>
)}
</FormControl>
<Controller
name="recurringStartDate"
control={control}
render={({ field }) => (
<DateTimePicker
label="Recurring Start Date"
value={field.value ? dayjs(field.value) : null} // Convert to Dayjs
onChange={(newValue: Dayjs | null) => {
const utcString = newValue ? newValue.utc().format("YYYY-MM-DDTHH:mm:ss[Z]") : null;
field.onChange(utcString);
trigger("recurringStartDate");
}}
slotProps={{
textField: {
fullWidth: true,
margin: "dense",
error: !!errors.recurringStartDate,
helperText: errors.recurringStartDate?.message,
},
}}
minDateTime={dayjs()} // Enforce future date in UI
/>
)}
/>
</>)}
<FormControlLabel control={<Checkbox checked={scheduleForLater} onChange={handleScheduleForLaterChange} />} label="Schedule for Later" />
{scheduleForLater && (
<Controller
name="scheduleDate"
control={control}
render={({ field }) => (
<DateTimePicker
label="Schedule Date"
value={field.value ? dayjs(field.value) : null} // Convert to Dayjs
onChange={(newValue: Dayjs | null) => {
const utcString = newValue ? newValue.utc().format("YYYY-MM-DDTHH:mm:ss[Z]") : null;
field.onChange(utcString);
trigger("scheduleDate");
}}
slotProps={{
textField: {
fullWidth: true,
margin: "dense",
error: !!errors.scheduleDate,
helperText: errors.scheduleDate?.message,
},
}}
minDateTime={dayjs()} // Enforce future date in UI
/>
)}
/>
)}
</Box>
</>)}
{templateViewerOpen && (
<TemplateViewer
open={templateViewerOpen}
template={currentTemplate!}
onClose={() => { setTemplateViewerOpen(false) }}
/>
)}
{TargetSampleModalOpen && (
<TargetSampleModal
open={TargetSampleModalOpen}
target={currentTarget!}
onClose={() => { setTargetSampleModalOpen(false) }}
/>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => { onClose( 'cancelled'); }} disabled={loading}>Cancel</Button>
<Button onClick={handleSubmit(handleSave)} color="primary" disabled={loading}>
{loading ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
</LocalizationProvider>
);
};
export default MailingEdit;