Add bulk calendar invitation delivery
This commit is contained in:
@@ -388,6 +388,16 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
{ id: "queue", header: "i18n:govoplan-campaign.queue.d325fcd9", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: QUEUE_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.queue_status ?? "unknown")} />, value: (row) => String(row.queue_status ?? "unknown") },
|
||||
{ id: "send", header: "Delivery", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.send_status ?? "unknown")} label={deliveryStatusLabel(String(row.send_status ?? "unknown"))} />, value: (row) => String(row.send_status ?? "unknown") },
|
||||
{ id: "postbox", header: "Postbox", width: 155, sortable: true, filterable: true, columnType: "from-list", list: { options: POSTBOX_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.postbox_status ?? "unknown")} label={deliveryStatusLabel(String(row.postbox_status ?? "unknown"))} />, value: (row) => String(row.postbox_status ?? "unknown") },
|
||||
{
|
||||
id: "rsvp",
|
||||
header: "RSVP",
|
||||
width: 145,
|
||||
value: (row) => calendarRsvpStatus(row),
|
||||
render: (row) => {
|
||||
const status = calendarRsvpStatus(row);
|
||||
return status === "—" ? <span className="muted">—</span> : <StatusBadge status={status.toLowerCase()} label={humanize(status)} />;
|
||||
}
|
||||
},
|
||||
{ id: "print", header: "Print", width: 135, sortable: true, filterable: true, columnType: "from-list", list: { options: PRINT_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.print_status ?? "unknown")} label={deliveryStatusLabel(String(row.print_status ?? "unknown"))} />, value: (row) => String(row.print_status ?? "unknown") },
|
||||
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: IMAP_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.imap_status ?? "unknown")} label={deliveryStatusLabel(String(row.imap_status ?? "unknown"))} />, value: (row) => String(row.imap_status ?? "unknown") },
|
||||
{ id: "attempts", header: "i18n:govoplan-campaign.attempts.5a29585e", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0), render: (row) => String(Number(row.attempt_count ?? 0) + Number(row.postbox_attempt_count ?? 0)) },
|
||||
@@ -603,6 +613,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
<div><dt>i18n:govoplan-campaign.smtp_state.ff372566</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.send_status ?? "unknown"))} /></dd></div>
|
||||
<div><dt>Postbox state</dt><dd><StatusBadge status={String(detail.job.postbox_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.postbox_status ?? "unknown"))} /></dd></div>
|
||||
<div><dt>Postbox targets</dt><dd>{String(detail.job.postbox_target_count ?? 0)}</dd></div>
|
||||
<div><dt>Calendar RSVP</dt><dd>{calendarRsvpStatus(detail.job)}</dd></div>
|
||||
<div><dt>Print state</dt><dd><StatusBadge status={String(detail.job.print_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.print_status ?? "unknown"))} /></dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.imap_state.03b83be0</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.imap_status ?? "unknown"))} /></dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.attachments.6771ade6</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
|
||||
@@ -1037,6 +1048,11 @@ function deliveryStatusLabel(status: string): string | undefined {
|
||||
return status === "skipped" ? "i18n:govoplan-campaign.skipped.5a000ad7" : undefined;
|
||||
}
|
||||
|
||||
function calendarRsvpStatus(row: Record<string, unknown>): string {
|
||||
const invitation = asRecord(row.calendar_invitation);
|
||||
return String(invitation.rsvp_status || "—");
|
||||
}
|
||||
|
||||
function initialReportQuery(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
return new URLSearchParams(window.location.search).get("q")?.trim() ?? "";
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import {
|
||||
getCampaignCalendarInvitationCatalog,
|
||||
getCampaignPostboxCatalog,
|
||||
type CampaignCalendarCatalog,
|
||||
type CampaignPostboxCatalog
|
||||
} from "../../api/campaigns";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
@@ -49,8 +51,14 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
templates: [],
|
||||
organization_units: []
|
||||
});
|
||||
const [calendarCatalog, setCalendarCatalog] = useState<CampaignCalendarCatalog>({
|
||||
available: false,
|
||||
calendars: []
|
||||
});
|
||||
const [postboxTargetsOpen, setPostboxTargetsOpen] = useState(false);
|
||||
const postboxModuleInstalled = usePlatformModuleInstalled("postbox");
|
||||
const calendarModuleInstalled = usePlatformModuleInstalled("calendar");
|
||||
const mailModuleInstalled = usePlatformModuleInstalled("mail");
|
||||
const isPolicyView = view === "policy";
|
||||
|
||||
const version = data.currentVersion;
|
||||
@@ -77,6 +85,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
const rateLimit = asRecord(delivery.rate_limit);
|
||||
const retry = asRecord(delivery.retry);
|
||||
const postboxDelivery = asRecord(delivery.postbox);
|
||||
const calendarInvitation = asRecord(delivery.calendar_invitation);
|
||||
const fieldDefinitions = useMemo(
|
||||
() => getDraftFields(displayDraft),
|
||||
[displayDraft]
|
||||
@@ -123,6 +132,40 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
settings.apiKey
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!calendarModuleInstalled) {
|
||||
setCalendarCatalog({
|
||||
available: false,
|
||||
reason: "The Calendar module is not active.",
|
||||
calendars: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void getCampaignCalendarInvitationCatalog(settings, campaignId)
|
||||
.then((catalog) => {
|
||||
if (!cancelled) setCalendarCatalog(catalog);
|
||||
})
|
||||
.catch((cause) => {
|
||||
if (!cancelled) {
|
||||
setCalendarCatalog({
|
||||
available: false,
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
calendars: []
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
campaignId,
|
||||
calendarModuleInstalled,
|
||||
settings.accessToken,
|
||||
settings.apiBaseUrl,
|
||||
settings.apiKey
|
||||
]);
|
||||
|
||||
function patchEditor(path: string[], value: unknown) {
|
||||
if (locked) return;
|
||||
setEditorState((current) => updateNested(current, path, value));
|
||||
@@ -329,6 +372,107 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
}
|
||||
</Card>
|
||||
|
||||
<Card title="Calendar invitations" collapsible>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<ToggleSwitch
|
||||
label="Send individualized invitations"
|
||||
checked={getBool(calendarInvitation, "enabled")}
|
||||
disabled={locked || !calendarCatalog.available || !mailModuleInstalled}
|
||||
onChange={(checked) => patch(["delivery", "calendar_invitation", "enabled"], checked)}
|
||||
/>
|
||||
<FormField label="Tracking calendar">
|
||||
<select
|
||||
value={getText(calendarInvitation, "calendar_id")}
|
||||
disabled={locked || !calendarCatalog.available}
|
||||
onChange={(event) => patch(["delivery", "calendar_invitation", "calendar_id"], event.target.value || null)}
|
||||
>
|
||||
<option value="">Select a calendar</option>
|
||||
{calendarCatalog.calendars.map((calendar) =>
|
||||
<option key={calendar.id} value={calendar.id} disabled={!calendar.writable}>
|
||||
{calendar.name}{calendar.writable ? "" : " (read-only)"}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Summary template">
|
||||
<input
|
||||
value={getText(calendarInvitation, "summary_template")}
|
||||
disabled={locked}
|
||||
placeholder="Appointment with {{name}}"
|
||||
onChange={(event) => patch(["delivery", "calendar_invitation", "summary_template"], event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Start (ISO 8601 or field template)">
|
||||
<input
|
||||
value={getText(calendarInvitation, "start_at_template")}
|
||||
disabled={locked}
|
||||
placeholder="2026-08-12T10:00 or {{appointment_start}}"
|
||||
onChange={(event) => patch(["delivery", "calendar_invitation", "start_at_template"], event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="End (ISO 8601 or field template)">
|
||||
<input
|
||||
value={getText(calendarInvitation, "end_at_template")}
|
||||
disabled={locked}
|
||||
placeholder="2026-08-12T11:00 or {{appointment_end}}"
|
||||
onChange={(event) => patch(["delivery", "calendar_invitation", "end_at_template"], event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Timezone">
|
||||
<input
|
||||
value={getText(calendarInvitation, "timezone", "UTC")}
|
||||
disabled={locked}
|
||||
placeholder="Europe/Berlin"
|
||||
onChange={(event) => patch(["delivery", "calendar_invitation", "timezone"], event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Location template">
|
||||
<input
|
||||
value={getText(calendarInvitation, "location_template")}
|
||||
disabled={locked}
|
||||
onChange={(event) => patch(["delivery", "calendar_invitation", "location_template"], event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Categories">
|
||||
<input
|
||||
value={(Array.isArray(calendarInvitation.categories) ? calendarInvitation.categories : []).join(", ")}
|
||||
disabled={locked}
|
||||
placeholder="Invitation, Campaign"
|
||||
onChange={(event) => patch(
|
||||
["delivery", "calendar_invitation", "categories"],
|
||||
event.target.value.split(",").map((value) => value.trim()).filter(Boolean)
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Description template">
|
||||
<textarea
|
||||
rows={3}
|
||||
value={getText(calendarInvitation, "description_template")}
|
||||
disabled={locked}
|
||||
onChange={(event) => patch(["delivery", "calendar_invitation", "description_template"], event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
{!mailModuleInstalled &&
|
||||
<DismissibleAlert tone="warning" dismissible={false}>
|
||||
Mail must be active to deliver iCalendar invitations.
|
||||
</DismissibleAlert>
|
||||
}
|
||||
{!calendarCatalog.available &&
|
||||
<DismissibleAlert tone="warning" dismissible={false}>
|
||||
{calendarCatalog.reason || "Calendar invitation tracking is unavailable."}
|
||||
</DismissibleAlert>
|
||||
}
|
||||
{calendarCatalog.available && calendarCatalog.calendars.every((item) => !item.writable) &&
|
||||
<DismissibleAlert tone="warning" dismissible={false}>
|
||||
No writable calendar is available for invitation tracking.
|
||||
</DismissibleAlert>
|
||||
}
|
||||
<p className="muted small-note">
|
||||
One METHOD:REQUEST attachment is frozen per recipient at build time. The Calendar mirror is created after delivery acceptance; attendee replies are shown in Campaign reports.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.opt_ins_and_local_assistance.d0d23635" collapsible>
|
||||
<div className="toggle-grid">
|
||||
<ToggleSwitch label="i18n:govoplan-campaign.suggest_addresses_from_this_campaign.5ebe2aea" checked={getBool(optIns, "campaign_address_suggestions", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "campaign_address_suggestions"], checked)} />
|
||||
|
||||
Reference in New Issue
Block a user