Add admin email wizard and logging

This commit is contained in:
2026-04-26 17:01:09 -04:00
parent 404ab3c45d
commit 8491b308eb
7 changed files with 1177 additions and 276 deletions
@@ -1,172 +1,173 @@
@using PowderCoating.Web.Controllers
@model BroadcastForm
@model AdminEmailComposeModel
@{
ViewData["Title"] = "Email Broadcast";
ViewData["Title"] = "Admin Email";
}
@section Styles {
<style>
[data-bs-theme="dark"] .card {
border-color: var(--bs-border-color) !important;
.admin-email-shell {
max-width: 1100px;
}
[data-bs-theme="dark"] .card-header {
background-color: var(--bs-secondary-bg) !important;
border-color: var(--bs-border-color) !important;
color: var(--bs-body-color);
.editor-toolbar {
display: flex;
flex-wrap: wrap;
gap: .5rem;
padding: .75rem;
border: 1px solid var(--bs-border-color);
border-bottom: 0;
border-top-left-radius: .75rem;
border-top-right-radius: .75rem;
background: linear-gradient(135deg, rgba(13,110,253,.08), rgba(25,135,84,.08));
}
[data-bs-theme="dark"] .alert-info {
background-color: rgba(13,202,240,.1);
border-color: rgba(13,202,240,.3);
color: var(--bs-body-color);
.editor-surface {
min-height: 320px;
padding: 1rem;
border: 1px solid var(--bs-border-color);
border-bottom-left-radius: .75rem;
border-bottom-right-radius: .75rem;
background: var(--bs-body-bg);
overflow: auto;
}
[data-bs-theme="dark"] .alert-warning {
background-color: rgba(255,193,7,.1);
border-color: rgba(255,193,7,.3);
color: var(--bs-body-color);
.editor-surface:focus {
outline: 0;
box-shadow: inset 0 0 0 1px rgba(13,110,253,.45);
}
.token-pill {
display: inline-flex;
align-items: center;
padding: .35rem .6rem;
border-radius: 999px;
background: rgba(13,110,253,.08);
color: var(--bs-primary);
font-size: .875rem;
font-family: var(--bs-font-monospace);
cursor: pointer;
}
</style>
}
<div class="container-fluid py-3" style="max-width:860px">
<div class="d-flex align-items-center gap-3 mb-3">
<h4 class="mb-0"><i class="bi bi-broadcast me-2 text-primary"></i>Email Broadcast</h4>
<div class="container-fluid py-4 admin-email-shell">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
<div>
<h3 class="mb-1"><i class="bi bi-envelope-paper me-2 text-primary"></i>Admin Email Wizard</h3>
<p class="text-muted mb-0">Step 1 of 3: write the subject and rich-text message.</p>
</div>
<div class="badge text-bg-primary-subtle border border-primary-subtle px-3 py-2">Super Admin Only</div>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent mb-3">@TempData["Success"]</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent mb-3">@TempData["Error"]</div>
<div class="alert alert-success alert-permanent mb-4">@TempData["Success"]</div>
}
<form method="post" asp-action="Send" id="broadcast-form">
@Html.AntiForgeryToken()
<div class="card shadow-sm border-0">
<div class="card-body p-4 p-lg-5">
<form method="post" asp-action="SelectCompanies" id="compose-form">
@Html.AntiForgeryToken()
<div class="row g-3">
@* Left: recipients *@
<div class="col-md-5">
<div class="card shadow-sm h-100">
<div class="card-header fw-semibold py-2">Recipients</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label fw-medium">Send to</label>
<select name="Target" id="target-select" class="form-select" onchange="onTargetChange()">
<option value="active" selected="@(Model.Target == "active")">All active companies</option>
<option value="all" selected="@(Model.Target == "all")">All companies (incl. expired)</option>
<option value="status_grace" selected="@(Model.Target == "status_grace")">Grace period companies</option>
<option value="status_expired" selected="@(Model.Target == "status_expired")">Expired companies</option>
<option value="plan" selected="@(Model.Target == "plan")">By subscription plan</option>
<option value="specific" selected="@(Model.Target == "specific")">Specific companies</option>
</select>
<div class="mb-4">
<label asp-for="Subject" class="form-label fw-semibold">Subject</label>
<input asp-for="Subject" class="form-control form-control-lg" placeholder="Service update for {{CompanyName}}" />
<span asp-validation-for="Subject" class="text-danger small"></span>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Message</label>
<div class="editor-toolbar">
<button type="button" class="btn btn-outline-secondary btn-sm" data-editor-command="bold"><i class="bi bi-type-bold"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-editor-command="italic"><i class="bi bi-type-italic"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-editor-command="underline"><i class="bi bi-type-underline"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-editor-command="insertUnorderedList"><i class="bi bi-list-ul"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-editor-command="insertOrderedList"><i class="bi bi-list-ol"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="add-link-btn"><i class="bi bi-link-45deg"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-editor-command="removeFormat">Clear Formatting</button>
</div>
<div id="message-editor" class="editor-surface" contenteditable="true">@Html.Raw(Model.BodyHtml)</div>
<textarea asp-for="BodyHtml" class="d-none"></textarea>
<span asp-validation-for="BodyHtml" class="text-danger small"></span>
</div>
<div class="row g-4 align-items-start mb-4">
<div class="col-lg-8">
<div class="alert alert-info mb-0">
The email sends one company at a time to that company's <strong>Primary Contact Email</strong>.
Rich text is supported, and the preview step will render one merged sample before anything sends.
</div>
<div id="plan-filter" class="mb-3" style="display:none">
<label class="form-label fw-medium">Plan</label>
<select name="PlanFilter" class="form-select">
@foreach (var p in (IEnumerable<dynamic>)ViewBag.PlanConfigs)
{
<option value="@p.Plan">@p.DisplayName</option>
}
</select>
</div>
<div id="specific-filter" class="mb-3" style="display:none">
<label class="form-label fw-medium">Companies</label>
<select name="CompanyIds" multiple class="form-select" style="height:160px">
@foreach (var c in (IEnumerable<dynamic>)ViewBag.Companies)
{
<option value="@c.Id">@c.CompanyName</option>
}
</select>
<div class="form-text">Hold Ctrl / Cmd to select multiple.</div>
</div>
<div class="alert alert-info py-2 small mb-0" id="recipient-preview">
<span id="recipient-count">@ViewBag.ActiveCount</span> company email(s) will receive this message.
</div>
<div class="col-lg-4">
<div class="card border-0 bg-light h-100">
<div class="card-body">
<div class="fw-semibold mb-2">Available Merge Tokens</div>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="token-pill border-0" data-token="{{FirstName}}">{{FirstName}}</button>
<button type="button" class="token-pill border-0" data-token="{{FullName}}">{{FullName}}</button>
<button type="button" class="token-pill border-0" data-token="{{CompanyName}}">{{CompanyName}}</button>
<button type="button" class="token-pill border-0" data-token="{{PrimaryContactName}}">{{PrimaryContactName}}</button>
<button type="button" class="token-pill border-0" data-token="{{PrimaryContactEmail}}">{{PrimaryContactEmail}}</button>
<button type="button" class="token-pill border-0" data-token="{{CompanyAdminFirstName}}">{{CompanyAdminFirstName}}</button>
<button type="button" class="token-pill border-0" data-token="{{CompanyAdminName}}">{{CompanyAdminName}}</button>
<button type="button" class="token-pill border-0" data-token="{{CompanyAdminEmail}}">{{CompanyAdminEmail}}</button>
</div>
<div class="small text-muted mt-2">Click a token to insert it into the editor.</div>
</div>
</div>
</div>
</div>
</div>
@* Right: compose *@
<div class="col-md-7">
<div class="card shadow-sm">
<div class="card-header fw-semibold py-2">Compose</div>
<div class="card-body">
<div class="mb-3">
<label asp-for="Subject" class="form-label fw-medium">Subject</label>
<input asp-for="Subject" class="form-control" placeholder="e.g. Scheduled maintenance this Saturday" />
<span asp-validation-for="Subject" class="text-danger small"></span>
</div>
<div class="mb-3">
<label asp-for="Body" class="form-label fw-medium">Message</label>
<textarea asp-for="Body" class="form-control" rows="12"
placeholder="Write your message here. Plain text — line breaks will be preserved."></textarea>
<span asp-validation-for="Body" class="text-danger small"></span>
</div>
<div class="alert alert-warning py-2 small mb-3">
<i class="bi bi-exclamation-triangle me-1"></i>
This will send a real email to the primary contact address of each matching company. Double-check your recipient selection before sending.
</div>
<button type="submit" class="btn btn-primary" id="send-btn">
<i class="bi bi-send me-1"></i>Send Broadcast
</button>
</div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary btn-lg">
Next: Choose Companies <i class="bi bi-arrow-right ms-2"></i>
</button>
</div>
</div>
</form>
</div>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
(function () {
const targetSelect = document.getElementById('target-select');
const planFilter = document.getElementById('plan-filter');
const specificFilter = document.getElementById('specific-filter');
const countEl = document.getElementById('recipient-count');
(function () {
const form = document.getElementById('compose-form');
const editor = document.getElementById('message-editor');
const hiddenBody = document.getElementById('BodyHtml');
function onTargetChange() {
const val = targetSelect.value;
planFilter.style.display = val === 'plan' ? '' : 'none';
specificFilter.style.display = val === 'specific' ? '' : 'none';
updateCount();
}
async function updateCount() {
const val = targetSelect.value;
const planSel = document.querySelector('[name="PlanFilter"]');
const companySel = document.querySelector('[name="CompanyIds"]');
const params = new URLSearchParams({ target: val });
if (val === 'plan' && planSel) params.set('plan', planSel.value);
if (val === 'specific' && companySel) {
Array.from(companySel.selectedOptions).forEach(o => params.append('companyIds', o.value));
function syncEditor() {
hiddenBody.value = editor.innerHTML.trim();
}
try {
const resp = await fetch('@Url.Action("RecipientCount")?' + params.toString());
const data = await resp.json();
countEl.textContent = data.count;
} catch { countEl.textContent = '?'; }
}
document.querySelectorAll('[data-editor-command]').forEach(button => {
button.addEventListener('click', () => {
document.execCommand(button.dataset.editorCommand, false);
editor.focus();
syncEditor();
});
});
window.onTargetChange = onTargetChange;
document.getElementById('add-link-btn').addEventListener('click', () => {
const url = prompt('Enter a full URL starting with https:// or mailto:');
if (!url) return;
document.execCommand('createLink', false, url);
editor.focus();
syncEditor();
});
// Wire up change events for sub-filters
document.querySelector('[name="PlanFilter"]')?.addEventListener('change', updateCount);
document.querySelector('[name="CompanyIds"]')?.addEventListener('change', updateCount);
document.querySelectorAll('[data-token]').forEach(button => {
button.addEventListener('click', () => {
editor.focus();
document.execCommand('insertText', false, button.dataset.token);
syncEditor();
});
});
// Init visibility
onTargetChange();
// Confirm before send
document.getElementById('send-btn').addEventListener('click', function (e) {
const count = countEl.textContent;
if (!confirm(`Send this email to ${count} company recipient(s)?`)) e.preventDefault();
});
})();
editor.addEventListener('input', syncEditor);
form.addEventListener('submit', syncEditor);
syncEditor();
})();
</script>
}
@@ -0,0 +1,135 @@
@using PowderCoating.Web.Controllers
@model AdminEmailPreviewModel
@{
ViewData["Title"] = "Preview Admin Email";
}
<div class="container-fluid py-4" style="max-width:1150px">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
<div>
<h3 class="mb-1"><i class="bi bi-eye me-2 text-primary"></i>Admin Email Wizard</h3>
<p class="text-muted mb-0">Step 3 of 3: preview one merged sample, then send sequentially.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<span class="badge text-bg-success">@Model.EligibleCount ready to send</span>
@if (Model.SkippedCount > 0)
{
<span class="badge text-bg-warning">@Model.SkippedCount missing email</span>
}
</div>
</div>
<div class="row g-4">
<div class="col-lg-5">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-transparent fw-semibold py-3">Sample Preview</div>
<div class="card-body">
<div class="small text-muted text-uppercase fw-semibold mb-1">Recipient Sample</div>
<div class="mb-3">
<div class="fw-semibold">@Model.SamplePreview.RecipientName</div>
<div class="text-muted">@Model.SamplePreview.RecipientEmail</div>
<div class="small text-muted">@Model.SamplePreview.CompanyName</div>
</div>
<div class="small text-muted text-uppercase fw-semibold mb-1">Rendered Subject</div>
<div class="fw-semibold mb-3">@Model.SamplePreview.RenderedSubject</div>
<div class="small text-muted text-uppercase fw-semibold mb-2">Rendered HTML Body</div>
<div class="border rounded-3 p-3 bg-light-subtle" style="min-height:320px">
@Html.Raw(Model.SamplePreview.RenderedHtmlBody)
</div>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-transparent fw-semibold py-3">Delivery Summary</div>
<div class="card-body">
<div class="alert alert-info mb-0">
The system will process each selected company one at a time.
The sample shown on the left uses the first available recipient after token replacement.
</div>
</div>
</div>
<div class="card shadow-sm border-0">
<div class="card-header bg-transparent fw-semibold py-3">Selected Companies</div>
<div class="table-responsive">
<table class="table align-middle mb-0">
<thead class="table-light">
<tr>
<th>Company</th>
<th>Recipient</th>
<th>Company Admin</th>
<th>Ready</th>
</tr>
</thead>
<tbody>
@foreach (var row in Model.SelectedCompanies)
{
<tr>
<td>
<div class="fw-semibold">@row.CompanyName</div>
<div class="small text-muted">#@row.CompanyId</div>
</td>
<td>
<div>@row.RecipientName</div>
<div class="small text-muted">@(string.IsNullOrWhiteSpace(row.RecipientEmail) ? "No primary contact email configured" : row.RecipientEmail)</div>
</td>
<td>
<div>@(string.IsNullOrWhiteSpace(row.CompanyAdminName) ? "—" : row.CompanyAdminName)</div>
@if (!string.IsNullOrWhiteSpace(row.CompanyAdminEmail))
{
<div class="small text-muted">@row.CompanyAdminEmail</div>
}
</td>
<td>
@if (row.CanSend)
{
<span class="badge text-bg-success">Ready</span>
}
else
{
<span class="badge text-bg-warning">@row.SkipReason</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
<form method="post" asp-action="Send" class="mt-4">
@Html.AntiForgeryToken()
<input type="hidden" asp-for="Subject" />
<input type="hidden" asp-for="BodyHtml" />
@foreach (var companyId in Model.CompanyIds ?? Array.Empty<int>())
{
<input type="hidden" name="CompanyIds" value="@companyId" />
}
<div class="d-flex flex-wrap justify-content-between gap-3">
<button type="submit"
formaction="@Url.Action("BackToSelectCompanies")"
class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to Companies
</button>
@if (Model.EligibleCount > 0)
{
<button type="submit" class="btn btn-primary"
onclick="return confirm('Send this message one company at a time to @Model.EligibleCount ready recipient(s)?');">
<i class="bi bi-send me-2"></i>Send Admin Email
</button>
}
else
{
<button type="button" class="btn btn-secondary" disabled>No deliverable recipients selected</button>
}
</div>
</form>
</div>
@@ -0,0 +1,170 @@
@using PowderCoating.Web.Controllers
@model AdminEmailSelectionModel
@{
ViewData["Title"] = "Choose Companies";
}
<div class="container-fluid py-4" style="max-width:1100px">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
<div>
<h3 class="mb-1"><i class="bi bi-building-check me-2 text-primary"></i>Admin Email Wizard</h3>
<p class="text-muted mb-0">Step 2 of 3: choose which companies should receive this message.</p>
</div>
<div class="badge text-bg-secondary px-3 py-2">@Model.AvailableCompanies.Count company records</div>
</div>
<div class="card shadow-sm border-0 mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-lg-6">
<div class="small text-muted text-uppercase fw-semibold mb-1">Subject</div>
<div class="fw-semibold">@Model.Subject</div>
</div>
<div class="col-lg-6">
<div class="small text-muted text-uppercase fw-semibold mb-1">Message Summary</div>
<div class="text-muted">Rich-text message prepared. Merge tokens will render on the preview step.</div>
</div>
</div>
</div>
</div>
<form method="post" asp-action="Preview" id="company-select-form">
@Html.AntiForgeryToken()
<input type="hidden" asp-for="Subject" />
<input type="hidden" asp-for="BodyHtml" />
<div class="card shadow-sm border-0">
<div class="card-body p-4">
<div class="row g-3 align-items-center mb-3">
<div class="col-lg-5">
<input type="search" class="form-control" id="company-filter" placeholder="Search company, contact, or email" />
</div>
<div class="col-lg-7 d-flex flex-wrap gap-2 justify-content-lg-end">
<button type="button" class="btn btn-outline-secondary btn-sm" id="select-all-btn">Select All Visible</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="clear-all-btn">Clear Visible</button>
<span class="badge text-bg-primary" id="selected-count">0 selected</span>
</div>
</div>
<span asp-validation-for="CompanyIds" class="text-danger small d-block mb-3"></span>
<div class="table-responsive">
<table class="table align-middle">
<thead class="table-light">
<tr>
<th style="width:56px"></th>
<th>Company</th>
<th>Primary Contact</th>
<th>Email</th>
<th>Company Admin</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach (var company in Model.AvailableCompanies)
{
<tr class="company-row">
<td>
<input class="form-check-input company-checkbox"
type="checkbox"
name="CompanyIds"
value="@company.CompanyId"
@(company.IsSelected ? "checked" : null) />
</td>
<td>
<div class="fw-semibold">@company.CompanyName</div>
<div class="small text-muted">#@company.CompanyId</div>
</td>
<td>@(string.IsNullOrWhiteSpace(company.PrimaryContactName) ? "—" : company.PrimaryContactName)</td>
<td>
@if (string.IsNullOrWhiteSpace(company.PrimaryContactEmail))
{
<span class="badge text-bg-warning">Missing</span>
}
else
{
<span>@company.PrimaryContactEmail</span>
}
</td>
<td>
<div>@(string.IsNullOrWhiteSpace(company.CompanyAdminName) ? "—" : company.CompanyAdminName)</div>
@if (!string.IsNullOrWhiteSpace(company.CompanyAdminEmail))
{
<div class="small text-muted">@company.CompanyAdminEmail</div>
}
</td>
<td>
@if (company.IsActive)
{
<span class="badge text-bg-success">Active</span>
}
else
{
<span class="badge text-bg-secondary">Inactive</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="d-flex flex-wrap justify-content-between gap-3 mt-4">
<button type="submit"
formaction="@Url.Action("BackToCompose")"
class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to Compose
</button>
<button type="submit" class="btn btn-primary">
Next: Preview <i class="bi bi-arrow-right ms-2"></i>
</button>
</div>
</div>
</div>
</form>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
(function () {
const filterInput = document.getElementById('company-filter');
const rows = Array.from(document.querySelectorAll('.company-row'));
const checkboxes = Array.from(document.querySelectorAll('.company-checkbox'));
const selectedCount = document.getElementById('selected-count');
function updateSelectedCount() {
const total = checkboxes.filter(cb => cb.checked).length;
selectedCount.textContent = `${total} selected`;
}
function applyFilter() {
const term = filterInput.value.trim().toLowerCase();
rows.forEach(row => {
const visible = row.textContent.toLowerCase().includes(term);
row.style.display = visible ? '' : 'none';
});
}
document.getElementById('select-all-btn').addEventListener('click', () => {
rows.forEach(row => {
if (row.style.display === 'none') return;
row.querySelector('.company-checkbox').checked = true;
});
updateSelectedCount();
});
document.getElementById('clear-all-btn').addEventListener('click', () => {
rows.forEach(row => {
if (row.style.display === 'none') return;
row.querySelector('.company-checkbox').checked = false;
});
updateSelectedCount();
});
filterInput.addEventListener('input', applyFilter);
checkboxes.forEach(cb => cb.addEventListener('change', updateSelectedCount));
updateSelectedCount();
})();
</script>
}