chore: sync GovOPlaN module split state
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.6",
|
||||
"lucide-react": "^0.555.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { ApiSettings, CampaignListItem } from "../types";
|
||||
import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types";
|
||||
import { apiDownload, apiFetch } from "./client";
|
||||
|
||||
export type CampaignListResponse =
|
||||
| CampaignListItem[]
|
||||
| {
|
||||
campaigns?: CampaignListItem[];
|
||||
items?: CampaignListItem[];
|
||||
results?: CampaignListItem[];
|
||||
};
|
||||
CampaignListItem[] |
|
||||
{
|
||||
campaigns?: CampaignListItem[];
|
||||
items?: CampaignListItem[];
|
||||
results?: CampaignListItem[];
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ export type CampaignShare = {
|
||||
revoked_at?: string | null;
|
||||
};
|
||||
|
||||
export type CampaignShareTarget = { id: string; name: string; secondary?: string | null };
|
||||
export type CampaignShareTargets = { users: CampaignShareTarget[]; groups: CampaignShareTarget[] };
|
||||
export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;};
|
||||
export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];};
|
||||
|
||||
export type CampaignUpdatePayload = {
|
||||
external_id?: string | null;
|
||||
@@ -85,11 +85,28 @@ export type CampaignWorkspaceResponse = {
|
||||
selected_version_id?: string | null;
|
||||
};
|
||||
|
||||
export type CampaignDeltaResponse = {
|
||||
campaigns: CampaignListItem[];
|
||||
deleted: DeltaDeletedItem[];
|
||||
watermark?: string | null;
|
||||
has_more: boolean;
|
||||
full: boolean;
|
||||
};
|
||||
|
||||
export type CampaignWorkspaceDeltaResponse = CampaignWorkspaceResponse & {
|
||||
deleted: DeltaDeletedItem[];
|
||||
watermark?: string | null;
|
||||
has_more: boolean;
|
||||
full: boolean;
|
||||
};
|
||||
|
||||
export type CampaignWorkspaceQuery = {
|
||||
versionId?: string | null;
|
||||
includeCurrentVersion?: boolean;
|
||||
includeSummary?: boolean;
|
||||
includeVersions?: boolean;
|
||||
since?: string | null;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type RecipientImportColumnKind = "ignore" | "id" | "active" | "name" | "from" | "to" | "cc" | "bcc" | "reply_to" | "field" | "new_field" | "attachment_pattern";
|
||||
@@ -279,6 +296,7 @@ export type CampaignJobsQuery = {
|
||||
versionId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
cursor?: string | null;
|
||||
sendStatus?: string[];
|
||||
validationStatus?: string[];
|
||||
imapStatus?: string[];
|
||||
@@ -292,6 +310,8 @@ export type CampaignJobsResponse = {
|
||||
total: number;
|
||||
total_unfiltered: number;
|
||||
pages: number;
|
||||
cursor?: string | null;
|
||||
next_cursor?: string | null;
|
||||
counts: Record<string, Record<string, number>>;
|
||||
filtered_counts: Record<string, Record<string, number>>;
|
||||
review: {
|
||||
@@ -303,6 +323,13 @@ export type CampaignJobsResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type CampaignJobsDeltaResponse = CampaignJobsResponse & {
|
||||
deleted: DeltaDeletedItem[];
|
||||
watermark?: string | null;
|
||||
has_more: boolean;
|
||||
full: boolean;
|
||||
};
|
||||
|
||||
export type CampaignJobDetailResponse = {
|
||||
job: Record<string, unknown>;
|
||||
attempts: {
|
||||
@@ -330,15 +357,26 @@ export async function listCampaigns(settings: ApiSettings): Promise<CampaignList
|
||||
return response.campaigns ?? response.items ?? response.results ?? [];
|
||||
}
|
||||
|
||||
export async function listCampaignsDelta(
|
||||
settings: ApiSettings,
|
||||
options: {since?: string | null;limit?: number;} = {})
|
||||
: Promise<CampaignDeltaResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.since) params.set("since", options.since);
|
||||
if (options.limit) params.set("limit", String(options.limit));
|
||||
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return apiFetch<CampaignDeltaResponse>(settings, `/api/v1/campaigns/delta${suffix}`);
|
||||
}
|
||||
|
||||
export async function listRecipientImportMappingProfiles(settings: ApiSettings): Promise<RecipientImportMappingProfile[]> {
|
||||
const response = await apiFetch<RecipientImportMappingProfileListResponse>(settings, "/api/v1/campaigns/recipient-import/mapping-profiles");
|
||||
return response.profiles ?? [];
|
||||
}
|
||||
|
||||
export async function createRecipientImportMappingProfile(
|
||||
settings: ApiSettings,
|
||||
payload: RecipientImportMappingProfilePayload
|
||||
): Promise<RecipientImportMappingProfile> {
|
||||
settings: ApiSettings,
|
||||
payload: RecipientImportMappingProfilePayload)
|
||||
: Promise<RecipientImportMappingProfile> {
|
||||
return apiFetch<RecipientImportMappingProfile>(settings, "/api/v1/campaigns/recipient-import/mapping-profiles", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -346,10 +384,10 @@ export async function createRecipientImportMappingProfile(
|
||||
}
|
||||
|
||||
export async function updateRecipientImportMappingProfile(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
payload: RecipientImportMappingProfilePayload
|
||||
): Promise<RecipientImportMappingProfile> {
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
payload: RecipientImportMappingProfilePayload)
|
||||
: Promise<RecipientImportMappingProfile> {
|
||||
return apiFetch<RecipientImportMappingProfile>(settings, `/api/v1/campaigns/recipient-import/mapping-profiles/${encodeURIComponent(profileId)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -365,10 +403,10 @@ export async function getCampaign(settings: ApiSettings, campaignId: string): Pr
|
||||
}
|
||||
|
||||
export async function updateCampaignMetadata(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignUpdatePayload
|
||||
): Promise<CampaignListItem> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignUpdatePayload)
|
||||
: Promise<CampaignListItem> {
|
||||
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -376,14 +414,14 @@ export async function updateCampaignMetadata(
|
||||
}
|
||||
|
||||
export async function createNewCampaign(
|
||||
settings: ApiSettings,
|
||||
overrides: CampaignCreateMinimalPayload = {}
|
||||
): Promise<CampaignCreateResponse> {
|
||||
settings: ApiSettings,
|
||||
overrides: CampaignCreateMinimalPayload = {})
|
||||
: Promise<CampaignCreateResponse> {
|
||||
const now = new Date();
|
||||
const stamp = now.toISOString().slice(0, 19).replace(/[-:T]/g, "");
|
||||
const payload = {
|
||||
external_id: overrides.external_id ?? `new-campaign-${stamp}`,
|
||||
name: overrides.name ?? "New Campaign",
|
||||
name: overrides.name ?? "i18n:govoplan-campaign.new_campaign.1f0d021c",
|
||||
description: overrides.description ?? "",
|
||||
current_flow: overrides.current_flow ?? "create",
|
||||
current_step: overrides.current_step ?? "basics"
|
||||
@@ -404,10 +442,10 @@ export async function getCampaignSchemaUi(settings: ApiSettings): Promise<unknow
|
||||
}
|
||||
|
||||
export async function getCampaignWorkspace(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
options: CampaignWorkspaceQuery = {}
|
||||
): Promise<CampaignWorkspaceResponse> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
options: CampaignWorkspaceQuery = {})
|
||||
: Promise<CampaignWorkspaceResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.versionId) params.set("version_id", options.versionId);
|
||||
if (options.includeCurrentVersion !== undefined) params.set("include_current_version", String(options.includeCurrentVersion));
|
||||
@@ -417,67 +455,83 @@ export async function getCampaignWorkspace(
|
||||
return apiFetch<CampaignWorkspaceResponse>(settings, `/api/v1/campaigns/${campaignId}/workspace${suffix}`);
|
||||
}
|
||||
|
||||
export async function getCampaignWorkspaceDelta(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
options: CampaignWorkspaceQuery = {})
|
||||
: Promise<CampaignWorkspaceDeltaResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.versionId) params.set("version_id", options.versionId);
|
||||
if (options.includeCurrentVersion !== undefined) params.set("include_current_version", String(options.includeCurrentVersion));
|
||||
if (options.includeSummary !== undefined) params.set("include_summary", String(options.includeSummary));
|
||||
if (options.includeVersions !== undefined) params.set("include_versions", String(options.includeVersions));
|
||||
if (options.since) params.set("since", options.since);
|
||||
if (options.limit) params.set("limit", String(options.limit));
|
||||
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return apiFetch<CampaignWorkspaceDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/workspace/delta${suffix}`);
|
||||
}
|
||||
|
||||
export async function listCampaignVersions(
|
||||
settings: ApiSettings,
|
||||
campaignId: string
|
||||
): Promise<CampaignVersionListItem[]> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string)
|
||||
: Promise<CampaignVersionListItem[]> {
|
||||
return apiFetch<CampaignVersionListItem[]>(settings, `/api/v1/campaigns/${campaignId}/versions`);
|
||||
}
|
||||
|
||||
export async function getCampaignVersion(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string
|
||||
): Promise<CampaignVersionDetail> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`);
|
||||
}
|
||||
|
||||
export async function unlockCampaignVersionValidation(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string
|
||||
): Promise<CampaignVersionDetail> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-validation`, {
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
|
||||
export async function lockCampaignVersionTemporarily(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string
|
||||
): Promise<CampaignVersionDetail> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-temporarily`, {
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
|
||||
export async function unlockCampaignVersionUserLock(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string
|
||||
): Promise<CampaignVersionDetail> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-user-lock`, {
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
|
||||
export async function lockCampaignVersionPermanently(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string
|
||||
): Promise<CampaignVersionDetail> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-permanently`, {
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCampaignVersion(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignVersionUpdatePayload
|
||||
): Promise<CampaignVersionDetail> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignVersionUpdatePayload)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -485,11 +539,11 @@ export async function updateCampaignVersion(
|
||||
}
|
||||
|
||||
export async function forkCampaignVersion(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignVersionUpdatePayload = {}
|
||||
): Promise<CampaignCreateResponse> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignVersionUpdatePayload = {})
|
||||
: Promise<CampaignCreateResponse> {
|
||||
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/fork`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -497,11 +551,11 @@ export async function forkCampaignVersion(
|
||||
}
|
||||
|
||||
export async function autosaveCampaignVersion(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignVersionUpdatePayload
|
||||
): Promise<CampaignVersionDetail> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignVersionUpdatePayload)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -509,12 +563,12 @@ export async function autosaveCampaignVersion(
|
||||
}
|
||||
|
||||
export async function setCampaignVersionStep(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
currentStep: string,
|
||||
currentFlow?: string | null
|
||||
): Promise<CampaignVersionDetail> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
currentStep: string,
|
||||
currentFlow?: string | null)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/set-step`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ current_flow: currentFlow, current_step: currentStep })
|
||||
@@ -522,11 +576,11 @@ export async function setCampaignVersionStep(
|
||||
}
|
||||
|
||||
export async function validatePartial(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignPartialValidationPayload = {}
|
||||
): Promise<CampaignPartialValidationResponse> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignPartialValidationPayload = {})
|
||||
: Promise<CampaignPartialValidationResponse> {
|
||||
return apiFetch<CampaignPartialValidationResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/validate-partial`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -534,20 +588,20 @@ export async function validatePartial(
|
||||
}
|
||||
|
||||
export async function publishCampaignVersion(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string
|
||||
): Promise<CampaignVersionDetail> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/publish`, {
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateVersion(
|
||||
settings: ApiSettings,
|
||||
versionId: string,
|
||||
checkFiles = false
|
||||
): Promise<Record<string, unknown>> {
|
||||
settings: ApiSettings,
|
||||
versionId: string,
|
||||
checkFiles = false)
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ check_files: checkFiles })
|
||||
@@ -555,10 +609,10 @@ export async function validateVersion(
|
||||
}
|
||||
|
||||
export async function buildVersion(
|
||||
settings: ApiSettings,
|
||||
versionId: string,
|
||||
writeEml = true
|
||||
): Promise<Record<string, unknown>> {
|
||||
settings: ApiSettings,
|
||||
versionId: string,
|
||||
writeEml = true)
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/build`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ write_eml: writeEml })
|
||||
@@ -567,11 +621,11 @@ export async function buildVersion(
|
||||
|
||||
|
||||
export function previewCampaignAttachments(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignAttachmentPreviewPayload = {}
|
||||
): Promise<CampaignAttachmentPreviewResponse> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignAttachmentPreviewPayload = {})
|
||||
: Promise<CampaignAttachmentPreviewResponse> {
|
||||
return apiFetch<CampaignAttachmentPreviewResponse>(
|
||||
settings,
|
||||
`/api/v1/campaigns/${campaignId}/versions/${versionId}/attachments/preview`,
|
||||
@@ -580,23 +634,24 @@ export function previewCampaignAttachments(
|
||||
}
|
||||
|
||||
export async function getCampaignSummary(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId?: string
|
||||
): Promise<CampaignSummary> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId?: string)
|
||||
: Promise<CampaignSummary> {
|
||||
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
|
||||
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary${suffix}`);
|
||||
}
|
||||
|
||||
export async function getCampaignJobs(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
options: CampaignJobsQuery = {}
|
||||
): Promise<CampaignJobsResponse> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
options: CampaignJobsQuery = {})
|
||||
: Promise<CampaignJobsResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.versionId) params.set("version_id", options.versionId);
|
||||
if (options.page) params.set("page", String(options.page));
|
||||
if (options.pageSize) params.set("page_size", String(options.pageSize));
|
||||
if (options.cursor) params.set("cursor", options.cursor);
|
||||
for (const value of options.sendStatus ?? []) params.append("send_status", value);
|
||||
for (const value of options.validationStatus ?? []) params.append("validation_status", value);
|
||||
for (const value of options.imapStatus ?? []) params.append("imap_status", value);
|
||||
@@ -605,29 +660,49 @@ export async function getCampaignJobs(
|
||||
return apiFetch<CampaignJobsResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`);
|
||||
}
|
||||
|
||||
export async function getCampaignJobsDelta(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
options: CampaignJobsQuery & {since?: string | null;limit?: number;} = {})
|
||||
: Promise<CampaignJobsDeltaResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.versionId) params.set("version_id", options.versionId);
|
||||
if (options.page) params.set("page", String(options.page));
|
||||
if (options.pageSize) params.set("page_size", String(options.pageSize));
|
||||
if (options.cursor) params.set("cursor", options.cursor);
|
||||
for (const value of options.sendStatus ?? []) params.append("send_status", value);
|
||||
for (const value of options.validationStatus ?? []) params.append("validation_status", value);
|
||||
for (const value of options.imapStatus ?? []) params.append("imap_status", value);
|
||||
if (options.query?.trim()) params.set("q", options.query.trim());
|
||||
if (options.since) params.set("since", options.since);
|
||||
if (options.limit) params.set("limit", String(options.limit));
|
||||
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return apiFetch<CampaignJobsDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/delta${suffix}`);
|
||||
}
|
||||
|
||||
export async function getCampaignJobDetail(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
jobId: string
|
||||
): Promise<CampaignJobDetailResponse> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
jobId: string)
|
||||
: Promise<CampaignJobDetailResponse> {
|
||||
return apiFetch<CampaignJobDetailResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}`);
|
||||
}
|
||||
|
||||
export async function getCampaignReport(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId?: string
|
||||
): Promise<CampaignSummary> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId?: string)
|
||||
: Promise<CampaignSummary> {
|
||||
const params = new URLSearchParams({ include_jobs: "false" });
|
||||
if (versionId) params.set("version_id", versionId);
|
||||
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/report?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function downloadCampaignJobsCsv(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId?: string
|
||||
): Promise<void> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId?: string)
|
||||
: Promise<void> {
|
||||
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
|
||||
await apiDownload(
|
||||
settings,
|
||||
@@ -637,10 +712,10 @@ export async function downloadCampaignJobsCsv(
|
||||
}
|
||||
|
||||
export async function emailCampaignReport(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignReportEmailPayload
|
||||
): Promise<Record<string, unknown>> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignReportEmailPayload)
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/report/email`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -648,10 +723,10 @@ export async function emailCampaignReport(
|
||||
}
|
||||
|
||||
export async function retryCampaignJobs(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: Record<string, unknown> = {}
|
||||
): Promise<Record<string, unknown>> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: Record<string, unknown> = {})
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/retry`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -659,10 +734,10 @@ export async function retryCampaignJobs(
|
||||
}
|
||||
|
||||
export async function sendUnattemptedCampaignJobs(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: Record<string, unknown> = {}
|
||||
): Promise<Record<string, unknown>> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: Record<string, unknown> = {})
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/send-unattempted`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -670,12 +745,12 @@ export async function sendUnattemptedCampaignJobs(
|
||||
}
|
||||
|
||||
export async function resolveCampaignJobOutcome(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
jobId: string,
|
||||
decision: "smtp_accepted" | "not_sent",
|
||||
note?: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
jobId: string,
|
||||
decision: "smtp_accepted" | "not_sent",
|
||||
note?: string)
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/resolve-outcome`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ decision, note: note || null })
|
||||
@@ -683,11 +758,11 @@ export async function resolveCampaignJobOutcome(
|
||||
}
|
||||
|
||||
export async function updateCampaignReviewState(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignReviewStatePayload
|
||||
): Promise<CampaignVersionDetail> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignReviewStatePayload)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/review-state`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -695,10 +770,10 @@ export async function updateCampaignReviewState(
|
||||
}
|
||||
|
||||
export async function queueCampaign(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignQueuePayload = {}
|
||||
): Promise<Record<string, unknown>> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignQueuePayload = {})
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/queue`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -706,10 +781,10 @@ export async function queueCampaign(
|
||||
}
|
||||
|
||||
export async function sendCampaignNow(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignSendNowPayload = {}
|
||||
): Promise<Record<string, unknown>> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignSendNowPayload = {})
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/send-now`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -718,10 +793,10 @@ export async function sendCampaignNow(
|
||||
|
||||
|
||||
export async function mockSendCampaign(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignMockSendPayload = {}
|
||||
): Promise<Record<string, unknown>> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignMockSendPayload = {})
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/mock-send`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -741,10 +816,10 @@ export async function cancelCampaign(settings: ApiSettings, campaignId: string):
|
||||
}
|
||||
|
||||
export async function appendSent(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignAppendSentPayload = {}
|
||||
): Promise<Record<string, unknown>> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignAppendSentPayload = {})
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/append-sent`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
@@ -760,23 +835,23 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId:
|
||||
}
|
||||
|
||||
export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> {
|
||||
const response = await apiFetch<{ shares: CampaignShare[] }>(settings, `/api/v1/campaigns/${campaignId}/shares`);
|
||||
const response = await apiFetch<{shares: CampaignShare[];}>(settings, `/api/v1/campaigns/${campaignId}/shares`);
|
||||
return response.shares;
|
||||
}
|
||||
|
||||
export async function updateCampaignOwner(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: { owner_user_id?: string | null; owner_group_id?: string | null }
|
||||
): Promise<CampaignListItem> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: {owner_user_id?: string | null;owner_group_id?: string | null;})
|
||||
: Promise<CampaignListItem> {
|
||||
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}/owner`, { method: "PUT", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export async function upsertCampaignShare(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: { target_type: "user" | "group"; target_id: string; permission: "read" | "write" }
|
||||
): Promise<CampaignShare> {
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: {target_type: "user" | "group";target_id: string;permission: "read" | "write";})
|
||||
: Promise<CampaignShare> {
|
||||
return apiFetch<CampaignShare>(settings, `/api/v1/campaigns/${campaignId}/shares`, { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
|
||||
@@ -5,28 +5,28 @@ import { StatusBadge } from "@govoplan/core-webui";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
|
||||
const personalContacts = [
|
||||
{ name: "Ada Lovelace", email: "ada@example.local", source: "Personal", tags: "Used recently" },
|
||||
{ name: "Grace Hopper", email: "grace@example.local", source: "Personal", tags: "Favorite" }
|
||||
];
|
||||
{ name: "i18n:govoplan-campaign.ada_lovelace.a69a9f8a", email: "ada@example.local", source: "Personal", tags: "i18n:govoplan-campaign.used_recently.af75cea7" },
|
||||
{ name: "i18n:govoplan-campaign.grace_hopper.d97e6939", email: "grace@example.local", source: "Personal", tags: "i18n:govoplan-campaign.favorite.6b90b6a1" }];
|
||||
|
||||
|
||||
const groupContacts = [
|
||||
{ name: "Project Office", email: "project-office@example.local", source: "Group", tags: "Shared" },
|
||||
{ name: "Finance Team", email: "finance@example.local", source: "Group", tags: "Shared list" }
|
||||
];
|
||||
{ name: "i18n:govoplan-campaign.project_office.c35aa9ca", email: "project-office@example.local", source: "Group", tags: "i18n:govoplan-campaign.shared.50d0d8dd" },
|
||||
{ name: "i18n:govoplan-campaign.finance_team.ff353e5c", email: "finance@example.local", source: "Group", tags: "i18n:govoplan-campaign.shared_list.b3c94b39" }];
|
||||
|
||||
|
||||
const tenantContacts = [
|
||||
{ name: "Helpdesk", email: "helpdesk@example.local", source: "Tenant", tags: "Directory" },
|
||||
{ name: "Data Protection", email: "privacy@example.local", source: "Tenant", tags: "Directory" }
|
||||
];
|
||||
{ name: "i18n:govoplan-campaign.helpdesk.191815bf", email: "helpdesk@example.local", source: "Tenant", tags: "i18n:govoplan-campaign.directory.4b892fe0" },
|
||||
{ name: "i18n:govoplan-campaign.data_protection.06e87cd3", email: "privacy@example.local", source: "Tenant", tags: "i18n:govoplan-campaign.directory.4b892fe0" }];
|
||||
|
||||
function contactColumns(): DataGridColumn<{ name: string; email: string; source: string; tags: string }>[] {
|
||||
|
||||
function contactColumns(): DataGridColumn<{name: string;email: string;source: string;tags: string;}>[] {
|
||||
return [
|
||||
{ id: "name", header: "Name", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (contact) => <strong>{contact.name}</strong>, value: (contact) => contact.name },
|
||||
{ id: "email", header: "Email", width: 240, sortable: true, filterable: true, value: (contact) => contact.email },
|
||||
{ id: "scope", header: "Scope", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Personal", label: "Personal" }, { value: "Group", label: "Group" }, { value: "Tenant", label: "Tenant" }] }, value: (contact) => contact.source },
|
||||
{ id: "tags", header: "Tags", width: 180, sortable: true, filterable: true, value: (contact) => contact.tags },
|
||||
{ id: "status", header: "Status", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "mock", label: "Mock" }], display: "pill" }, render: () => <StatusBadge status="mock" />, value: () => "mock" }
|
||||
];
|
||||
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (contact) => <strong>{contact.name}</strong>, value: (contact) => contact.name },
|
||||
{ id: "email", header: "i18n:govoplan-campaign.email.84add5b2", width: 240, sortable: true, filterable: true, value: (contact) => contact.email },
|
||||
{ id: "scope", header: "i18n:govoplan-campaign.scope.4651a34e", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Personal", label: "i18n:govoplan-campaign.personal.40f07323" }, { value: "Group", label: "i18n:govoplan-campaign.group.171a0606" }, { value: "Tenant", label: "i18n:govoplan-campaign.tenant.3ca93c78" }] }, value: (contact) => contact.source },
|
||||
{ id: "tags", header: "i18n:govoplan-campaign.tags.848eed0f", width: 180, sortable: true, filterable: true, value: (contact) => contact.tags },
|
||||
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "mock", label: "i18n:govoplan-campaign.mock.3bba2a47" }], display: "pill" }, render: () => <StatusBadge status="mock" />, value: () => "mock" }];
|
||||
|
||||
}
|
||||
|
||||
export default function AddressBookPage() {
|
||||
@@ -36,55 +36,55 @@ export default function AddressBookPage() {
|
||||
<div className="content-pad workspace-data-page module-entry-page address-book-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>Address Book</PageTitle>
|
||||
<p>Mock workspace for personal, group and tenant address books. These contacts can later feed recipient autocomplete and reusable address selections.</p>
|
||||
<PageTitle>i18n:govoplan-campaign.address_book.f6327f59</PageTitle>
|
||||
<p>i18n:govoplan-campaign.mock_workspace_for_personal_group_and_tenant_add.ce99f4d4</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>Import</Button>
|
||||
<Button variant="primary" disabled>Add contact</Button>
|
||||
<Button disabled>i18n:govoplan-campaign.import.d6fbc9d2</Button>
|
||||
<Button variant="primary" disabled>i18n:govoplan-campaign.add_contact.6da0b4b8</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metric-grid">
|
||||
<Card title="Personal"><strong className="module-big-number">{personalContacts.length}</strong><p className="muted">Private contacts and remembered addresses.</p></Card>
|
||||
<Card title="Group"><strong className="module-big-number">{groupContacts.length}</strong><p className="muted">Shared group address books and lists.</p></Card>
|
||||
<Card title="Tenant"><strong className="module-big-number">{tenantContacts.length}</strong><p className="muted">Tenant directory and approved shared contacts.</p></Card>
|
||||
<Card title="Sync"><strong className="module-big-number">Mock</strong><p className="muted">CardDAV/LDAP/import connectors can be added later.</p></Card>
|
||||
<Card title="i18n:govoplan-campaign.personal.40f07323"><strong className="module-big-number">{personalContacts.length}</strong><p className="muted">i18n:govoplan-campaign.private_contacts_and_remembered_addresses.2bd71556</p></Card>
|
||||
<Card title="i18n:govoplan-campaign.group.171a0606"><strong className="module-big-number">{groupContacts.length}</strong><p className="muted">i18n:govoplan-campaign.shared_group_address_books_and_lists.12bac69d</p></Card>
|
||||
<Card title="i18n:govoplan-campaign.tenant.3ca93c78"><strong className="module-big-number">{tenantContacts.length}</strong><p className="muted">i18n:govoplan-campaign.tenant_directory_and_approved_shared_contacts.fa671f1b</p></Card>
|
||||
<Card title="i18n:govoplan-campaign.sync.905f6309"><strong className="module-big-number">i18n:govoplan-campaign.mock.3bba2a47</strong><p className="muted">i18n:govoplan-campaign.carddav_ldap_import_connectors_can_be_added_late.0471f513</p></Card>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Address book scopes">
|
||||
<Card title="i18n:govoplan-campaign.address_book_scopes.b0d0efde">
|
||||
<div className="address-book-scope-list">
|
||||
<AddressBookScope title="Personal address book" description="Private contacts, remembered recipients and personal aliases." status="Local" />
|
||||
<AddressBookScope title="Group address books" description="Shared contact sets for teams, departments or campaign groups." status="Shared" />
|
||||
<AddressBookScope title="Tenant directory" description="Tenant-wide contacts, functional mailboxes and approved lists." status="Directory" />
|
||||
<AddressBookScope title="i18n:govoplan-campaign.personal_address_book.e240066d" description="i18n:govoplan-campaign.private_contacts_remembered_recipients_and_perso.685cca95" status="Local" />
|
||||
<AddressBookScope title="i18n:govoplan-campaign.group_address_books.aed5a7c0" description="i18n:govoplan-campaign.shared_contact_sets_for_teams_departments_or_cam.4408edd7" status="Shared" />
|
||||
<AddressBookScope title="i18n:govoplan-campaign.tenant_directory.11b0e09c" description="i18n:govoplan-campaign.tenant_wide_contacts_functional_mailboxes_and_ap.c437a8b9" status="Directory" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Planned address actions">
|
||||
<Card title="i18n:govoplan-campaign.planned_address_actions.1d4a056a">
|
||||
<div className="placeholder-stack">
|
||||
<span>Choose addresses from personal, group or tenant source</span>
|
||||
<span>Remember addresses used in campaigns after opt-in</span>
|
||||
<span>Share selected contacts with a group</span>
|
||||
<span>Use contacts in To, CC, BCC, sender and Reply-To fields</span>
|
||||
<span>i18n:govoplan-campaign.choose_addresses_from_personal_group_or_tenant_s.3d6dc0e4</span>
|
||||
<span>i18n:govoplan-campaign.remember_addresses_used_in_campaigns_after_opt_i.6f8b2529</span>
|
||||
<span>i18n:govoplan-campaign.share_selected_contacts_with_a_group.604d1464</span>
|
||||
<span>i18n:govoplan-campaign.use_contacts_in_to_cc_bcc_sender_and_reply_to_fi.79f3ea6a</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="Contacts" actions={<Button disabled>Manage sources</Button>}>
|
||||
<Card title="i18n:govoplan-campaign.contacts.b0dd615c" actions={<Button disabled>i18n:govoplan-campaign.manage_sources.ec758de0</Button>}>
|
||||
<DataGrid
|
||||
id="address-book-contacts"
|
||||
rows={allContacts}
|
||||
columns={contactColumns()}
|
||||
getRowKey={(contact) => contact.source + "-" + contact.email}
|
||||
emptyText="No contacts found."
|
||||
className="compact-table-wrap module-table"
|
||||
/>
|
||||
emptyText="i18n:govoplan-campaign.no_contacts_found.ad977b09"
|
||||
className="compact-table-wrap module-table" />
|
||||
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function AddressBookScope({ title, description, status }: { title: string; description: string; status: string }) {
|
||||
function AddressBookScope({ title, description, status }: {title: string;description: string;status: string;}) {
|
||||
return (
|
||||
<div className="address-book-scope-card">
|
||||
<div>
|
||||
@@ -92,6 +92,6 @@ function AddressBookScope({ title, description, status }: { title: string; descr
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui";
|
||||
import { useGuardedNavigate, usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
@@ -19,14 +19,15 @@ import { updateNested } from "./utils/draftEditor";
|
||||
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
|
||||
import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog";
|
||||
import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments";
|
||||
import { insertAfter, moveArrayItem } from "@govoplan/core-webui";
|
||||
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
||||
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
|
||||
import { buildTemplatePreviewContext, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
||||
|
||||
type PathChooserState = { index: number };
|
||||
type IndividualDisableState = { index: number; usageCount: number };
|
||||
type PathChooserState = {index: number;};
|
||||
type IndividualDisableState = {index: number;usageCount: number;};
|
||||
|
||||
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
export default function AttachmentsDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
||||
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
|
||||
const ManagedFileChooser = filesModuleInstalled ? filesFileExplorer?.ManagedFileChooser : null;
|
||||
@@ -47,8 +48,8 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
reload,
|
||||
setError,
|
||||
currentStep: "files",
|
||||
unsavedTitle: "Unsaved attachment settings",
|
||||
unsavedMessage: "Attachment settings have unsaved changes. Save them before leaving, or discard them and continue."
|
||||
unsavedTitle: "i18n:govoplan-campaign.unsaved_attachment_settings.a6045f67",
|
||||
unsavedMessage: "i18n:govoplan-campaign.attachment_settings_have_unsaved_changes_save_th.b9b415bb"
|
||||
});
|
||||
const attachments = asRecord(displayDraft.attachments);
|
||||
const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]);
|
||||
@@ -78,10 +79,10 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void listManagedFileSpaces(settings)
|
||||
.then((response) => { if (!cancelled) setFileSpaces(response.spaces); })
|
||||
.catch(() => { if (!cancelled) setFileSpaces([]); });
|
||||
return () => { cancelled = true; };
|
||||
void listManagedFileSpaces(settings).
|
||||
then((response) => {if (!cancelled) setFileSpaces(response.spaces);}).
|
||||
catch(() => {if (!cancelled) setFileSpaces([]);});
|
||||
return () => {cancelled = true;};
|
||||
}, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
function patchBasePaths(paths: AttachmentBasePath[]) {
|
||||
@@ -153,9 +154,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
}
|
||||
|
||||
function setZipEnabled(enabled: boolean) {
|
||||
const archives = enabled && zipConfig.archives.length === 0
|
||||
? [createAttachmentZipArchive("{{local:id}}-attachments.zip", true)]
|
||||
: zipConfig.archives;
|
||||
const archives = enabled && zipConfig.archives.length === 0 ?
|
||||
[createAttachmentZipArchive("{{local:id}}-attachments.zip", true)] :
|
||||
zipConfig.archives;
|
||||
patchZipCollection({ enabled, archives });
|
||||
}
|
||||
|
||||
@@ -195,9 +196,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
const resetRule = (value: unknown) => {
|
||||
const rule = asRecord(value);
|
||||
const ruleZip = asRecord(rule.zip);
|
||||
return String(ruleZip.archive_id ?? "") === removed.id
|
||||
? { ...rule, zip: { ...ruleZip, archive_id: "inherit" } }
|
||||
: rule;
|
||||
return String(ruleZip.archive_id ?? "") === removed.id ?
|
||||
{ ...rule, zip: { ...ruleZip, archive_id: "inherit" } } :
|
||||
rule;
|
||||
};
|
||||
setDraft((current) => {
|
||||
const source = asRecord(current ?? {});
|
||||
@@ -232,44 +233,44 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Attachments</PageTitle>
|
||||
<PageTitle loading={loading}>i18n:govoplan-campaign.attachments.6771ade6</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
{filesModuleInstalled && <Button onClick={() => { window.location.href = "/files"; }}>Manage files</Button>}
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
|
||||
{filesModuleInstalled && <Button onClick={() => navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7</Button>}
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||
<>
|
||||
<Card title="Attachment sources">
|
||||
<Card title="i18n:govoplan-campaign.attachment_sources.8ef0a6ce">
|
||||
<div className="admin-table-surface attachment-sources-table-surface">
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-attachment-sources`}
|
||||
rows={basePaths}
|
||||
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
|
||||
getRowKey={(basePath) => basePath.id}
|
||||
emptyText="No attachment sources configured."
|
||||
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="Add first attachment source" />}
|
||||
className="attachment-sources-table-wrap attachment-sources-table"
|
||||
/>
|
||||
emptyText="i18n:govoplan-campaign.no_attachment_sources_configured.48664606"
|
||||
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_attachment_source.cefa7882" />}
|
||||
className="attachment-sources-table-wrap attachment-sources-table" />
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="ZIP attachments" collapsible>
|
||||
<Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible>
|
||||
<div className="attachment-zip-master-toggle">
|
||||
<ToggleSwitch
|
||||
label="Enable ZIP attachments"
|
||||
label="i18n:govoplan-campaign.enable_zip_attachments.6077075b"
|
||||
checked={zipConfig.enabled}
|
||||
disabled={locked}
|
||||
onChange={setZipEnabled}
|
||||
/>
|
||||
onChange={setZipEnabled} />
|
||||
|
||||
</div>
|
||||
<div className="admin-table-surface attachment-zip-table-surface">
|
||||
<DataGrid
|
||||
@@ -288,48 +289,48 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
removeArchive: removeZipArchive
|
||||
})}
|
||||
getRowKey={(archive) => archive.id}
|
||||
emptyText={zipConfig.enabled ? "No ZIP attachments configured." : "ZIP attachments are disabled."}
|
||||
emptyAction={zipConfig.enabled ? <DataGridEmptyAction onAdd={() => addZipArchive(-1)} disabled={locked} label="Add first ZIP attachment" /> : undefined}
|
||||
className="attachment-zip-table-wrap"
|
||||
/>
|
||||
emptyText={zipConfig.enabled ? "i18n:govoplan-campaign.no_zip_attachments_configured.29c16424" : "i18n:govoplan-campaign.zip_attachments_are_disabled.6969b41d"}
|
||||
emptyAction={zipConfig.enabled ? <DataGridEmptyAction onAdd={() => addZipArchive(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_zip_attachment.f64cc332" /> : undefined}
|
||||
className="attachment-zip-table-wrap" />
|
||||
|
||||
</div>
|
||||
{zipConfig.enabled && passwordFields.length === 0 && zipConfig.archives.some((archive) => archive.password_enabled) && (
|
||||
<DismissibleAlert tone="warning">No password field exists. Add one under Fields with field type <strong>Password</strong>.</DismissibleAlert>
|
||||
)}
|
||||
{zipArchiveNameValidation.message && (
|
||||
<DismissibleAlert tone="danger" compact resetKey={zipArchiveNameValidation.message} className="attachment-zip-name-error">{zipArchiveNameValidation.message}</DismissibleAlert>
|
||||
)}
|
||||
<p className="muted small-note">Archive names support recipient and campaign fields. Use the pencil action to edit the filename and insert placeholders. Password fields are intentionally not offered for filenames. The campaign standard is used by attachment rows set to Campaign standard.</p>
|
||||
{zipConfig.enabled && passwordFields.length === 0 && zipConfig.archives.some((archive) => archive.password_enabled) &&
|
||||
<DismissibleAlert tone="warning">i18n:govoplan-campaign.no_password_field_exists_add_one_under_fields_wi.2b6d4a7f <strong>i18n:govoplan-campaign.password.8be3c943</strong>.</DismissibleAlert>
|
||||
}
|
||||
{zipArchiveNameValidation.message &&
|
||||
<DismissibleAlert tone="danger" compact resetKey={zipArchiveNameValidation.message} className="attachment-zip-name-error">{zipArchiveNameValidation.message}</DismissibleAlert>
|
||||
}
|
||||
<p className="muted small-note">i18n:govoplan-campaign.archive_names_support_recipient_and_campaign_fie.d5b1b2d1</p>
|
||||
</Card>
|
||||
|
||||
<Card title="Global Attachments" collapsible>
|
||||
<Card title="i18n:govoplan-campaign.global_attachments.492bd841" collapsible>
|
||||
<AttachmentRulesDataGrid
|
||||
id={`campaign-${campaignId}-global-attachments`}
|
||||
rules={globalRules}
|
||||
disabled={locked}
|
||||
emptyText="No global attachments are configured yet. Add files here only if every message should include them."
|
||||
emptyText="i18n:govoplan-campaign.no_global_attachments_are_configured_yet_add_fil.d9fdfbb8"
|
||||
basePaths={basePaths}
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
zipConfig={zipConfig}
|
||||
filesModuleInstalled={managedFilesAvailable}
|
||||
previewContext={attachmentPreviewContext}
|
||||
onChange={(rules) => patch(["attachments", "global"], rules)}
|
||||
/>
|
||||
onChange={(rules) => patch(["attachments", "global"], rules)} />
|
||||
|
||||
</Card>
|
||||
|
||||
<Card title="Statistics" collapsible>
|
||||
<Card title="i18n:govoplan-campaign.statistics.2086b21f" collapsible>
|
||||
<dl className="detail-list">
|
||||
<div><dt>Base paths</dt><dd>{basePaths.length}</dd></div>
|
||||
<div><dt>Global attachments</dt><dd>direct: {globalSummary.direct} / rules: {globalSummary.rules}</dd></div>
|
||||
<div><dt>Per-recipient patterns</dt><dd>{individualRulesCount}</dd></div>
|
||||
<div><dt>Upload support</dt><dd>{managedFilesAvailable ? "Connected through Files" : "Manual paths"}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.base_paths.edf35a97</dt><dd>{basePaths.length}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.global_attachments.438263a1</dt><dd>direct: {globalSummary.direct} i18n:govoplan-campaign.rules.e5d36074 {globalSummary.rules}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.per_recipient_patterns.53da30da</dt><dd>{individualRulesCount}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.upload_support.1c54931c</dt><dd>{managedFilesAvailable ? "i18n:govoplan-campaign.connected_through_files.95007112" : "i18n:govoplan-campaign.manual_paths.8e20627a"}</dd></div>
|
||||
</dl>
|
||||
<p className="muted small-note">{managedFilesAvailable
|
||||
? "Files are managed in the top-level Files module. Upload there, share files with campaigns, then use these rules to resolve concrete attachments during review/build."
|
||||
: filesModuleInstalled
|
||||
? "Managed file browsing is unavailable. Use manual paths and patterns until the Files module exposes the file explorer capability."
|
||||
: "The Files module is not installed. Use manual paths and patterns; managed file browsing and sharing are unavailable."}</p>
|
||||
<p className="muted small-note">{managedFilesAvailable ?
|
||||
"i18n:govoplan-campaign.files_are_managed_in_the_top_level_files_module_.4b370222" :
|
||||
filesModuleInstalled ?
|
||||
"i18n:govoplan-campaign.managed_file_browsing_is_unavailable_use_manual_.782867fe" :
|
||||
"i18n:govoplan-campaign.the_files_module_is_not_installed_use_manual_pat.49abc725"}</p>
|
||||
</Card>
|
||||
|
||||
</>
|
||||
@@ -337,13 +338,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
|
||||
<TemplateExpressionEditorDialog
|
||||
open={zipNameEditorIndex !== null && Boolean(zipConfig.archives[zipNameEditorIndex])}
|
||||
title="Edit ZIP archive filename"
|
||||
value={zipNameEditorIndex !== null ? (zipConfig.archives[zipNameEditorIndex]?.name ?? "") : ""}
|
||||
title="i18n:govoplan-campaign.edit_zip_archive_filename.5a2c95e4"
|
||||
value={zipNameEditorIndex !== null ? zipConfig.archives[zipNameEditorIndex]?.name ?? "" : ""}
|
||||
localFields={filenameFieldOptions.filter((field) => field.namespace === "local").map(({ name, label }) => ({ name, label }))}
|
||||
globalFields={filenameFieldOptions.filter((field) => field.namespace === "global").map(({ name, label }) => ({ name, label }))}
|
||||
placeholder="attachments.zip"
|
||||
description="Use a fixed filename or combine text with recipient and campaign fields. The .zip suffix is added automatically when omitted."
|
||||
saveLabel="Save filename"
|
||||
description="i18n:govoplan-campaign.use_a_fixed_filename_or_combine_text_with_recipi.43a091fd"
|
||||
saveLabel="i18n:govoplan-campaign.save_filename.2dbd2273"
|
||||
validate={(value) => zipNameEditorIndex === null ? "" : describeZipArchiveNameValueProblem(zipConfig.archives, zipNameEditorIndex, value)}
|
||||
onCancel={() => setZipNameEditorIndex(null)}
|
||||
onSave={(name) => {
|
||||
@@ -356,52 +357,52 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
const existingFields = asArray(draft.fields).map(asRecord);
|
||||
if (existingFields.some((field) => String(field.name || field.id || "").trim() === name)) return;
|
||||
patch(["fields"], [
|
||||
...existingFields,
|
||||
{ name, label: humanizeFieldName(name), type: "string", required: false, can_override: true }
|
||||
]);
|
||||
...existingFields,
|
||||
{ name, label: humanizeFieldName(name), type: "string", required: false, can_override: true }]
|
||||
);
|
||||
}}
|
||||
canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)}
|
||||
/>
|
||||
canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)} />
|
||||
|
||||
|
||||
{pathChooser && ManagedFileChooser && (
|
||||
<ManagedFileChooser
|
||||
open
|
||||
settings={settings}
|
||||
storageScope={campaignId}
|
||||
mode="folder"
|
||||
source={basePaths[pathChooser.index]?.source}
|
||||
basePath={basePaths[pathChooser.index]?.path}
|
||||
onClose={() => setPathChooser(null)}
|
||||
onSelectFolder={(selection) => {
|
||||
const current = basePaths[pathChooser.index];
|
||||
const folderLabel = selection.folderPath ? selection.folderPath.split("/").filter(Boolean).slice(-1)[0] : selection.space.label;
|
||||
patchBasePath(pathChooser.index, {
|
||||
source: selection.source,
|
||||
path: selection.folderPath || ".",
|
||||
name: !current?.name || current.name === "New attachment source" || current.name === "Campaign files"
|
||||
? `${selection.space.label}${selection.folderPath ? ` / ${folderLabel}` : ""}`
|
||||
: current.name
|
||||
});
|
||||
setPathChooser(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{pathChooser && ManagedFileChooser &&
|
||||
<ManagedFileChooser
|
||||
open
|
||||
settings={settings}
|
||||
storageScope={campaignId}
|
||||
mode="folder"
|
||||
source={basePaths[pathChooser.index]?.source}
|
||||
basePath={basePaths[pathChooser.index]?.path}
|
||||
onClose={() => setPathChooser(null)}
|
||||
onSelectFolder={(selection) => {
|
||||
const current = basePaths[pathChooser.index];
|
||||
const folderLabel = selection.folderPath ? selection.folderPath.split("/").filter(Boolean).slice(-1)[0] : selection.space.label;
|
||||
patchBasePath(pathChooser.index, {
|
||||
source: selection.source,
|
||||
path: selection.folderPath || ".",
|
||||
name: !current?.name || current.name === "i18n:govoplan-campaign.new_attachment_source.563145ba" || current.name === "i18n:govoplan-campaign.campaign_files.96e7004b" ?
|
||||
`${selection.space.label}${selection.folderPath ? ` / ${folderLabel}` : ""}` :
|
||||
current.name
|
||||
});
|
||||
setPathChooser(null);
|
||||
}} />
|
||||
|
||||
}
|
||||
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(individualDisable)}
|
||||
title="Disable individual attachments for this source?"
|
||||
message={individualDisable
|
||||
? `${individualDisable.usageCount} individual attachment ${individualDisable.usageCount === 1 ? "entry uses" : "entries use"} this source. Disabling it will remove those recipient-specific attachment entries.`
|
||||
: ""}
|
||||
confirmLabel="Remove individual attachments"
|
||||
cancelLabel="Cancel"
|
||||
title="i18n:govoplan-campaign.disable_individual_attachments_for_this_source.185f9a95"
|
||||
message={individualDisable ? i18nMessage("i18n:govoplan-campaign.value_individual_attachment_value_this_source_di.3c7428e7", { value0:
|
||||
individualDisable.usageCount, value1: individualDisable.usageCount === 1 ? "i18n:govoplan-campaign.entry_uses.5f269cab" : "i18n:govoplan-campaign.entries_use.e9169679" }) :
|
||||
""}
|
||||
confirmLabel="i18n:govoplan-campaign.remove_individual_attachments.9e5f0ad3"
|
||||
cancelLabel="i18n:govoplan-campaign.cancel.77dfd213"
|
||||
tone="danger"
|
||||
onConfirm={confirmIndividualDisable}
|
||||
onCancel={() => setIndividualDisable(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
onCancel={() => setIndividualDisable(null)} />
|
||||
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
type ZipArchiveColumnContext = {
|
||||
@@ -419,73 +420,73 @@ type ZipArchiveColumnContext = {
|
||||
|
||||
function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] {
|
||||
return [
|
||||
{
|
||||
id: "name", header: "Archive name", width: "minmax(360px, 1fr)", resizable: true, sortable: true, filterable: true, sticky: "start",
|
||||
render: (archive, index) => (
|
||||
<button
|
||||
type="button"
|
||||
className="attachment-zip-name-button"
|
||||
disabled={disabled}
|
||||
aria-invalid={invalidNameIndexes.has(index) || undefined}
|
||||
aria-label={`Edit ZIP archive filename ${archive.name || index + 1}`}
|
||||
title={invalidNameIndexes.has(index) ? "Archive filenames must be present and unique." : "Edit ZIP archive filename"}
|
||||
onClick={() => onEditName(index)}
|
||||
>
|
||||
<span className="attachment-zip-name-value">{archive.name || "Unnamed ZIP attachment"}</span>
|
||||
{
|
||||
id: "name", header: "i18n:govoplan-campaign.archive_name.6310f9e1", width: "minmax(360px, 1fr)", resizable: true, sortable: true, filterable: true, sticky: "start",
|
||||
render: (archive, index) =>
|
||||
<button
|
||||
type="button"
|
||||
className="attachment-zip-name-button"
|
||||
disabled={disabled}
|
||||
aria-invalid={invalidNameIndexes.has(index) || undefined}
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.edit_zip_archive_filename_value.deb41dfc", { value0: archive.name || index + 1 })}
|
||||
title={invalidNameIndexes.has(index) ? "i18n:govoplan-campaign.archive_filenames_must_be_present_and_unique.aa2fb639" : "i18n:govoplan-campaign.edit_zip_archive_filename.5a2c95e4"}
|
||||
onClick={() => onEditName(index)}>
|
||||
|
||||
<span className="attachment-zip-name-value">{archive.name || "i18n:govoplan-campaign.unnamed_zip_attachment.54f515bb"}</span>
|
||||
<Pencil size={15} aria-hidden="true" />
|
||||
</button>
|
||||
),
|
||||
value: (archive) => archive.name
|
||||
},
|
||||
{
|
||||
id: "standard", header: "Standard", width: 150, sortable: true, filterable: true,
|
||||
columnType: "from-list", list: { options: [{ value: "standard", label: "Standard" }, { value: "optional", label: "Optional" }] },
|
||||
render: (archive, index) => <ToggleSwitch label="Standard" checked={archive.standard} disabled={disabled} onChange={(checked) => checked && setStandard(index)} />,
|
||||
value: (archive) => archive.standard ? "standard" : "optional"
|
||||
},
|
||||
{
|
||||
id: "password_enabled", header: "Password", width: 165, sortable: true, filterable: true,
|
||||
columnType: "from-list", list: { options: [{ value: "protected", label: "Protected" }, { value: "none", label: "No password" }] },
|
||||
render: (archive, index) => <ToggleSwitch label="Protected" checked={archive.password_enabled} disabled={disabled} onChange={(checked) => patchArchive(index, { password_enabled: checked })} />,
|
||||
value: (archive) => archive.password_enabled ? "protected" : "none"
|
||||
},
|
||||
{
|
||||
id: "password_field", header: "Password field", width: 230, sortable: true, filterable: true,
|
||||
columnType: "from-list", list: { options: [{ value: "", label: "No field" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] },
|
||||
render: (archive, index) => (
|
||||
<select value={archive.password_field} disabled={disabled || !archive.password_enabled || passwordFields.length === 0} onChange={(event) => patchArchive(index, { password_field: event.target.value })}>
|
||||
<option value="">Select password field</option>
|
||||
</button>,
|
||||
|
||||
value: (archive) => archive.name
|
||||
},
|
||||
{
|
||||
id: "standard", header: "i18n:govoplan-campaign.standard.2dfa6607", width: 150, sortable: true, filterable: true,
|
||||
columnType: "from-list", list: { options: [{ value: "standard", label: "i18n:govoplan-campaign.standard.2dfa6607" }, { value: "optional", label: "i18n:govoplan-campaign.optional.0c6c4102" }] },
|
||||
render: (archive, index) => <ToggleSwitch label="i18n:govoplan-campaign.standard.2dfa6607" checked={archive.standard} disabled={disabled} onChange={(checked) => checked && setStandard(index)} />,
|
||||
value: (archive) => archive.standard ? "standard" : "optional"
|
||||
},
|
||||
{
|
||||
id: "password_enabled", header: "i18n:govoplan-campaign.password.8be3c943", width: 165, sortable: true, filterable: true,
|
||||
columnType: "from-list", list: { options: [{ value: "protected", label: "i18n:govoplan-campaign.protected.28531336" }, { value: "none", label: "i18n:govoplan-campaign.no_password.18518368" }] },
|
||||
render: (archive, index) => <ToggleSwitch label="i18n:govoplan-campaign.protected.28531336" checked={archive.password_enabled} disabled={disabled} onChange={(checked) => patchArchive(index, { password_enabled: checked })} />,
|
||||
value: (archive) => archive.password_enabled ? "protected" : "none"
|
||||
},
|
||||
{
|
||||
id: "password_field", header: "i18n:govoplan-campaign.password_field.a1fc8a1c", width: 230, sortable: true, filterable: true,
|
||||
columnType: "from-list", list: { options: [{ value: "", label: "i18n:govoplan-campaign.no_field.1fe00ed4" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] },
|
||||
render: (archive, index) =>
|
||||
<select value={archive.password_field} disabled={disabled || !archive.password_enabled || passwordFields.length === 0} onChange={(event) => patchArchive(index, { password_field: event.target.value })}>
|
||||
<option value="">i18n:govoplan-campaign.select_password_field.4007aeb0</option>
|
||||
{passwordFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
|
||||
</select>
|
||||
),
|
||||
value: (archive) => archive.password_field
|
||||
},
|
||||
{
|
||||
id: "password_scope", header: "Value scope", width: 190, sortable: true, filterable: true,
|
||||
columnType: "from-list", list: { options: [{ value: "local", label: "Recipient / local" }, { value: "global", label: "Campaign / global" }] },
|
||||
render: (archive, index) => (
|
||||
<select value={archive.password_scope} disabled={disabled || !archive.password_enabled} onChange={(event) => patchArchive(index, { password_scope: event.target.value === "global" ? "global" : "local" })}>
|
||||
<option value="local">Recipient / local</option>
|
||||
<option value="global">Campaign / global</option>
|
||||
</select>
|
||||
),
|
||||
value: (archive) => archive.password_scope
|
||||
},
|
||||
{
|
||||
id: "actions", header: "Actions", width: 180, sticky: "end",
|
||||
render: (_archive, index) => <DataGridRowActions
|
||||
disabled={disabled}
|
||||
onAddBelow={() => addArchive(index)}
|
||||
onRemove={() => removeArchive(index)}
|
||||
onMoveUp={index > 0 ? () => moveArchive(index, index - 1) : undefined}
|
||||
onMoveDown={index < archives.length - 1 ? () => moveArchive(index, index + 1) : undefined}
|
||||
addLabel="Add ZIP attachment below"
|
||||
removeLabel="Remove ZIP attachment"
|
||||
moveUpLabel="Move ZIP attachment up"
|
||||
moveDownLabel="Move ZIP attachment down"
|
||||
/>
|
||||
}
|
||||
];
|
||||
</select>,
|
||||
|
||||
value: (archive) => archive.password_field
|
||||
},
|
||||
{
|
||||
id: "password_scope", header: "i18n:govoplan-campaign.value_scope.5e1e16b2", width: 190, sortable: true, filterable: true,
|
||||
columnType: "from-list", list: { options: [{ value: "local", label: "i18n:govoplan-campaign.recipient_local.418a289f" }, { value: "global", label: "i18n:govoplan-campaign.campaign_global.ba944948" }] },
|
||||
render: (archive, index) =>
|
||||
<select value={archive.password_scope} disabled={disabled || !archive.password_enabled} onChange={(event) => patchArchive(index, { password_scope: event.target.value === "global" ? "global" : "local" })}>
|
||||
<option value="local">i18n:govoplan-campaign.recipient_local.418a289f</option>
|
||||
<option value="global">i18n:govoplan-campaign.campaign_global.ba944948</option>
|
||||
</select>,
|
||||
|
||||
value: (archive) => archive.password_scope
|
||||
},
|
||||
{
|
||||
id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 180, sticky: "end",
|
||||
render: (_archive, index) => <DataGridRowActions
|
||||
disabled={disabled}
|
||||
onAddBelow={() => addArchive(index)}
|
||||
onRemove={() => removeArchive(index)}
|
||||
onMoveUp={index > 0 ? () => moveArchive(index, index - 1) : undefined}
|
||||
onMoveDown={index < archives.length - 1 ? () => moveArchive(index, index + 1) : undefined}
|
||||
addLabel="i18n:govoplan-campaign.add_zip_attachment_below.776dabc1"
|
||||
removeLabel="i18n:govoplan-campaign.remove_zip_attachment.d2bdf662"
|
||||
moveUpLabel="i18n:govoplan-campaign.move_zip_attachment_up.63446ac0"
|
||||
moveDownLabel="i18n:govoplan-campaign.move_zip_attachment_down.4ead4805" />
|
||||
|
||||
}];
|
||||
|
||||
}
|
||||
|
||||
type ZipFilenameNamespace = "local" | "global";
|
||||
@@ -511,19 +512,19 @@ function buildZipFilenameFieldOptions(draft: Record<string, unknown>): ZipFilena
|
||||
const filenameFields = [...fieldDefinitions.values()].filter((field) => field.type !== "password");
|
||||
const labels = new Map(filenameFields.map((field) => [field.name, field.label || field.name]));
|
||||
const localNames = uniqueStrings(["id", "name", "email", ...recipientAddressTemplateFieldOptions().map((field) => field.name), ...filenameFields.map((field) => field.name)]);
|
||||
const globalNames = uniqueStrings([...filenameFields.map((field) => field.name), ...Object.keys(asRecord(draft.global_values))])
|
||||
.filter((name) => fieldDefinitions.get(name)?.type !== "password");
|
||||
const globalNames = uniqueStrings([...filenameFields.map((field) => field.name), ...Object.keys(asRecord(draft.global_values))]).
|
||||
filter((name) => fieldDefinitions.get(name)?.type !== "password");
|
||||
return [
|
||||
...localNames.map((name) => ({ namespace: "local" as const, name, label: labels.get(name) || humanizeFieldName(name) })),
|
||||
...globalNames.map((name) => ({ namespace: "global" as const, name, label: labels.get(name) || humanizeFieldName(name) }))
|
||||
];
|
||||
...localNames.map((name) => ({ namespace: "local" as const, name, label: labels.get(name) || humanizeFieldName(name) })),
|
||||
...globalNames.map((name) => ({ namespace: "global" as const, name, label: labels.get(name) || humanizeFieldName(name) }))];
|
||||
|
||||
}
|
||||
|
||||
function describeZipArchiveNameValueProblem(archives: AttachmentZipArchive[], index: number, value: string): string {
|
||||
const normalized = normalizeZipArchiveName(value);
|
||||
if (!normalized) return "Archive filename must not be empty.";
|
||||
if (!normalized) return "i18n:govoplan-campaign.archive_filename_must_not_be_empty.4234b817";
|
||||
const duplicate = archives.some((archive, currentIndex) => currentIndex !== index && normalizeZipArchiveName(archive.name) === normalized);
|
||||
return duplicate ? "Another ZIP attachment already uses this effective filename." : "";
|
||||
return duplicate ? "i18n:govoplan-campaign.another_zip_attachment_already_uses_this_effecti.d05eb098" : "";
|
||||
}
|
||||
|
||||
function validateZipArchiveNames(archives: AttachmentZipArchive[]): ZipArchiveNameValidation {
|
||||
@@ -545,14 +546,14 @@ function validateZipArchiveNames(archives: AttachmentZipArchive[]): ZipArchiveNa
|
||||
}
|
||||
if (invalidIndexes.size === 0) return EMPTY_ZIP_ARCHIVE_NAME_VALIDATION;
|
||||
const hasEmpty = archives.some((archive, index) => invalidIndexes.has(index) && !archive.name.trim());
|
||||
const duplicateNames = [...byName.entries()]
|
||||
.filter(([, indexes]) => indexes.length > 1)
|
||||
.map(([, indexes]) => archives[indexes[0]]?.name.trim())
|
||||
.filter(Boolean);
|
||||
const duplicateNames = [...byName.entries()].
|
||||
filter(([, indexes]) => indexes.length > 1).
|
||||
map(([, indexes]) => archives[indexes[0]]?.name.trim()).
|
||||
filter(Boolean);
|
||||
const parts: string[] = [];
|
||||
if (hasEmpty) parts.push("Archive filenames must not be empty");
|
||||
if (hasEmpty) parts.push("i18n:govoplan-campaign.archive_filenames_must_not_be_empty.42e91427");
|
||||
if (duplicateNames.length > 0) parts.push(`Archive filenames must be unique: ${duplicateNames.join(", ")}`);
|
||||
return { invalidIndexes, message: `${parts.join(". ")}. Saving is disabled until this is corrected.` };
|
||||
return { invalidIndexes, message: i18nMessage("i18n:govoplan-campaign.value_saving_is_disabled_until_this_is_corrected.cd0a2ca0", { value0: parts.join(". ") }) };
|
||||
}
|
||||
|
||||
function normalizeZipArchiveName(value: string): string {
|
||||
@@ -580,70 +581,70 @@ type AttachmentSourceColumnContext = {
|
||||
|
||||
function attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
|
||||
return [
|
||||
{ id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
|
||||
{
|
||||
id: "path",
|
||||
header: "Path",
|
||||
width: "minmax(260px, 1fr)",
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
render: (basePath, index) => (
|
||||
<div className="field-with-action split-field-action">
|
||||
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="i18n:govoplan-campaign.campaign_files.96e7004b" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
|
||||
{
|
||||
id: "path",
|
||||
header: "i18n:govoplan-campaign.path.519e3913",
|
||||
width: "minmax(260px, 1fr)",
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
render: (basePath, index) =>
|
||||
<div className="field-with-action split-field-action">
|
||||
<input
|
||||
className="chooser-display-input"
|
||||
value={managedFilesAvailable ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path}
|
||||
disabled={locked}
|
||||
readOnly={managedFilesAvailable}
|
||||
tabIndex={managedFilesAvailable ? -1 : undefined}
|
||||
placeholder="attachments"
|
||||
onChange={(event) => {
|
||||
if (!managedFilesAvailable) patchBasePath(index, { path: event.target.value, source: "" });
|
||||
}}
|
||||
onClick={() => managedFilesAvailable && !locked && setPathChooser({ index })}
|
||||
onKeyDown={(event) => {
|
||||
if (managedFilesAvailable && !locked && (event.key === "Enter" || event.key === " ")) {
|
||||
event.preventDefault();
|
||||
setPathChooser({ index });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{managedFilesAvailable && <Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>}
|
||||
</div>
|
||||
),
|
||||
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)
|
||||
},
|
||||
{ id: "individual", header: "Individual attachments", width: 260, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "individual", label: "Individual" }, { value: "global only", label: "Global only" }] }, render: (basePath, index) => <ToggleSwitch label="Individual" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => setIndividualEligibility(index, checked)} />, value: (basePath) => basePath.allow_individual ? "individual" : "global only" },
|
||||
{ id: "unsent_warning", header: "Unsent warning", width: 200, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "warn", label: "Warn" }, { value: "off", label: "Off" }] }, render: (basePath, index) => <ToggleSwitch label="Unsent" checked={Boolean(basePath.unsent_warning)} disabled={locked} onChange={(checked) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" },
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 180,
|
||||
sticky: "end",
|
||||
render: (_basePath, index) => (
|
||||
<DataGridRowActions
|
||||
disabled={locked}
|
||||
removeDisabled={basePaths.length <= 1}
|
||||
onAddBelow={() => addBasePath(index)}
|
||||
onRemove={() => removeBasePath(index)}
|
||||
onMoveUp={index > 0 ? () => moveBasePath(index, index - 1) : undefined}
|
||||
onMoveDown={index < basePaths.length - 1 ? () => moveBasePath(index, index + 1) : undefined}
|
||||
addLabel="Add attachment source below"
|
||||
removeLabel="Remove attachment source"
|
||||
moveUpLabel="Move attachment source up"
|
||||
moveDownLabel="Move attachment source down"
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
className="chooser-display-input"
|
||||
value={managedFilesAvailable ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path}
|
||||
disabled={locked}
|
||||
readOnly={managedFilesAvailable}
|
||||
tabIndex={managedFilesAvailable ? -1 : undefined}
|
||||
placeholder="i18n:govoplan-campaign.attachments_placeholder"
|
||||
onChange={(event) => {
|
||||
if (!managedFilesAvailable) patchBasePath(index, { path: event.target.value, source: "" });
|
||||
}}
|
||||
onClick={() => managedFilesAvailable && !locked && setPathChooser({ index })}
|
||||
onKeyDown={(event) => {
|
||||
if (managedFilesAvailable && !locked && (event.key === "Enter" || event.key === " ")) {
|
||||
event.preventDefault();
|
||||
setPathChooser({ index });
|
||||
}
|
||||
}} />
|
||||
|
||||
{managedFilesAvailable && <Button onClick={() => setPathChooser({ index })} disabled={locked}>i18n:govoplan-campaign.choose_folder.1838a415</Button>}
|
||||
</div>,
|
||||
|
||||
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)
|
||||
},
|
||||
{ id: "individual", header: "i18n:govoplan-campaign.individual_attachments.8164fc7a", width: 260, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "individual", label: "i18n:govoplan-campaign.individual.a7abed83" }, { value: "global_only", label: "i18n:govoplan-campaign.global_only.4641751c" }] }, render: (basePath, index) => <ToggleSwitch label="i18n:govoplan-campaign.individual.a7abed83" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => setIndividualEligibility(index, checked)} />, value: (basePath) => basePath.allow_individual ? "individual" : "global_only" },
|
||||
{ id: "unsent_warning", header: "i18n:govoplan-campaign.unsent_warning.11bdfbf7", width: 200, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "warn", label: "i18n:govoplan-campaign.warn.3009d557" }, { value: "off", label: "i18n:govoplan-campaign.off.e3de5ab0" }] }, render: (basePath, index) => <ToggleSwitch label="i18n:govoplan-campaign.unsent.0d09fa0d" checked={Boolean(basePath.unsent_warning)} disabled={locked} onChange={(checked) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" },
|
||||
{
|
||||
id: "actions",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 180,
|
||||
sticky: "end",
|
||||
render: (_basePath, index) =>
|
||||
<DataGridRowActions
|
||||
disabled={locked}
|
||||
removeDisabled={basePaths.length <= 1}
|
||||
onAddBelow={() => addBasePath(index)}
|
||||
onRemove={() => removeBasePath(index)}
|
||||
onMoveUp={index > 0 ? () => moveBasePath(index, index - 1) : undefined}
|
||||
onMoveDown={index < basePaths.length - 1 ? () => moveBasePath(index, index + 1) : undefined}
|
||||
addLabel="i18n:govoplan-campaign.add_attachment_source_below.635c6a61"
|
||||
removeLabel="i18n:govoplan-campaign.remove_attachment_source.95855c67"
|
||||
moveUpLabel="i18n:govoplan-campaign.move_attachment_source_up.956ec756"
|
||||
moveDownLabel="i18n:govoplan-campaign.move_attachment_source_down.cbf9ebd8" />
|
||||
|
||||
|
||||
}];
|
||||
|
||||
}
|
||||
function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FilesFileSpace[]): string {
|
||||
const parsedSource = parseManagedAttachmentSource(basePath.source);
|
||||
const matchingSpace = parsedSource
|
||||
? spaces.find((space) => space.owner_type === parsedSource.ownerType && space.owner_id === parsedSource.ownerId)
|
||||
: undefined;
|
||||
const rootLabel = matchingSpace?.label
|
||||
|| (parsedSource?.ownerType === "user" ? "My files" : parsedSource?.ownerType === "group" ? "Group files" : "");
|
||||
const matchingSpace = parsedSource ?
|
||||
spaces.find((space) => space.owner_type === parsedSource.ownerType && space.owner_id === parsedSource.ownerId) :
|
||||
undefined;
|
||||
const rootLabel = matchingSpace?.label || (
|
||||
parsedSource?.ownerType === "user" ? "i18n:govoplan-campaign.my_files.71d01a41" : parsedSource?.ownerType === "group" ? "i18n:govoplan-campaign.group_files.1aacad0b" : "");
|
||||
const relativePath = basePath.path.trim().replace(/^\.\/?$/, "").replace(/^\/+|\/+$/g, "");
|
||||
if (rootLabel) return `${rootLabel}/${relativePath ? `${relativePath}/` : ""}`;
|
||||
return `${relativePath || "."}/`;
|
||||
|
||||
@@ -7,7 +7,7 @@ import VersionLine from "./components/VersionLine";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
|
||||
export default function CampaignAuditPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
export default function CampaignAuditPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const version = data.currentVersion;
|
||||
|
||||
@@ -15,21 +15,21 @@ export default function CampaignAuditPage({ settings, campaignId }: { settings:
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Audit log</PageTitle>
|
||||
<PageTitle loading={loading}>i18n:govoplan-campaign.audit_log.3cfc5f1c</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
|
||||
<LoadingFrame loading={loading} label="Loading audit data…">
|
||||
<Card title="Recent audit events">
|
||||
<p className="muted">Campaign-specific audit API integration will be added in the audit section pass.</p>
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_audit_data.af52b968">
|
||||
<Card title="i18n:govoplan-campaign.recent_audit_events.7ec32b1d">
|
||||
<p className="muted">i18n:govoplan-campaign.campaign_specific_audit_api_integration_will_be_.e53c8280</p>
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -15,8 +15,8 @@ import FieldValueInput from "./components/FieldValueInput";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
|
||||
import { insertAfter, moveArrayItem } from "@govoplan/core-webui";
|
||||
export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
||||
export default function CampaignFieldsPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const fieldValueKeys = useRef<string[]>([]);
|
||||
|
||||
@@ -30,8 +30,8 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
||||
reload,
|
||||
setError,
|
||||
currentStep: "campaign-fields",
|
||||
unsavedTitle: "Unsaved fields",
|
||||
unsavedMessage: "Campaign fields have unsaved changes. Save them before leaving, or discard them and continue.",
|
||||
unsavedTitle: "i18n:govoplan-campaign.unsaved_fields.c0913c13",
|
||||
unsavedMessage: "i18n:govoplan-campaign.campaign_fields_have_unsaved_changes_save_them_b.c7e992f1",
|
||||
transformLoadedDraft: (loadedVersion, loadedDraft) => migrateFieldOverridePolicy(loadedDraft, asRecord(loadedVersion.editor_state)),
|
||||
onLoaded: (_loadedVersion, loadedDraft) => {
|
||||
fieldValueKeys.current = normalizeFields(loadedDraft.fields).map((field) => field.name);
|
||||
@@ -142,7 +142,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
||||
const fieldProblem = describeFieldNameProblem(fields);
|
||||
if (fieldProblem) {
|
||||
setLocalError(fieldProblem);
|
||||
setSaveState("Save blocked");
|
||||
setSaveState("i18n:govoplan-campaign.save_blocked.c09570a4");
|
||||
return false;
|
||||
}
|
||||
return saveDraft("manual");
|
||||
@@ -153,39 +153,39 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Fields</PageTitle>
|
||||
<PageTitle loading={loading}>i18n:govoplan-campaign.fields.e8b68527</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={saveFields} disabled={!canSave}>Save</Button>
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button variant="primary" onClick={saveFields} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||
{fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning} floating>{fieldNameWarning}</DismissibleAlert>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||
<>
|
||||
<Card title="Campaign fields">
|
||||
<Card title="i18n:govoplan-campaign.campaign_fields.969e7d80">
|
||||
<div className="admin-table-surface campaign-fields-table-surface">
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-fields`}
|
||||
rows={fields}
|
||||
columns={fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField })}
|
||||
getRowKey={(_field, index) => `field-row-${index}`}
|
||||
emptyText="No campaign fields configured yet."
|
||||
emptyAction={<DataGridEmptyAction onAdd={() => addField(-1)} disabled={locked} label="Add first field" />}
|
||||
className="field-editor-table-wrap field-editor-table"
|
||||
/>
|
||||
emptyText="i18n:govoplan-campaign.no_campaign_fields_configured_yet.6772f948"
|
||||
emptyAction={<DataGridEmptyAction onAdd={() => addField(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_field.9fef7186" />}
|
||||
className="field-editor-table-wrap field-editor-table" />
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
type FieldColumnContext = {
|
||||
@@ -203,32 +203,32 @@ type FieldColumnContext = {
|
||||
|
||||
function fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField }: FieldColumnContext): DataGridColumn<CampaignFieldDefinition>[] {
|
||||
return [
|
||||
{ id: "name", header: "Field ID", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (field, index) => <input value={field.name} disabled={locked} placeholder="field_name" onChange={(event) => renameField(index, event.target.value)} />, value: (field) => field.name },
|
||||
{ id: "label", header: "Label", width: 210, resizable: true, sortable: true, filterable: true, render: (field, index) => <input value={field.label} disabled={locked} placeholder="Display label" onChange={(event) => setField(index, { label: event.target.value })} />, value: (field) => field.label },
|
||||
{ id: "type", header: "Type", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: fieldTypeOptions.map((option) => ({ value: option, label: option })) }, render: (field, index) => <select value={field.type} disabled={locked} onChange={(event) => setField(index, { type: normalizeFieldType(event.target.value) })}>{fieldTypeOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>, value: (field) => field.type },
|
||||
{ id: "required", header: "Required", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "Required" }, { value: "optional", label: "Optional" }] }, render: (field, index) => <ToggleSwitch label="Required" checked={field.required} disabled={locked} onChange={(checked) => setField(index, { required: checked })} />, value: (field) => field.required ? "required" : "optional" },
|
||||
{ id: "global_value", header: "Global value", width: 220, resizable: true, filterable: true, render: (field) => <FieldValueInput fieldType={field.type} value={globalValues[field.name]} disabled={locked || !field.name} placeholder="Optional default" onChange={(value) => setGlobalValue(field.name, value)} />, value: (field) => String(globalValues[field.name] ?? "") },
|
||||
{ id: "override", header: "Recipient override", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "can override", label: "Can override" }, { value: "locked", label: "Global only" }] }, render: (field, index) => <ToggleSwitch label="Can override" checked={field.can_override} disabled={locked || !field.name} onChange={(checked) => setOverrideAllowed(index, checked)} />, value: (field) => field.can_override ? "can override" : "locked" },
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 180,
|
||||
sticky: "end",
|
||||
render: (_field, index) => (
|
||||
<DataGridRowActions
|
||||
disabled={locked}
|
||||
onAddBelow={() => addField(index)}
|
||||
onRemove={() => deleteField(index)}
|
||||
onMoveUp={index > 0 ? () => moveField(index, index - 1) : undefined}
|
||||
onMoveDown={index < fields.length - 1 ? () => moveField(index, index + 1) : undefined}
|
||||
addLabel="Add field below"
|
||||
removeLabel="Remove field"
|
||||
moveUpLabel="Move field up"
|
||||
moveDownLabel="Move field down"
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
{ id: "name", header: "i18n:govoplan-campaign.field_id.4ee26ffd", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (field, index) => <input value={field.name} disabled={locked} placeholder="field_name" onChange={(event) => renameField(index, event.target.value)} />, value: (field) => field.name },
|
||||
{ id: "label", header: "i18n:govoplan-campaign.label.74341e3c", width: 210, resizable: true, sortable: true, filterable: true, render: (field, index) => <input value={field.label} disabled={locked} placeholder="i18n:govoplan-campaign.display_label.d747868d" onChange={(event) => setField(index, { label: event.target.value })} />, value: (field) => field.label },
|
||||
{ id: "type", header: "i18n:govoplan-campaign.type.3deb7456", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: fieldTypeOptions.map((option) => ({ value: option, label: option })) }, render: (field, index) => <select value={field.type} disabled={locked} onChange={(event) => setField(index, { type: normalizeFieldType(event.target.value) })}>{fieldTypeOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>, value: (field) => field.type },
|
||||
{ id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "i18n:govoplan-campaign.required.eed6bfb4" }, { value: "optional", label: "i18n:govoplan-campaign.optional.0c6c4102" }] }, render: (field, index) => <ToggleSwitch label="i18n:govoplan-campaign.required.eed6bfb4" checked={field.required} disabled={locked} onChange={(checked) => setField(index, { required: checked })} />, value: (field) => field.required ? "required" : "optional" },
|
||||
{ id: "global_value", header: "i18n:govoplan-campaign.global_value.ab230e7f", width: 220, resizable: true, filterable: true, render: (field) => <FieldValueInput fieldType={field.type} value={globalValues[field.name]} disabled={locked || !field.name} placeholder="i18n:govoplan-campaign.optional_default.e04a3bfc" onChange={(value) => setGlobalValue(field.name, value)} />, value: (field) => String(globalValues[field.name] ?? "") },
|
||||
{ id: "override", header: "i18n:govoplan-campaign.recipient_override.4d84be6d", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "can_override", label: "i18n:govoplan-campaign.can_override.c37b61c4" }, { value: "locked", label: "i18n:govoplan-campaign.global_only.4641751c" }] }, render: (field, index) => <ToggleSwitch label="i18n:govoplan-campaign.can_override.c37b61c4" checked={field.can_override} disabled={locked || !field.name} onChange={(checked) => setOverrideAllowed(index, checked)} />, value: (field) => field.can_override ? "can_override" : "locked" },
|
||||
{
|
||||
id: "actions",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 180,
|
||||
sticky: "end",
|
||||
render: (_field, index) =>
|
||||
<DataGridRowActions
|
||||
disabled={locked}
|
||||
onAddBelow={() => addField(index)}
|
||||
onRemove={() => deleteField(index)}
|
||||
onMoveUp={index > 0 ? () => moveField(index, index - 1) : undefined}
|
||||
onMoveDown={index < fields.length - 1 ? () => moveField(index, index + 1) : undefined}
|
||||
addLabel="i18n:govoplan-campaign.add_field_below.c087fdd7"
|
||||
removeLabel="i18n:govoplan-campaign.remove_field.f4e03605"
|
||||
moveUpLabel="i18n:govoplan-campaign.move_field_up.d98ba824"
|
||||
moveDownLabel="i18n:govoplan-campaign.move_field_down.8f27ccb2" />
|
||||
|
||||
|
||||
}];
|
||||
|
||||
}
|
||||
|
||||
function normalizeFields(value: unknown): CampaignFieldDefinition[] {
|
||||
@@ -279,7 +279,7 @@ function migrateFieldOverridePolicy(draft: Record<string, unknown>, editorState:
|
||||
function describeFieldNameProblem(fields: CampaignFieldDefinition[]): string {
|
||||
const names = fields.map((field) => field.name.trim());
|
||||
if (names.some((name) => !name)) {
|
||||
return "Field IDs must not be empty before saving.";
|
||||
return "i18n:govoplan-campaign.field_ids_must_not_be_empty_before_saving.3ac7b4ca";
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
@@ -290,7 +290,7 @@ function describeFieldNameProblem(fields: CampaignFieldDefinition[]): string {
|
||||
}
|
||||
|
||||
if (duplicates.size === 0) return "";
|
||||
return `Duplicate field ID${duplicates.size === 1 ? "" : "s"}: ${[...duplicates].sort().join(", ")}. Field IDs must be unique before saving.`;
|
||||
return i18nMessage("i18n:govoplan-campaign.duplicate_field_id_value_value_field_ids_must_be.9343a5e9", { value0: duplicates.size === 1 ? "" : "s", value1: [...duplicates].sort().join(", ") });
|
||||
}
|
||||
|
||||
function uniqueFieldName(fields: CampaignFieldDefinition[]): string {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asRecord, getCampaignJson } from "./utils/campaignView";
|
||||
import { downloadJson, safeFileStem } from "./utils/draftEditor";
|
||||
|
||||
export default function CampaignJsonView({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
export default function CampaignJsonView({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const version = data.currentVersion;
|
||||
const campaignJson = getCampaignJson(version);
|
||||
@@ -20,20 +20,20 @@ export default function CampaignJsonView({ settings, campaignId }: { settings: A
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>JSON</PageTitle>
|
||||
<PageTitle loading={loading}>i18n:govoplan-campaign.json.031a4e76</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>Download JSON</Button>
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>i18n:govoplan-campaign.download_json.d296a30a</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
<LoadingFrame loading={loading} label="Loading JSON…">
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_json.812c7a50">
|
||||
<Card>
|
||||
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -1,32 +1,41 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate } from "@govoplan/core-webui";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate, mergeDeltaRows } from "@govoplan/core-webui";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import { createNewCampaign, listCampaigns } from "../../api/campaigns";
|
||||
import { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns";
|
||||
import type { CampaignListItem } from "../../types";
|
||||
|
||||
export default function CampaignListPage({ settings }: { settings: ApiSettings }) {
|
||||
const navigate = useNavigate();
|
||||
export default function CampaignListPage({ settings }: {settings: ApiSettings;}) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const [campaigns, setCampaigns] = useState<CampaignListItem[]>([]);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [lastLoadedAt, setLastLoadedAt] = useState<string>("");
|
||||
const [campaignDeltaWatermark, setCampaignDeltaWatermark] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
async function load(forcedSince: string | null | undefined = campaignDeltaWatermark) {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await listCampaigns(settings);
|
||||
setCampaigns(data);
|
||||
let nextWatermark = forcedSince ?? null;
|
||||
let nextCampaigns = campaigns;
|
||||
let response: CampaignDeltaResponse;
|
||||
do {
|
||||
response = await listCampaignsDelta(settings, { since: nextWatermark });
|
||||
nextCampaigns = mergeCampaignDelta(nextCampaigns, response);
|
||||
nextWatermark = response.watermark ?? null;
|
||||
} while (response.has_more);
|
||||
setCampaigns(nextCampaigns);
|
||||
setCampaignDeltaWatermark(nextWatermark);
|
||||
setLastLoadedAt(formatLoadedAt(new Date()));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -49,114 +58,115 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
setCampaignDeltaWatermark(null);
|
||||
load(null);
|
||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
const columns: DataGridColumn<CampaignListItem>[] = [
|
||||
{
|
||||
id: "campaign",
|
||||
header: "Campaign",
|
||||
width: "minmax(260px, 1.8fr)",
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
sticky: "start",
|
||||
render: (campaign) => (
|
||||
<div>
|
||||
{
|
||||
id: "campaign",
|
||||
header: "i18n:govoplan-campaign.campaign.69390e16",
|
||||
width: "minmax(260px, 1.8fr)",
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
sticky: "start",
|
||||
render: (campaign) =>
|
||||
<div>
|
||||
<Link className="table-primary-link" to={`/campaigns/${campaign.id}`}>
|
||||
{campaign.name || campaign.external_id || campaign.id}
|
||||
</Link>
|
||||
<div className="table-subline">{campaign.description || campaign.external_id || campaign.id}</div>
|
||||
</div>
|
||||
),
|
||||
value: (campaign) => `${campaign.name ?? ""} ${campaign.external_id ?? ""} ${campaign.id}`
|
||||
</div>,
|
||||
|
||||
value: (campaign) => `${campaign.name ?? ""} ${campaign.external_id ?? ""} ${campaign.id}`
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "i18n:govoplan-campaign.status.bae7d5be",
|
||||
width: 150,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
columnType: "from-list",
|
||||
list: {
|
||||
options: [
|
||||
{ value: "draft", label: "i18n:govoplan-campaign.draft.23d33e22" },
|
||||
{ value: "active", label: "i18n:govoplan-campaign.active.a733b809" },
|
||||
{ value: "completed", label: "i18n:govoplan-campaign.completed.1798b3ba" },
|
||||
{ value: "archived", label: "i18n:govoplan-campaign.archived.eddc813f" }],
|
||||
|
||||
display: "pill"
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
width: 150,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
columnType: "from-list",
|
||||
list: {
|
||||
options: [
|
||||
{ value: "draft", label: "Draft" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "completed", label: "Completed" },
|
||||
{ value: "archived", label: "Archived" }
|
||||
],
|
||||
display: "pill"
|
||||
},
|
||||
render: (campaign) => <StatusBadge status={campaign.status || "draft"} />,
|
||||
value: (campaign) => campaign.status || "draft"
|
||||
},
|
||||
{
|
||||
id: "current_version",
|
||||
header: "Current version",
|
||||
width: 170,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
className: "version-cell mono-small",
|
||||
render: (campaign) => <span title={campaign.current_version_id || undefined}>{campaign.current_version_id ? shortId(campaign.current_version_id) : "—"}</span>,
|
||||
value: (campaign) => campaign.current_version_id ?? ""
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
header: "Updated",
|
||||
width: 190,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterType: "date",
|
||||
className: "updated-cell",
|
||||
render: (campaign) => formatDateTime(campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at),
|
||||
value: (campaign) => campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at ?? ""
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 70,
|
||||
sticky: "end",
|
||||
align: "right",
|
||||
render: (campaign) => (
|
||||
<Link
|
||||
to={`/campaigns/${campaign.id}`}
|
||||
className="btn btn-primary admin-icon-button"
|
||||
aria-label={`Open ${campaign.name || campaign.external_id || campaign.id}`}
|
||||
title={`Open ${campaign.name || campaign.external_id || campaign.id}`}
|
||||
>
|
||||
render: (campaign) => <StatusBadge status={campaign.status || "draft"} />,
|
||||
value: (campaign) => campaign.status || "draft"
|
||||
},
|
||||
{
|
||||
id: "current_version",
|
||||
header: "i18n:govoplan-campaign.current_version.7be72582",
|
||||
width: 170,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
className: "version-cell mono-small",
|
||||
render: (campaign) => <span title={campaign.current_version_id || undefined}>{campaign.current_version_id ? shortId(campaign.current_version_id) : "—"}</span>,
|
||||
value: (campaign) => campaign.current_version_id ?? ""
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
header: "i18n:govoplan-campaign.updated.f2f8570d",
|
||||
width: 190,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterType: "date",
|
||||
className: "updated-cell",
|
||||
render: (campaign) => formatDateTime(campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at),
|
||||
value: (campaign) => campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at ?? ""
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 70,
|
||||
sticky: "end",
|
||||
align: "right",
|
||||
render: (campaign) =>
|
||||
<Link
|
||||
to={`/campaigns/${campaign.id}`}
|
||||
className="btn btn-primary admin-icon-button"
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })}
|
||||
title={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })}>
|
||||
|
||||
<ExternalLink aria-hidden="true" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
}];
|
||||
|
||||
|
||||
return (
|
||||
<div className="content-pad campaigns-page">
|
||||
<div className="content-pad workspace-data-page campaigns-page">
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>All campaigns</PageTitle>
|
||||
<p className="mono-small">{lastLoadedAt ? `Last loaded: ${lastLoadedAt}` : "Not loaded yet"}</p>
|
||||
<PageTitle loading={loading}>i18n:govoplan-campaign.all_campaigns.2bd1ee3a</PageTitle>
|
||||
<p className="mono-small">{lastLoadedAt ? i18nMessage("i18n:govoplan-campaign.last_loaded_value.35ef046a", { value0: lastLoadedAt }) : "i18n:govoplan-campaign.not_loaded_yet.9968c191"}</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={load} disabled={loading}>Reload</Button>
|
||||
<Button onClick={load} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button variant="primary" onClick={create} disabled={creating}>
|
||||
{creating ? "Creating…" : "New campaign"}
|
||||
{creating ? "i18n:govoplan-campaign.creating.94d7d8ee" : "i18n:govoplan-campaign.new_campaign.aaf9a8a4"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card title="Campaigns">
|
||||
<LoadingFrame loading={loading} label="Loading campaigns…">
|
||||
{campaigns.length === 0 ? (
|
||||
<Card title="i18n:govoplan-campaign.campaigns.01a23a28">
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaigns.90a466bb">
|
||||
{campaigns.length === 0 ?
|
||||
<div className="empty-state">
|
||||
<h2>No campaigns yet</h2>
|
||||
<p>Start with a guided campaign draft. The WebUI will create a portable campaign JSON in the background.</p>
|
||||
<h2>i18n:govoplan-campaign.no_campaigns_yet.926409b3</h2>
|
||||
<p>i18n:govoplan-campaign.start_with_a_guided_campaign_draft_the_webui_wil.c8a675d0</p>
|
||||
<Button variant="primary" onClick={create} disabled={creating}>
|
||||
Create first campaign
|
||||
i18n:govoplan-campaign.create_first_campaign.9be974a4
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
</div> :
|
||||
|
||||
<div className="admin-table-surface campaigns-table-surface">
|
||||
<DataGrid
|
||||
id="campaigns"
|
||||
@@ -165,14 +175,14 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
||||
getRowKey={(campaign) => campaign.id}
|
||||
initialSort={{ columnId: "updated", direction: "desc" }}
|
||||
className="campaign-table-wrap"
|
||||
emptyText="No campaigns found."
|
||||
/>
|
||||
emptyText="i18n:govoplan-campaign.no_campaigns_found.22d297f9" />
|
||||
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
</LoadingFrame>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function shortId(value: string): string {
|
||||
@@ -187,3 +197,17 @@ function formatDateTime(value?: string): string {
|
||||
function formatLoadedAt(value: Date): string {
|
||||
return formatDateTimeFromDate(value, { second: "2-digit" });
|
||||
}
|
||||
|
||||
function mergeCampaignDelta(current: CampaignListItem[], response: CampaignDeltaResponse): CampaignListItem[] {
|
||||
if (response.full) return response.campaigns;
|
||||
return mergeDeltaRows(current, response.campaigns, response.deleted, (campaign) => campaign.id, {
|
||||
deletedResourceType: "campaign",
|
||||
sort: sortCampaignsByUpdatedDesc
|
||||
});
|
||||
}
|
||||
|
||||
function sortCampaignsByUpdatedDesc(left: CampaignListItem, right: CampaignListItem): number {
|
||||
return String(right.updated_at ?? right.updatedAt ?? right.created_at ?? "").localeCompare(
|
||||
String(left.updated_at ?? left.updatedAt ?? left.created_at ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { MetricCard } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import {
|
||||
lockCampaignVersionPermanently,
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
unlockCampaignVersionUserLock,
|
||||
updateCampaignMetadata,
|
||||
type CampaignVersionDetail,
|
||||
type CampaignVersionListItem,
|
||||
} from "../../api/campaigns";
|
||||
type CampaignVersionListItem } from
|
||||
"../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import {
|
||||
asArray,
|
||||
@@ -31,15 +31,15 @@ import {
|
||||
isPermanentUserLockedVersion,
|
||||
isTemporaryUserLockedVersion,
|
||||
isVersionReadyForDelivery,
|
||||
summaryValue,
|
||||
} from "./utils/campaignView";
|
||||
summaryValue } from
|
||||
"./utils/campaignView";
|
||||
import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
||||
|
||||
const campaignModeOptions = ["draft", "test", "send"];
|
||||
type LockAction = "temporary" | "unlock" | "permanent";
|
||||
type PendingLockAction = { version: CampaignVersionListItem; action: LockAction } | null;
|
||||
type PendingLockAction = {version: CampaignVersionListItem;action: LockAction;} | null;
|
||||
|
||||
export default function CampaignOverviewPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
export default function CampaignOverviewPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||
const campaign = data.campaign;
|
||||
const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]);
|
||||
@@ -51,13 +51,28 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
const [message, setMessage] = useState("");
|
||||
const versionMetrics = useMemo(() => campaignVersionMetrics(data.currentVersion), [data.currentVersion]);
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: identityDirty,
|
||||
onSave: saveIdentity,
|
||||
onDiscard: () => {
|
||||
if (!campaign) return;
|
||||
setIdentity({
|
||||
external_id: campaign.external_id ?? "",
|
||||
name: campaign.name ?? "",
|
||||
status: campaign.status ?? "",
|
||||
description: campaign.description ?? ""
|
||||
});
|
||||
setIdentityDirty(false);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!campaign || identityDirty) return;
|
||||
setIdentity({
|
||||
external_id: campaign.external_id ?? "",
|
||||
name: campaign.name ?? "",
|
||||
status: campaign.status ?? "",
|
||||
description: campaign.description ?? "",
|
||||
description: campaign.description ?? ""
|
||||
});
|
||||
}, [campaign, identityDirty]);
|
||||
|
||||
@@ -67,8 +82,8 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
setMessage("");
|
||||
}
|
||||
|
||||
async function saveIdentity() {
|
||||
if (!campaign || savingIdentity || !identityDirty) return;
|
||||
async function saveIdentity(): Promise<boolean> {
|
||||
if (!campaign || savingIdentity || !identityDirty) return false;
|
||||
setSavingIdentity(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
@@ -77,12 +92,14 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
external_id: identity.external_id,
|
||||
name: identity.name,
|
||||
status: identity.status,
|
||||
description: identity.description,
|
||||
description: identity.description
|
||||
});
|
||||
setIdentityDirty(false);
|
||||
await reload();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
} finally {
|
||||
setSavingIdentity(false);
|
||||
}
|
||||
@@ -97,13 +114,13 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
try {
|
||||
if (pending.action === "temporary") {
|
||||
await lockCampaignVersionTemporarily(settings, campaignId, pending.version.id);
|
||||
setMessage(`Version #${pending.version.version_number} temporarily locked.`);
|
||||
setMessage(i18nMessage("i18n:govoplan-campaign.version_value_temporarily_locked.4ed4ffa7", { value0: pending.version.version_number }));
|
||||
} else if (pending.action === "unlock") {
|
||||
await unlockCampaignVersionUserLock(settings, campaignId, pending.version.id);
|
||||
setMessage(`Temporary lock removed from version #${pending.version.version_number}.`);
|
||||
setMessage(i18nMessage("i18n:govoplan-campaign.temporary_lock_removed_from_version_value.9a05bcfb", { value0: pending.version.version_number }));
|
||||
} else {
|
||||
await lockCampaignVersionPermanently(settings, campaignId, pending.version.id);
|
||||
setMessage(`Version #${pending.version.version_number} permanently locked.`);
|
||||
setMessage(i18nMessage("i18n:govoplan-campaign.version_value_permanently_locked.60595f45", { value0: pending.version.version_number }));
|
||||
}
|
||||
setPendingLockAction(null);
|
||||
await reload();
|
||||
@@ -118,70 +135,70 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>{campaign?.name || "Overview"}</PageTitle>
|
||||
<p className="mono-small">Campaign overview · version-independent identity and version history</p>
|
||||
<PageTitle loading={loading}>{campaign?.name || "i18n:govoplan-campaign.overview.0efc2e6b"}</PageTitle>
|
||||
<p className="mono-small">i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading || savingIdentity || lockBusy}>Reload</Button>
|
||||
<Link to="wizard/create"><Button>Edit with wizard</Button></Link>
|
||||
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "Saving…" : "Save"}</Button>
|
||||
<Button onClick={reload} disabled={loading || savingIdentity || lockBusy}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Link to="wizard/create"><Button>i18n:govoplan-campaign.edit_with_wizard.672a7d1a</Button></Link>
|
||||
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "i18n:govoplan-campaign.saving.56a2285c" : "i18n:govoplan-campaign.save.efc007a3"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
||||
|
||||
<LoadingFrame loading={loading} label="Loading campaign overview…">
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaign_overview.ffa1adf0">
|
||||
<div className="metric-grid campaign-overview-metrics current-version-metrics">
|
||||
<MetricCard label="Version" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
|
||||
<MetricCard label="Fields" value={versionMetrics.fieldCount} tone="info" />
|
||||
<MetricCard label="Recipients" value={versionMetrics.recipientCount} tone="neutral" detail="Active inline recipients" />
|
||||
<MetricCard label="Template health" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} />
|
||||
<MetricCard label="i18n:govoplan-campaign.version.2da600bf" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
|
||||
<MetricCard label="i18n:govoplan-campaign.fields.e8b68527" value={versionMetrics.fieldCount} tone="info" />
|
||||
<MetricCard label="i18n:govoplan-campaign.recipients.78cbf8eb" value={versionMetrics.recipientCount} tone="neutral" detail="i18n:govoplan-campaign.active_inline_recipients.8ba58f6e" />
|
||||
<MetricCard label="i18n:govoplan-campaign.template_health.22e14b59" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} />
|
||||
</div>
|
||||
|
||||
<div className="metric-grid campaign-overview-metrics">
|
||||
<MetricCard label="Queueable" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="Ready or warning" />
|
||||
<MetricCard label="Needs attention" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="Review first" />
|
||||
<MetricCard label="Sent" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="SMTP success" />
|
||||
<MetricCard label="Failed" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
|
||||
<MetricCard label="i18n:govoplan-campaign.queueable.ea776f8d" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="i18n:govoplan-campaign.ready_or_warning.4dcce676" />
|
||||
<MetricCard label="i18n:govoplan-campaign.needs_attention.a126722e" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="i18n:govoplan-campaign.review_first.741ac781" />
|
||||
<MetricCard label="i18n:govoplan-campaign.sent.35f49dcf" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="i18n:govoplan-campaign.smtp_success.3591a856" />
|
||||
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="i18n:govoplan-campaign.smtp_failures.00b33b85" />
|
||||
</div>
|
||||
|
||||
<Card title="Current version state" actions={<Link
|
||||
to={`send?version=${campaign?.current_version_id}`}
|
||||
className={`btn btn-primary`}
|
||||
aria-label={`Open curent version`}
|
||||
title={`Open curent version`}
|
||||
>
|
||||
Open
|
||||
<Card title="i18n:govoplan-campaign.current_version_state.f581778f" actions={<Link
|
||||
to={`send?version=${campaign?.current_version_id}`}
|
||||
className={`btn btn-primary`}
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}
|
||||
title={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}>
|
||||
|
||||
i18n:govoplan-campaign.open.cf9b7706
|
||||
</Link>}>
|
||||
<div className="summary-grid overview-summary-grid">
|
||||
<SummaryTile label="Validation errors" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
|
||||
<SummaryTile label="Warnings" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
|
||||
<SummaryTile label="Built messages" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
|
||||
<SummaryTile label="Jobs total" value={data.summary?.cards?.jobs_total ?? "—"} />
|
||||
<SummaryTile label="i18n:govoplan-campaign.validation_errors.e54ca4fe" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
|
||||
<SummaryTile label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
|
||||
<SummaryTile label="i18n:govoplan-campaign.built_messages.1fb804f2" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
|
||||
<SummaryTile label="i18n:govoplan-campaign.jobs_total.98da65bc" value={data.summary?.cards?.jobs_total ?? "—"} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Campaign identity" collapsible>
|
||||
<Card title="i18n:govoplan-campaign.campaign_identity.a00ca574" collapsible>
|
||||
<div className="form-grid campaign-identity-grid">
|
||||
<FormField label="Campaign ID">
|
||||
<FormField label="i18n:govoplan-campaign.campaign_id.4c4ed79e">
|
||||
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Mode">
|
||||
<FormField label="i18n:govoplan-campaign.mode.a7b93d21">
|
||||
<select value={identity.status} onChange={(event) => patchIdentity("status", event.target.value)}>
|
||||
{campaignModeOptions.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Name">
|
||||
<FormField label="i18n:govoplan-campaign.name.709a2322">
|
||||
<input value={identity.name} onChange={(event) => patchIdentity("name", event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Description">
|
||||
<FormField label="i18n:govoplan-campaign.description.55f8ebc8">
|
||||
<textarea rows={4} value={identity.description} onChange={(event) => patchIdentity("description", event.target.value)} />
|
||||
</FormField>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Version history" collapsible>
|
||||
<Card title="i18n:govoplan-campaign.version_history.91f86581" collapsible>
|
||||
<div className="admin-table-surface">
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-versions`}
|
||||
@@ -189,10 +206,10 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
columns={versionColumns(setPendingLockAction, campaign?.current_version_id)}
|
||||
getRowKey={(version) => version.id}
|
||||
initialSort={{ columnId: "version", direction: "desc" }}
|
||||
emptyText="No versions found."
|
||||
emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e"
|
||||
className="version-history-table"
|
||||
rowClassName={(version) => version.id === data.currentVersion?.id ? "current-version-row" : undefined}
|
||||
/>
|
||||
rowClassName={(version) => version.id === data.currentVersion?.id ? "current-version-row" : undefined} />
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
@@ -205,10 +222,10 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
tone={pendingLockAction?.action === "unlock" ? "default" : "danger"}
|
||||
busy={lockBusy}
|
||||
onCancel={() => setPendingLockAction(null)}
|
||||
onConfirm={() => void applyLockAction()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
onConfirm={() => void applyLockAction()} />
|
||||
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
type TemplateHealthTone = "neutral" | "good" | "warning" | "danger" | "info";
|
||||
@@ -253,10 +270,10 @@ function campaignVersionMetrics(version: CampaignVersionDetail | null): Campaign
|
||||
templateHealthValue: `${score}/3`,
|
||||
templateHealthTone: score === 3 ? "good" : score === 2 ? "warning" : "danger",
|
||||
templateHealthDetail: [
|
||||
subjectOk ? "Subject OK" : "Subject missing",
|
||||
bodyOk ? "Body OK" : "Body missing",
|
||||
placeholdersOk ? "Placeholders OK" : `${undefinedPlaceholders.length} undefined`
|
||||
].join(" · ")
|
||||
subjectOk ? "i18n:govoplan-campaign.subject_ok.3dbdc3ed" : "i18n:govoplan-campaign.subject_missing.f545bf65",
|
||||
bodyOk ? "i18n:govoplan-campaign.body_ok.cdb33005" : "i18n:govoplan-campaign.body_missing.bd58622f",
|
||||
placeholdersOk ? "i18n:govoplan-campaign.placeholders_ok.3a07f3bc" : `${undefinedPlaceholders.length} undefined`].
|
||||
join(" · ")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -270,106 +287,106 @@ function textValue(value: unknown, fallback = ""): string {
|
||||
|
||||
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
|
||||
return [
|
||||
{ id: "version", header: "Version", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
|
||||
{ id: "state", header: "State", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
|
||||
{ id: "lock", header: "Lock", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Current working version", label: "Current working version" }, { value: "Historical version", label: "Historical version" }, { value: "Temporarily locked", label: "Temporarily locked" }, { value: "Permanently locked", label: "Permanently locked" }, { value: "Delivery locked", label: "Delivery locked" }] }, render: (version) => versionLockLabel(version, currentVersionId), value: (version) => versionLockLabel(version, currentVersionId) },
|
||||
{ id: "validation", header: "Validation", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Not validated", label: "Not validated" }, { value: "Valid", label: "Valid" }, { value: "Validation issues", label: "Validation issues" }] }, render: validationLabel, value: validationLabel },
|
||||
{ id: "build", header: "Build", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Not built", label: "Not built" }, { value: "Built", label: "Built" }, { value: "Build issues", label: "Build issues" }] }, render: buildLabel, value: buildLabel },
|
||||
{ id: "updated", header: "Updated", width: 190, sortable: true, filterable: true, filterType: "date", render: (version) => formatDateTime(version.updated_at), value: (version) => version.updated_at ?? "" },
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 260,
|
||||
sticky: "end",
|
||||
render: (version) => {
|
||||
const isCurrent = version.id === currentVersionId;
|
||||
return (
|
||||
<div className="button-row compact-actions">
|
||||
{ id: "version", header: "i18n:govoplan-campaign.version.2da600bf", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
|
||||
{ id: "state", header: "i18n:govoplan-campaign.state.a7250206", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
|
||||
{ id: "lock", header: "i18n:govoplan-campaign.lock.891ebccd", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Current working version", label: "i18n:govoplan-campaign.current_working_version.eda99d70" }, { value: "Historical version", label: "i18n:govoplan-campaign.historical_version.8880931f" }, { value: "Temporarily locked", label: "i18n:govoplan-campaign.temporarily_locked.1716dc95" }, { value: "Permanently locked", label: "i18n:govoplan-campaign.permanently_locked.327d59fd" }, { value: "Delivery locked", label: "i18n:govoplan-campaign.delivery_locked.2b664305" }] }, render: (version) => versionLockLabel(version, currentVersionId), value: (version) => versionLockLabel(version, currentVersionId) },
|
||||
{ id: "validation", header: "i18n:govoplan-campaign.validation.dd74d182", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Not validated", label: "i18n:govoplan-campaign.not_validated.11bb4178" }, { value: "Valid", label: "i18n:govoplan-campaign.valid.a4aefa35" }, { value: "Validation issues", label: "i18n:govoplan-campaign.validation_issues.528305b1" }] }, render: validationLabel, value: validationLabel },
|
||||
{ id: "build", header: "i18n:govoplan-campaign.build.bbd80cf7", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Not built", label: "i18n:govoplan-campaign.not_built.88bbe87a" }, { value: "Built", label: "i18n:govoplan-campaign.built.a6ad3f82" }, { value: "Build issues", label: "i18n:govoplan-campaign.build_issues.3e03bf8e" }] }, render: buildLabel, value: buildLabel },
|
||||
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 190, sortable: true, filterable: true, filterType: "date", render: (version) => formatDateTime(version.updated_at), value: (version) => version.updated_at ?? "" },
|
||||
{
|
||||
id: "actions",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 260,
|
||||
sticky: "end",
|
||||
render: (version) => {
|
||||
const isCurrent = version.id === currentVersionId;
|
||||
return (
|
||||
<div className="button-row compact-actions">
|
||||
<Link
|
||||
to={`send?version=${version.id}`}
|
||||
className={`btn ${isCurrent ? "btn-primary" : "btn-secondary"} admin-icon-button`}
|
||||
aria-label={`Open version ${version.version_number}`}
|
||||
title={`Open version ${version.version_number}`}
|
||||
>
|
||||
to={`send?version=${version.id}`}
|
||||
className={`btn ${isCurrent ? "btn-primary" : "btn-secondary"} admin-icon-button`}
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })}
|
||||
title={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })}>
|
||||
|
||||
<ExternalLink aria-hidden="true" />
|
||||
</Link>
|
||||
{isCurrent && (isTemporaryUserLockedVersion(version) ? (
|
||||
<>
|
||||
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>Unlock</Button>
|
||||
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>Lock permanently</Button>
|
||||
</>
|
||||
) : !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ? (
|
||||
<Button
|
||||
className="admin-icon-button"
|
||||
onClick={() => setPendingLockAction({ version, action: "temporary" })}
|
||||
aria-label={`Temporarily lock version ${version.version_number}`}
|
||||
title="Temporarily lock version"
|
||||
>
|
||||
{isCurrent && (isTemporaryUserLockedVersion(version) ?
|
||||
<>
|
||||
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>i18n:govoplan-campaign.unlock.1526a17e</Button>
|
||||
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>i18n:govoplan-campaign.lock_permanently.cc0ce9e7</Button>
|
||||
</> :
|
||||
!isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ?
|
||||
<Button
|
||||
className="admin-icon-button"
|
||||
onClick={() => setPendingLockAction({ version, action: "temporary" })}
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.temporarily_lock_version_value.8019e581", { value0: version.version_number })}
|
||||
title="i18n:govoplan-campaign.temporarily_lock_version.82b31149">
|
||||
|
||||
<LockKeyhole aria-hidden="true" />
|
||||
</Button>
|
||||
) : null)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
</Button> :
|
||||
null)}
|
||||
</div>);
|
||||
|
||||
}
|
||||
}];
|
||||
|
||||
}
|
||||
|
||||
function versionLockLabel(version: CampaignVersionListItem, currentVersionId?: string | null): string {
|
||||
if (currentVersionId && version.id !== currentVersionId) return "Historical · review-only";
|
||||
if (isTemporaryUserLockedVersion(version)) return "Temporary user lock";
|
||||
if (isPermanentUserLockedVersion(version)) return "Permanent user lock";
|
||||
if (isFinalLockedVersion(version)) return "Permanent delivery lock";
|
||||
if (canUnlockValidationVersion(version)) return "Temporary validation lock";
|
||||
if (version.locked_at) return "Temporarily locked";
|
||||
return "Editable";
|
||||
if (currentVersionId && version.id !== currentVersionId) return "i18n:govoplan-campaign.historical_review_only.5afffe82";
|
||||
if (isTemporaryUserLockedVersion(version)) return "i18n:govoplan-campaign.temporary_user_lock.c2bda6a9";
|
||||
if (isPermanentUserLockedVersion(version)) return "i18n:govoplan-campaign.permanent_user_lock.9d5d8959";
|
||||
if (isFinalLockedVersion(version)) return "i18n:govoplan-campaign.permanent_delivery_lock.07932206";
|
||||
if (canUnlockValidationVersion(version)) return "i18n:govoplan-campaign.temporary_validation_lock.fdc5545d";
|
||||
if (version.locked_at) return "i18n:govoplan-campaign.temporarily_locked.1716dc95";
|
||||
return "i18n:govoplan-campaign.editable.b91faec0";
|
||||
}
|
||||
|
||||
function validationLabel(version: CampaignVersionListItem): string {
|
||||
const validation = version.validation_summary ?? {};
|
||||
if (validation.ok === true && isVersionReadyForDelivery(version)) return "Passed";
|
||||
if (validation.ok === false) return "Needs attention";
|
||||
if (validation.ok === true) return "Passed, unavailable while user-locked";
|
||||
return "Not validated";
|
||||
if (validation.ok === true && isVersionReadyForDelivery(version)) return "i18n:govoplan-campaign.passed.271d60f4";
|
||||
if (validation.ok === false) return "i18n:govoplan-campaign.needs_attention.a126722e";
|
||||
if (validation.ok === true) return "i18n:govoplan-campaign.passed_unavailable_while_user_locked.d6771ff4";
|
||||
return "i18n:govoplan-campaign.not_validated.11bb4178";
|
||||
}
|
||||
|
||||
function buildLabel(version: CampaignVersionListItem): string {
|
||||
const build = version.build_summary ?? {};
|
||||
return String(build.built_count ?? build.ready_count ?? "Not built");
|
||||
return String(build.built_count ?? build.ready_count ?? "i18n:govoplan-campaign.not_built.88bbe87a");
|
||||
}
|
||||
|
||||
function lockDialogTitle(pending: PendingLockAction): string {
|
||||
if (pending?.action === "temporary") return "Temporarily lock version?";
|
||||
if (pending?.action === "unlock") return "Unlock version?";
|
||||
if (pending?.action === "permanent") return "Lock version permanently?";
|
||||
return "Confirm lock action";
|
||||
if (pending?.action === "temporary") return "i18n:govoplan-campaign.temporarily_lock_version.8ccc5708";
|
||||
if (pending?.action === "unlock") return "i18n:govoplan-campaign.unlock_version.ebd2fd9a";
|
||||
if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_version_permanently.a8625753";
|
||||
return "i18n:govoplan-campaign.confirm_lock_action.617986ee";
|
||||
}
|
||||
|
||||
function lockDialogMessage(pending: PendingLockAction): string {
|
||||
if (pending?.action === "temporary") {
|
||||
return "This makes the version read-only without making it final. An authorized user may unlock it later or make the lock permanent.";
|
||||
return "i18n:govoplan-campaign.this_makes_the_version_read_only_without_making_.9d86667b";
|
||||
}
|
||||
if (pending?.action === "unlock") {
|
||||
return "This removes the temporary user lock and makes the version editable again. Existing validation/build state is otherwise retained.";
|
||||
return "i18n:govoplan-campaign.this_removes_the_temporary_user_lock_and_makes_t.fdabd47a";
|
||||
}
|
||||
if (pending?.action === "permanent") {
|
||||
return "This lock cannot be removed by any role. The version remains reviewable for audit purposes; future changes require an editable copy.";
|
||||
return "i18n:govoplan-campaign.this_lock_cannot_be_removed_by_any_role_the_vers.14ae9b80";
|
||||
}
|
||||
return "Continue?";
|
||||
return "i18n:govoplan-campaign.continue.4e6302ed";
|
||||
}
|
||||
|
||||
function lockDialogLabel(pending: PendingLockAction): string {
|
||||
if (pending?.action === "temporary") return "Lock temporarily";
|
||||
if (pending?.action === "unlock") return "Unlock";
|
||||
if (pending?.action === "permanent") return "Lock permanently";
|
||||
return "Confirm";
|
||||
if (pending?.action === "temporary") return "i18n:govoplan-campaign.lock_temporarily.3f91d346";
|
||||
if (pending?.action === "unlock") return "i18n:govoplan-campaign.unlock.1526a17e";
|
||||
if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
|
||||
return "i18n:govoplan-campaign.confirm.04a21221";
|
||||
}
|
||||
|
||||
function SummaryTile({ label, value }: { label: string; value: string | number }) {
|
||||
function SummaryTile({ label, value }: {label: string;value: string | number;}) {
|
||||
return (
|
||||
<div className="summary-tile">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
downloadCampaignJobsCsv,
|
||||
emailCampaignReport,
|
||||
getCampaignJobDetail,
|
||||
getCampaignJobs,
|
||||
getCampaignJobsDelta,
|
||||
resolveCampaignJobOutcome,
|
||||
retryCampaignJobs,
|
||||
sendUnattemptedCampaignJobs,
|
||||
type CampaignJobDetailResponse,
|
||||
type CampaignJobsResponse,
|
||||
} from "../../api/campaigns";
|
||||
type CampaignJobsResponse } from
|
||||
"../../api/campaigns";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
@@ -20,47 +20,50 @@ import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { LoadingFrame, i18nMessage, useDeltaWatermarks } from "@govoplan/core-webui";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asRecord, formatDateTime, humanize, stringifyPreview } from "./utils/campaignView";
|
||||
import { asRecord, formatDateTime, humanize } from "./utils/campaignView";
|
||||
import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas";
|
||||
|
||||
const SEND_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"not_queued",
|
||||
"queued",
|
||||
"claimed",
|
||||
"sending",
|
||||
"smtp_accepted",
|
||||
"sent",
|
||||
"outcome_unknown",
|
||||
"failed_temporary",
|
||||
"failed_permanent",
|
||||
"cancelled",
|
||||
].map((value) => ({ value, label: humanize(value) }));
|
||||
"not_queued",
|
||||
"queued",
|
||||
"claimed",
|
||||
"sending",
|
||||
"smtp_accepted",
|
||||
"sent",
|
||||
"outcome_unknown",
|
||||
"failed_temporary",
|
||||
"failed_permanent",
|
||||
"cancelled"].
|
||||
map((value) => ({ value, label: humanize(value) }));
|
||||
|
||||
type ReconcileRequest = { jobId: string; decision: "smtp_accepted" | "not_sent" } | null;
|
||||
const IMAP_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"not_requested",
|
||||
"pending",
|
||||
"appended",
|
||||
"failed",
|
||||
"skipped"].
|
||||
map((value) => ({ value, label: humanize(value) }));
|
||||
|
||||
export default function CampaignReportPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
type ReconcileRequest = {jobId: string;decision: "smtp_accepted" | "not_sent";} | null;
|
||||
|
||||
export default function CampaignReportPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const version = data.currentVersion;
|
||||
const cards = data.summary?.cards;
|
||||
const delivery = asRecord(data.summary?.delivery);
|
||||
const rateLimit = asRecord(delivery.rate_limit);
|
||||
const imapPolicy = asRecord(delivery.imap_append_sent);
|
||||
|
||||
const [jobs, setJobs] = useState<CampaignJobsResponse>({
|
||||
jobs: [],
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
total: 0,
|
||||
total_unfiltered: 0,
|
||||
pages: 0,
|
||||
counts: {},
|
||||
filtered_counts: {},
|
||||
review: {},
|
||||
});
|
||||
const [jobs, setJobs] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
|
||||
const jobsRef = useRef<CampaignJobsResponse>(emptyCampaignJobsResponse());
|
||||
const jobPageCursorsRef = useRef<Record<number, string | null>>({ 1: null });
|
||||
const [jobsLoading, setJobsLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [sendStatus, setSendStatus] = useState("");
|
||||
const [imapStatus, setImapStatus] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [appliedQuery, setAppliedQuery] = useState("");
|
||||
const [actionMessage, setActionMessage] = useState("");
|
||||
@@ -81,30 +84,75 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadJobs();
|
||||
}, [campaignId, version?.id, page, sendStatus, appliedQuery]);
|
||||
const jobsQueryKey = useMemo(
|
||||
() => JSON.stringify({
|
||||
campaignId,
|
||||
versionId: version?.id ?? null,
|
||||
page,
|
||||
pageSize: 50,
|
||||
sendStatus,
|
||||
imapStatus,
|
||||
appliedQuery,
|
||||
apiBaseUrl: settings.apiBaseUrl,
|
||||
apiKey: settings.apiKey,
|
||||
accessToken: settings.accessToken
|
||||
}),
|
||||
[campaignId, version?.id, page, sendStatus, imapStatus, appliedQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]
|
||||
);
|
||||
|
||||
async function loadJobs() {
|
||||
useEffect(() => {
|
||||
jobPageCursorsRef.current = { 1: null };
|
||||
}, [campaignId, version?.id, sendStatus, imapStatus, appliedQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
if (!campaignId) return;
|
||||
setJobsLoading(true);
|
||||
setActionError("");
|
||||
try {
|
||||
const response = await getCampaignJobs(settings, campaignId, {
|
||||
versionId: version?.id,
|
||||
page,
|
||||
pageSize: 50,
|
||||
sendStatus: sendStatus ? [sendStatus] : undefined,
|
||||
query: appliedQuery || undefined,
|
||||
});
|
||||
setJobs(response);
|
||||
if (response.pages > 0 && page > response.pages) setPage(response.pages);
|
||||
let nextWatermark = getDeltaWatermark(jobsQueryKey);
|
||||
let merged = jobsRef.current;
|
||||
let hasMore = false;
|
||||
const pageCursor = page === 1 ? null : jobPageCursorsRef.current[page];
|
||||
do {
|
||||
const response = await getCampaignJobsDelta(settings, campaignId, {
|
||||
versionId: version?.id,
|
||||
page,
|
||||
pageSize: 50,
|
||||
cursor: pageCursor,
|
||||
sendStatus: sendStatus ? [sendStatus] : undefined,
|
||||
imapStatus: imapStatus ? [imapStatus] : undefined,
|
||||
query: appliedQuery || undefined,
|
||||
since: nextWatermark
|
||||
});
|
||||
merged = mergeCampaignJobsDelta(merged, response);
|
||||
if (response.cursor !== undefined) jobPageCursorsRef.current[page] = response.cursor ?? null;
|
||||
if (response.next_cursor !== undefined) {
|
||||
if (response.next_cursor) jobPageCursorsRef.current[page + 1] = response.next_cursor;
|
||||
else delete jobPageCursorsRef.current[page + 1];
|
||||
}
|
||||
nextWatermark = response.watermark ?? null;
|
||||
hasMore = response.has_more;
|
||||
} while (hasMore);
|
||||
setDeltaWatermark(jobsQueryKey, nextWatermark);
|
||||
jobsRef.current = merged;
|
||||
setJobs(merged);
|
||||
if (merged.pages > 0 && page > merged.pages) setPage(merged.pages);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setJobsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [settings, campaignId, version?.id, page, sendStatus, imapStatus, appliedQuery, jobsQueryKey, getDeltaWatermark, setDeltaWatermark]);
|
||||
|
||||
useEffect(() => {
|
||||
resetDeltaWatermark(jobsQueryKey);
|
||||
jobsRef.current = emptyCampaignJobsResponse();
|
||||
setJobs(emptyCampaignJobsResponse());
|
||||
}, [jobsQueryKey, resetDeltaWatermark]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadJobs();
|
||||
}, [loadJobs]);
|
||||
|
||||
async function reloadAll() {
|
||||
await Promise.all([reload(), loadJobs()]);
|
||||
@@ -116,9 +164,9 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
||||
setActionError("");
|
||||
setActionMessage("");
|
||||
try {
|
||||
const response = action === "retry"
|
||||
? await retryCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true })
|
||||
: await sendUnattemptedCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true });
|
||||
const response = action === "retry" ?
|
||||
await retryCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true }) :
|
||||
await sendUnattemptedCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true });
|
||||
const result = asRecord(response.result ?? response);
|
||||
setActionMessage(`${humanize(String(result.action ?? action))}: ${String(result.selected_count ?? 0)} job(s) selected, ${String(result.enqueued_count ?? 0)} enqueued.`);
|
||||
await reloadAll();
|
||||
@@ -135,9 +183,9 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
||||
setActionError("");
|
||||
try {
|
||||
await resolveCampaignJobOutcome(settings, campaignId, reconcile.jobId, reconcile.decision);
|
||||
setActionMessage(reconcile.decision === "smtp_accepted"
|
||||
? "The job was recorded as SMTP accepted and is protected from retry."
|
||||
: "The job was recorded as not sent. It is now an explicit retry candidate.");
|
||||
setActionMessage(reconcile.decision === "smtp_accepted" ?
|
||||
"i18n:govoplan-campaign.the_job_was_recorded_as_smtp_accepted_and_is_pro.12ee72b6" :
|
||||
"i18n:govoplan-campaign.the_job_was_recorded_as_not_sent_it_is_now_an_ex.2cea8409");
|
||||
setReconcile(null);
|
||||
await reloadAll();
|
||||
} catch (err) {
|
||||
@@ -164,7 +212,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
||||
setActionError("");
|
||||
try {
|
||||
await downloadCampaignJobsCsv(settings, campaignId, version?.id);
|
||||
setActionMessage("Campaign job CSV downloaded.");
|
||||
setActionMessage("i18n:govoplan-campaign.campaign_job_csv_downloaded.08af6930");
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -183,7 +231,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
||||
version_id: version?.id,
|
||||
include_jobs: false,
|
||||
attach_jobs_csv: attachCsv,
|
||||
attach_report_json: attachJson,
|
||||
attach_report_json: attachJson
|
||||
});
|
||||
setActionMessage(`Report sent to ${recipients.join(", ")}.`);
|
||||
setEmailOpen(false);
|
||||
@@ -195,163 +243,252 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() => [
|
||||
{ id: "number", header: "#", width: 70, sticky: "start", sortable: true, value: (row) => Number(row.entry_index ?? 0) + 1 },
|
||||
{ id: "recipient", header: "Recipient", width: 240, resizable: true, sortable: true, filterable: true, value: (row) => String(row.recipient_email ?? "—") },
|
||||
{ id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
||||
{ id: "validation", header: "Validation", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
|
||||
{ id: "send", header: "SMTP", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, value: (row) => String(row.send_status ?? "unknown") },
|
||||
{ id: "imap", header: "IMAP", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "unknown")} />, value: (row) => String(row.imap_status ?? "unknown") },
|
||||
{ id: "attempts", header: "Attempts", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0) },
|
||||
{ id: "error", header: "Last result", width: "minmax(220px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "—") },
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 250,
|
||||
sticky: "end",
|
||||
render: (row) => {
|
||||
const id = String(row.id ?? "");
|
||||
const status = String(row.send_status ?? "");
|
||||
return (
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>Details</Button>
|
||||
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>Accepted</Button>}
|
||||
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>Not sent</Button>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
], [busyAction]);
|
||||
{ id: "number", header: "#", width: 70, sticky: "start", sortable: true, value: (row) => Number(row.entry_index ?? 0) || 1 },
|
||||
{
|
||||
id: "recipient",
|
||||
header: "i18n:govoplan-campaign.recipient.90343260",
|
||||
width: 260,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
render: (row) =>
|
||||
<div className="recipient-outcome-cell">
|
||||
<strong>{String(row.recipient_email ?? "—")}</strong>
|
||||
<span>{String(row.entry_id ?? i18nMessage("i18n:govoplan-campaign.entry_value.b7706ee4", { value0: Number(row.entry_index ?? 0) || 1 }))}</span>
|
||||
</div>,
|
||||
|
||||
value: (row) => String(row.recipient_email ?? "—")
|
||||
},
|
||||
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
||||
{ id: "validation", header: "i18n:govoplan-campaign.validation.dd74d182", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
|
||||
{ id: "queue", header: "i18n:govoplan-campaign.queue.d325fcd9", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.queue_status ?? "unknown")} />, value: (row) => String(row.queue_status ?? "unknown") },
|
||||
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", 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")} />, value: (row) => String(row.send_status ?? "unknown") },
|
||||
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={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) },
|
||||
{
|
||||
id: "evidence",
|
||||
header: "i18n:govoplan-campaign.evidence.7ea014de",
|
||||
width: 240,
|
||||
resizable: true,
|
||||
filterable: true,
|
||||
render: (row) =>
|
||||
<div className="recipient-outcome-cell">
|
||||
<span title={String(row.message_id_header ?? "")}>{row.message_id_header ? i18nMessage("i18n:govoplan-campaign.message_id_value_value.24027e70", { value0: String(row.message_id_header).slice(0, 28), value1: String(row.message_id_header).length > 28 ? "..." : "" }) : "i18n:govoplan-campaign.no_message_id.43390ef7"}</span>
|
||||
<span>{String(row.attachment_count ?? 0)} i18n:govoplan-campaign.attachment_rule_s.0e5ee66a {String(row.matched_file_count ?? 0)} i18n:govoplan-campaign.file_s.4bc4cc05</span>
|
||||
</div>,
|
||||
|
||||
value: (row) => `${String(row.message_id_header ?? "")} ${String(row.eml_sha256 ?? "")}`
|
||||
},
|
||||
{
|
||||
id: "error",
|
||||
header: "i18n:govoplan-campaign.last_result.110b888b",
|
||||
width: "minmax(220px, 1fr)",
|
||||
resizable: true,
|
||||
filterable: true,
|
||||
render: (row) => <span className={row.last_error ? "recipient-outcome-error" : "muted"} title={String(row.last_error ?? "")}>{String(row.last_error ?? "—")}</span>,
|
||||
value: (row) => String(row.last_error ?? "—")
|
||||
},
|
||||
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 165, sortable: true, value: (row) => formatDateTime(String(row.updated_at ?? row.sent_at ?? row.queued_at ?? "")) },
|
||||
{
|
||||
id: "actions",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 250,
|
||||
sticky: "end",
|
||||
render: (row) => {
|
||||
const id = String(row.id ?? "");
|
||||
const status = String(row.send_status ?? "");
|
||||
return (
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>i18n:govoplan-campaign.details.dc3decbb</Button>
|
||||
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>i18n:govoplan-campaign.accepted.61a0572c</Button>}
|
||||
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>i18n:govoplan-campaign.not_sent.587c501e</Button>}
|
||||
</div>);
|
||||
|
||||
}
|
||||
}],
|
||||
[busyAction]);
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Report</PageTitle>
|
||||
<PageTitle loading={loading}>i18n:govoplan-campaign.report.ee45c303</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at ?? data.summary?.generated_at} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>Download CSV</Button>
|
||||
<Button onClick={() => setEmailOpen(true)}>Email report</Button>
|
||||
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>Reload</Button>
|
||||
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>i18n:govoplan-campaign.download_csv.eaa216ad</Button>
|
||||
<Button onClick={() => setEmailOpen(true)}>i18n:govoplan-campaign.email_report.ee3e7091</Button>
|
||||
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
</div>
|
||||
</div>
|
||||
{(error || actionError) && <DismissibleAlert tone="danger" resetKey={`${error}${actionError}`} floating>{error || actionError}</DismissibleAlert>}
|
||||
{actionMessage && <DismissibleAlert tone="success" resetKey={actionMessage} floating>{actionMessage}</DismissibleAlert>}
|
||||
<LoadingFrame loading={loading} label="Loading report data…">
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_report_data.0908ade5">
|
||||
<div className="dashboard-grid">
|
||||
<Card title="Delivery outcome">
|
||||
<Card title="i18n:govoplan-campaign.delivery_outcome.f9d7c085">
|
||||
<dl className="detail-list">
|
||||
<div><dt>Generated</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
|
||||
<div><dt>Jobs total</dt><dd>{cards?.jobs_total ?? "—"}</dd></div>
|
||||
<div><dt>SMTP accepted</dt><dd>{cards?.smtp_accepted ?? cards?.sent ?? 0}</dd></div>
|
||||
<div><dt>Failed</dt><dd>{cards?.failed ?? 0}</dd></div>
|
||||
<div><dt>Outcome unknown</dt><dd>{cards?.outcome_unknown ?? 0}</dd></div>
|
||||
<div><dt>Not attempted</dt><dd>{cards?.not_attempted ?? 0}</dd></div>
|
||||
<div><dt>Cancelled</dt><dd>{cards?.cancelled ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.jobs_total.98da65bc</dt><dd>{cards?.jobs_total ?? "—"}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.smtp_accepted.e3aa7603</dt><dd>{cards?.smtp_accepted ?? cards?.sent ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.failed.09fef5d8</dt><dd>{cards?.failed ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.outcome_unknown.6e929fca</dt><dd>{cards?.outcome_unknown ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.not_attempted.e1be3c69</dt><dd>{cards?.not_attempted ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.cancelled.a1bf92ef</dt><dd>{cards?.cancelled ?? 0}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="IMAP and execution plan">
|
||||
<Card title="i18n:govoplan-campaign.imap_and_execution_plan.4c80c058">
|
||||
<dl className="detail-list">
|
||||
<div><dt>IMAP appended</dt><dd>{cards?.imap_appended ?? 0}</dd></div>
|
||||
<div><dt>IMAP failed</dt><dd>{cards?.imap_failed ?? 0}</dd></div>
|
||||
<div><dt>Append policy</dt><dd>{imapPolicy.enabled === true ? `Enabled (${String(imapPolicy.folder ?? "auto")})` : "Disabled"}</dd></div>
|
||||
<div><dt>Rate limit</dt><dd>{rateLimit.messages_per_minute ? `${String(rateLimit.messages_per_minute)} / minute` : "—"}</dd></div>
|
||||
<div><dt>Minimum remaining duration</dt><dd>{String(delivery.estimated_remaining_send_human ?? "—")}</dd></div>
|
||||
<div><dt>Execution snapshot</dt><dd title={String(delivery.execution_snapshot_hash ?? "")}>{delivery.execution_snapshot_hash ? `${String(delivery.execution_snapshot_hash).slice(0, 12)}…` : "Missing"}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.imap_appended.56017ea3</dt><dd>{cards?.imap_appended ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.imap_failed.50dbca55</dt><dd>{cards?.imap_failed ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.append_policy.f195cb05</dt><dd>{imapPolicy.enabled === true ? i18nMessage("i18n:govoplan-campaign.enabled_value.e395e48f", { value0: String(imapPolicy.folder ?? "i18n:govoplan-campaign.auto.0d612c12") }) : "i18n:govoplan-campaign.disabled.f4f4473d"}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.rate_limit.d08e55f5</dt><dd>{rateLimit.messages_per_minute ? i18nMessage("i18n:govoplan-campaign.value_minute.aeb1a9ea", { value0: String(rateLimit.messages_per_minute) }) : "—"}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.minimum_remaining_duration.639b792c</dt><dd>{String(delivery.estimated_remaining_send_human ?? "—")}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.execution_snapshot.5a67f098</dt><dd title={String(delivery.execution_snapshot_hash ?? "")}>{delivery.execution_snapshot_hash ? i18nMessage("i18n:govoplan-campaign.value.382bcd25", { value0: String(delivery.execution_snapshot_hash).slice(0, 12) }) : "i18n:govoplan-campaign.missing.92185dc5"}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="Explicit delivery actions">
|
||||
<p className="muted">These actions never include SMTP-accepted or unresolved jobs. Uncertain outcomes must be reconciled first.</p>
|
||||
<Card title="i18n:govoplan-campaign.explicit_delivery_actions.b35e72a4">
|
||||
<p className="muted">i18n:govoplan-campaign.these_actions_never_include_smtp_accepted_or_unr.449d0a80</p>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void runExplicitAction("retry")} disabled={!version || Boolean(busyAction)}>Retry temporary failures</Button>
|
||||
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>Send unattempted jobs</Button>
|
||||
<Button onClick={() => void runExplicitAction("retry")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.retry_temporary_failures.e65cfd13</Button>
|
||||
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="Recipient delivery jobs">
|
||||
<Card title="i18n:govoplan-campaign.recipient_delivery_jobs.52492608">
|
||||
<div className="page-heading split">
|
||||
<div className="button-row compact-actions">
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search recipient, subject or entry ID" />
|
||||
<select value={sendStatus} onChange={(event) => { setSendStatus(event.target.value); setPage(1); }}>
|
||||
<option value="">All SMTP states</option>
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="i18n:govoplan-campaign.search_recipient_subject_or_entry_id.6d6544f5" />
|
||||
<select value={sendStatus} onChange={(event) => {setSendStatus(event.target.value);setPage(1);}}>
|
||||
<option value="">i18n:govoplan-campaign.all_smtp_states.739597b1</option>
|
||||
{SEND_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
<select value={imapStatus} onChange={(event) => {setImapStatus(event.target.value);setPage(1);}}>
|
||||
<option value="">i18n:govoplan-campaign.all_imap_states.8546b84c</option>
|
||||
{IMAP_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<span className="muted">{jobs.total} matching of {jobs.total_unfiltered} total job(s)</span>
|
||||
<span className="muted">{jobs.total} i18n:govoplan-campaign.matching_of.66a3778e {jobs.total_unfiltered} i18n:govoplan-campaign.total_job_s.c94b7d20</span>
|
||||
</div>
|
||||
<LoadingFrame loading={jobsLoading} label="Loading delivery jobs…">
|
||||
<LoadingFrame loading={jobsLoading} label="i18n:govoplan-campaign.loading_delivery_jobs.20ecc37e">
|
||||
<DataGrid<Record<string, unknown>>
|
||||
id={`campaign-report-jobs-${campaignId}`}
|
||||
rows={jobs.jobs}
|
||||
columns={columns}
|
||||
getRowKey={(row: Record<string, unknown>) => String(row.id ?? "")}
|
||||
emptyText="No jobs match the current filters."
|
||||
/>
|
||||
emptyText="i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5" />
|
||||
|
||||
</LoadingFrame>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => setPage((value) => Math.max(1, value - 1))} disabled={page <= 1 || jobsLoading}>Previous</Button>
|
||||
<span>Page {jobs.pages === 0 ? 0 : jobs.page} of {jobs.pages}</span>
|
||||
<Button onClick={() => setPage((value) => Math.min(jobs.pages || 1, value + 1))} disabled={page >= jobs.pages || jobsLoading}>Next</Button>
|
||||
<Button onClick={() => setPage((value) => Math.max(1, value - 1))} disabled={page <= 1 || jobsLoading}>i18n:govoplan-campaign.previous.50f94286</Button>
|
||||
<span>i18n:govoplan-campaign.page.fb06270f {jobs.pages === 0 ? 0 : jobs.page} of {jobs.pages}</span>
|
||||
<Button onClick={() => setPage((value) => Math.min(jobs.pages || 1, value + 1))} disabled={page >= jobs.pages || jobsLoading}>i18n:govoplan-campaign.next.bc981983</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
|
||||
<Dialog
|
||||
open={emailOpen}
|
||||
title="Email campaign report"
|
||||
title="i18n:govoplan-campaign.email_campaign_report.61a2989d"
|
||||
onClose={() => setEmailOpen(false)}
|
||||
closeDisabled={busyAction === "email"}
|
||||
footer={(
|
||||
<div className="button-row">
|
||||
<Button onClick={() => setEmailOpen(false)} disabled={busyAction === "email"}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void sendReportEmail()} disabled={!emailRecipients.trim() || busyAction === "email"}>Send report</Button>
|
||||
footer={
|
||||
<div className="button-row">
|
||||
<Button onClick={() => setEmailOpen(false)} disabled={busyAction === "email"}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
||||
<Button variant="primary" onClick={() => void sendReportEmail()} disabled={!emailRecipients.trim() || busyAction === "email"}>i18n:govoplan-campaign.send_report.a5b32af9</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
}>
|
||||
|
||||
<label className="field-stack">
|
||||
<span>Recipients</span>
|
||||
<span>i18n:govoplan-campaign.recipients.78cbf8eb</span>
|
||||
<textarea value={emailRecipients} onChange={(event) => setEmailRecipients(event.target.value)} placeholder="audit@example.org; owner@example.org" rows={3} />
|
||||
</label>
|
||||
<label><input type="checkbox" checked={attachCsv} onChange={(event) => setAttachCsv(event.target.checked)} /> Attach job CSV</label>
|
||||
<label><input type="checkbox" checked={attachJson} onChange={(event) => setAttachJson(event.target.checked)} /> Attach JSON report</label>
|
||||
<label><input type="checkbox" checked={attachCsv} onChange={(event) => setAttachCsv(event.target.checked)} /> i18n:govoplan-campaign.attach_job_csv.adb76197</label>
|
||||
<label><input type="checkbox" checked={attachJson} onChange={(event) => setAttachJson(event.target.checked)} /> i18n:govoplan-campaign.attach_json_report.d70883b5</label>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(detail)}
|
||||
title="Campaign job detail"
|
||||
title="i18n:govoplan-campaign.campaign_job_detail.81dc68e1"
|
||||
onClose={() => setDetail(null)}
|
||||
className="dialog-panel-wide"
|
||||
>
|
||||
{detail && (
|
||||
<div className="stacked-sections">
|
||||
className="dialog-panel-wide">
|
||||
|
||||
{detail &&
|
||||
<div className="stacked-sections">
|
||||
<dl className="detail-list">
|
||||
<div><dt>Recipient</dt><dd>{String(detail.job.recipient_email ?? "—")}</dd></div>
|
||||
<div><dt>Subject</dt><dd>{String(detail.job.subject ?? "—")}</dd></div>
|
||||
<div><dt>SMTP state</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} /></dd></div>
|
||||
<div><dt>IMAP state</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} /></dd></div>
|
||||
<div><dt>Attachments</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.recipient.90343260</dt><dd>{String(detail.job.recipient_email ?? "—")}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.subject.8d183dbd</dt><dd>{String(detail.job.subject ?? "—")}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.message_id.465056ba</dt><dd>{String(detail.job.message_id_header ?? "—")}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.smtp_state.ff372566</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} /></dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.imap_state.03b83be0</dt><dd><StatusBadge status={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>
|
||||
</dl>
|
||||
<h3>SMTP attempts</h3>
|
||||
<pre className="json-preview">{stringifyPreview(detail.attempts.smtp ?? [], 12000)}</pre>
|
||||
<h3>IMAP attempts</h3>
|
||||
<pre className="json-preview">{stringifyPreview(detail.attempts.imap ?? [], 12000)}</pre>
|
||||
<AttemptHistoryTable kind="smtp" rows={detail.attempts.smtp ?? []} />
|
||||
<AttemptHistoryTable kind="imap" rows={detail.attempts.imap ?? []} />
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(reconcile)}
|
||||
title={reconcile?.decision === "smtp_accepted" ? "Record SMTP acceptance?" : "Record message as not sent?"}
|
||||
message={reconcile?.decision === "smtp_accepted"
|
||||
? "Use this only after checking the SMTP server or recipient-side evidence. The job will be protected from retry and may proceed to IMAP append."
|
||||
: "Use this only when you have evidence that SMTP did not accept the message. The job will become a failed-temporary retry candidate, but it will not be retried automatically."}
|
||||
confirmLabel={reconcile?.decision === "smtp_accepted" ? "Record accepted" : "Record not sent"}
|
||||
title={reconcile?.decision === "smtp_accepted" ? "i18n:govoplan-campaign.record_smtp_acceptance.c40f8c9d" : "i18n:govoplan-campaign.record_message_as_not_sent.42e4faf8"}
|
||||
message={reconcile?.decision === "smtp_accepted" ?
|
||||
"i18n:govoplan-campaign.use_this_only_after_checking_the_smtp_server_or_.6f4396e1" :
|
||||
"i18n:govoplan-campaign.use_this_only_when_you_have_evidence_that_smtp_d.aa48f4ad"}
|
||||
confirmLabel={reconcile?.decision === "smtp_accepted" ? "i18n:govoplan-campaign.record_accepted.023d6747" : "i18n:govoplan-campaign.record_not_sent.b376b4ed"}
|
||||
tone={reconcile?.decision === "smtp_accepted" ? "default" : "danger"}
|
||||
busy={busyAction === "reconcile"}
|
||||
onConfirm={() => void reconcileOutcome()}
|
||||
onCancel={() => setReconcile(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
onCancel={() => setReconcile(null)} />
|
||||
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record<string, unknown>[];}) {
|
||||
const title = kind === "smtp" ? "i18n:govoplan-campaign.smtp_attempts.eb0a9ca6" : "i18n:govoplan-campaign.imap_append_attempts.b30e980a";
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<section className="attempt-history-section">
|
||||
<h3>{title}</h3>
|
||||
<p className="muted small-note">i18n:govoplan-campaign.no.816c52fd {kind === "smtp" ? "i18n:govoplan-campaign.smtp.efff9cca" : "i18n:govoplan-campaign.imap.271f9ef2"} i18n:govoplan-campaign.attempt_has_been_recorded_for_this_job.e4050f01</p>
|
||||
</section>);
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="attempt-history-section">
|
||||
<h3>{title}</h3>
|
||||
<div className="attempt-history-wrap">
|
||||
<table className="attempt-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>i18n:govoplan-campaign.status.bae7d5be</th>
|
||||
{kind === "imap" && <th>i18n:govoplan-campaign.folder.30baa249</th>}
|
||||
{kind === "smtp" && <th>i18n:govoplan-campaign.code.adac6937</th>}
|
||||
<th>i18n:govoplan-campaign.started.faa9e7e7</th>
|
||||
<th>i18n:govoplan-campaign.finished.355bcc57</th>
|
||||
<th>i18n:govoplan-campaign.result.5faa59d4</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) =>
|
||||
<tr key={String(row.id ?? `${kind}-${index}`)}>
|
||||
<td>{String(row.attempt_number ?? index + 1)}</td>
|
||||
<td><StatusBadge status={String(row.status ?? "unknown")} /></td>
|
||||
{kind === "imap" && <td>{String(row.folder ?? "—")}</td>}
|
||||
{kind === "smtp" && <td>{String(row.smtp_status_code ?? "—")}</td>}
|
||||
<td>{formatDateTime(String(row.started_at ?? row.created_at ?? ""))}</td>
|
||||
<td>{formatDateTime(String(row.finished_at ?? row.updated_at ?? ""))}</td>
|
||||
<td title={String(row.smtp_response ?? row.error_message ?? "")}>
|
||||
{String(row.smtp_response ?? row.error_message ?? "—")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { useGuardedNavigate } from "@govoplan/core-webui";
|
||||
import type { ApiSettings, AuthInfo, CampaignWorkspaceSection } from "../../types";
|
||||
import SectionSidebar from "../../layout/SectionSidebar";
|
||||
import CampaignOverviewPage from "./CampaignOverviewPage";
|
||||
@@ -17,7 +18,6 @@ import SendWizard from "./wizard/SendWizard";
|
||||
import CampaignJsonView from "./CampaignJsonView";
|
||||
import CampaignReportPage from "./CampaignReportPage";
|
||||
import CampaignAuditPage from "./CampaignAuditPage";
|
||||
import { useCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
|
||||
|
||||
const sectionPaths: Record<CampaignWorkspaceSection, string> = {
|
||||
overview: "",
|
||||
@@ -44,7 +44,7 @@ export default function CampaignWorkspace({ settings, auth }: { settings: ApiSet
|
||||
function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
||||
const { campaignId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { requestNavigation } = useCampaignUnsavedChanges();
|
||||
const guardedNavigate = useGuardedNavigate();
|
||||
const location = useLocation();
|
||||
const active = sectionFromPath(location.pathname);
|
||||
const urlVersionId = new URLSearchParams(location.search).get("version");
|
||||
@@ -73,7 +73,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
||||
const query = params.toString();
|
||||
const target = query ? `${pathname}?${query}` : pathname;
|
||||
if (`${location.pathname}${location.search}` === target) return;
|
||||
requestNavigation(() => navigate(target));
|
||||
guardedNavigate(target);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
@@ -6,6 +6,8 @@ import { FormField } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { PolicyRow } from "@govoplan/core-webui";
|
||||
import { PolicyTable } from "@govoplan/core-webui";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import CampaignAccessCard from "./components/CampaignAccessCard";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
@@ -44,10 +46,10 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
reload,
|
||||
setError,
|
||||
currentStep: isPolicyView ? "policies" : "global-settings",
|
||||
unsavedTitle: isPolicyView ? "Unsaved campaign policy changes" : "Unsaved campaign settings",
|
||||
unsavedMessage: isPolicyView
|
||||
? "Campaign policies have unsaved changes. Save them before leaving, or discard them and continue."
|
||||
: "Campaign settings have unsaved changes. Save them before leaving, or discard them and continue.",
|
||||
unsavedTitle: isPolicyView ? "i18n:govoplan-campaign.unsaved_campaign_policy_changes.181f3426" : "i18n:govoplan-campaign.unsaved_campaign_settings.0555d713",
|
||||
unsavedMessage: isPolicyView ?
|
||||
"i18n:govoplan-campaign.campaign_policies_have_unsaved_changes_save_them.7b07827f" :
|
||||
"i18n:govoplan-campaign.campaign_settings_have_unsaved_changes_save_them.c1c5f65f",
|
||||
extraPayload: () => ({ editor_state: editorState }),
|
||||
onLoaded: (loadedVersion) => setEditorState(cloneJson(loadedVersion.editor_state ?? {})),
|
||||
onSaved: (savedVersion) => setEditorState(cloneJson(savedVersion.editor_state ?? editorState))
|
||||
@@ -61,7 +63,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
const optIns = asRecord(editorState.opt_ins);
|
||||
const canReadRetentionPolicy = hasScope(auth, "admin:policies:read");
|
||||
const canWriteRetentionPolicy = hasScope(auth, "admin:policies:write");
|
||||
const pageTitle = isPolicyView ? "Campaign policies" : "Campaign settings";
|
||||
const pageTitle = isPolicyView ? "i18n:govoplan-campaign.campaign_policies.0b5de1f5" : "i18n:govoplan-campaign.campaign_settings.efffec26";
|
||||
|
||||
function patchEditor(path: string[], value: unknown) {
|
||||
if (locked) return;
|
||||
@@ -77,171 +79,185 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "Save now" : "Saved"}</Button>
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||
{isPolicyView ? (
|
||||
<>
|
||||
{canReadRetentionPolicy && (
|
||||
<RetentionPolicyEditor
|
||||
settings={settings}
|
||||
scopeType="campaign"
|
||||
scopeId={campaignId}
|
||||
title="Campaign retention policy"
|
||||
description="Campaign-level retention limits applied after system, tenant and owner policy. Blank values inherit."
|
||||
canWrite={canWriteRetentionPolicy}
|
||||
locked={locked}
|
||||
/>
|
||||
)}
|
||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||
{isPolicyView ?
|
||||
<>
|
||||
{canReadRetentionPolicy &&
|
||||
<RetentionPolicyEditor
|
||||
settings={settings}
|
||||
scopeType="campaign"
|
||||
scopeId={campaignId}
|
||||
title="i18n:govoplan-campaign.campaign_retention_policy.fcc9897c"
|
||||
description="i18n:govoplan-campaign.campaign_level_retention_limits_applied_after_sy.e1a5fd45"
|
||||
canWrite={canWriteRetentionPolicy}
|
||||
locked={locked} />
|
||||
|
||||
}
|
||||
|
||||
<div className="dashboard-grid below-grid">
|
||||
<Card title="Validation policy" collapsible>
|
||||
<PolicyTable>
|
||||
<Card title="i18n:govoplan-campaign.validation_policy.57dcc756" collapsible>
|
||||
<PolicyTable className="campaign-policy-table" rowClassName="campaign-policy-row" headerClassName="campaign-policy-row-header" fieldLabel="i18n:govoplan-campaign.policy.bb9cf141" settingLabel="i18n:govoplan-campaign.setting.fb449f71" effectiveLabel="i18n:govoplan-campaign.effective_behavior.3daa11c2" showEffectiveColumn>
|
||||
<PolicyRow
|
||||
label="Missing required attachment"
|
||||
note="Required attachment rule found no matching file."
|
||||
control={<PolicySelectControl value={getText(validationPolicy, "missing_required_attachment", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_required_attachment"], value)} />}
|
||||
effective={behaviorSummary(getText(validationPolicy, "missing_required_attachment", "ask"))}
|
||||
/>
|
||||
className="campaign-policy-row"
|
||||
labelClassName="campaign-policy-label"
|
||||
controlClassName="campaign-policy-control"
|
||||
effectiveClassName="campaign-policy-effective-note"
|
||||
label="i18n:govoplan-campaign.missing_required_attachment.a8aa73e0"
|
||||
note="i18n:govoplan-campaign.required_attachment_rule_found_no_matching_file.d947b52b"
|
||||
control={<PolicySelectControl value={getText(validationPolicy, "missing_required_attachment", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_required_attachment"], value)} />}
|
||||
effective={behaviorSummary(getText(validationPolicy, "missing_required_attachment", "ask"))} />
|
||||
|
||||
<PolicyRow
|
||||
label="Missing optional attachment"
|
||||
note="Optional attachment rule found no matching file."
|
||||
control={<PolicySelectControl value={getText(validationPolicy, "missing_optional_attachment", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_optional_attachment"], value)} />}
|
||||
effective={behaviorSummary(getText(validationPolicy, "missing_optional_attachment", "warn"))}
|
||||
/>
|
||||
className="campaign-policy-row"
|
||||
labelClassName="campaign-policy-label"
|
||||
controlClassName="campaign-policy-control"
|
||||
effectiveClassName="campaign-policy-effective-note"
|
||||
label="i18n:govoplan-campaign.missing_optional_attachment.36640fa6"
|
||||
note="i18n:govoplan-campaign.optional_attachment_rule_found_no_matching_file.709a2a13"
|
||||
control={<PolicySelectControl value={getText(validationPolicy, "missing_optional_attachment", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_optional_attachment"], value)} />}
|
||||
effective={behaviorSummary(getText(validationPolicy, "missing_optional_attachment", "warn"))} />
|
||||
|
||||
<PolicyRow
|
||||
label="Ambiguous attachment match"
|
||||
note="Attachment rule matched more files than expected."
|
||||
control={<PolicySelectControl value={getText(validationPolicy, "ambiguous_attachment_match", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "ambiguous_attachment_match"], value)} />}
|
||||
effective={behaviorSummary(getText(validationPolicy, "ambiguous_attachment_match", "ask"))}
|
||||
/>
|
||||
className="campaign-policy-row"
|
||||
labelClassName="campaign-policy-label"
|
||||
controlClassName="campaign-policy-control"
|
||||
effectiveClassName="campaign-policy-effective-note"
|
||||
label="i18n:govoplan-campaign.ambiguous_attachment_match.8043ddfa"
|
||||
note="i18n:govoplan-campaign.attachment_rule_matched_more_files_than_expected.3e2110e8"
|
||||
control={<PolicySelectControl value={getText(validationPolicy, "ambiguous_attachment_match", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "ambiguous_attachment_match"], value)} />}
|
||||
effective={behaviorSummary(getText(validationPolicy, "ambiguous_attachment_match", "ask"))} />
|
||||
|
||||
<PolicyRow
|
||||
label="Unsent attachment file"
|
||||
note="A watched attachment directory contains files that were not selected."
|
||||
control={<PolicySelectControl value={getText(validationPolicy, "unsent_attachment_files", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "unsent_attachment_files"], value)} />}
|
||||
effective={behaviorSummary(getText(validationPolicy, "unsent_attachment_files", "warn"))}
|
||||
/>
|
||||
className="campaign-policy-row"
|
||||
labelClassName="campaign-policy-label"
|
||||
controlClassName="campaign-policy-control"
|
||||
effectiveClassName="campaign-policy-effective-note"
|
||||
label="i18n:govoplan-campaign.unsent_attachment_file.a25263ce"
|
||||
note="i18n:govoplan-campaign.a_watched_attachment_directory_contains_files_th.03f58a42"
|
||||
control={<PolicySelectControl value={getText(validationPolicy, "unsent_attachment_files", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "unsent_attachment_files"], value)} />}
|
||||
effective={behaviorSummary(getText(validationPolicy, "unsent_attachment_files", "warn"))} />
|
||||
|
||||
<PolicyRow
|
||||
label="Missing email address"
|
||||
note="The effective recipient list has no To address."
|
||||
control={<PolicySelectControl value={getText(validationPolicy, "missing_email", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_email"], value)} options={["block", "drop"]} />}
|
||||
effective={behaviorSummary(getText(validationPolicy, "missing_email", "block"))}
|
||||
/>
|
||||
className="campaign-policy-row"
|
||||
labelClassName="campaign-policy-label"
|
||||
controlClassName="campaign-policy-control"
|
||||
effectiveClassName="campaign-policy-effective-note"
|
||||
label="i18n:govoplan-campaign.missing_email_address.2a94b6d8"
|
||||
note="i18n:govoplan-campaign.the_effective_recipient_list_has_no_to_address.df4e24f7"
|
||||
control={<PolicySelectControl value={getText(validationPolicy, "missing_email", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_email"], value)} options={["block", "drop"]} />}
|
||||
effective={behaviorSummary(getText(validationPolicy, "missing_email", "block"))} />
|
||||
|
||||
<PolicyRow
|
||||
label="Template error"
|
||||
note="A selected template body contains unresolved placeholders."
|
||||
control={<PolicySelectControl value={getText(validationPolicy, "template_error", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "template_error"], value)} options={["block", "drop"]} />}
|
||||
effective={behaviorSummary(getText(validationPolicy, "template_error", "block"))}
|
||||
/>
|
||||
className="campaign-policy-row"
|
||||
labelClassName="campaign-policy-label"
|
||||
controlClassName="campaign-policy-control"
|
||||
effectiveClassName="campaign-policy-effective-note"
|
||||
label="i18n:govoplan-campaign.template_error.50b92586"
|
||||
note="i18n:govoplan-campaign.a_selected_template_body_contains_unresolved_pla.d061acad"
|
||||
control={<PolicySelectControl value={getText(validationPolicy, "template_error", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "template_error"], value)} options={["block", "drop"]} />}
|
||||
effective={behaviorSummary(getText(validationPolicy, "template_error", "block"))} />
|
||||
|
||||
<PolicyRow
|
||||
label="Ignore empty fields"
|
||||
note="Controls how missing placeholder values are rendered."
|
||||
control={<ToggleSwitch label="Enabled" checked={getBool(validationPolicy, "ignore_empty_fields")} disabled={locked} onChange={(checked) => patch(["validation_policy", "ignore_empty_fields"], checked)} />}
|
||||
effective={getBool(validationPolicy, "ignore_empty_fields") ? "Missing values render as empty text." : "Missing values remain visible and can trigger template errors."}
|
||||
/>
|
||||
className="campaign-policy-row"
|
||||
labelClassName="campaign-policy-label"
|
||||
controlClassName="campaign-policy-control"
|
||||
effectiveClassName="campaign-policy-effective-note"
|
||||
label="i18n:govoplan-campaign.ignore_empty_fields.1ffcdc5d"
|
||||
note="i18n:govoplan-campaign.controls_how_missing_placeholder_values_are_rend.c1ad492a"
|
||||
control={<ToggleSwitch label="i18n:govoplan-campaign.enabled.df174a3f" checked={getBool(validationPolicy, "ignore_empty_fields")} disabled={locked} onChange={(checked) => patch(["validation_policy", "ignore_empty_fields"], checked)} />}
|
||||
effective={getBool(validationPolicy, "ignore_empty_fields") ? "i18n:govoplan-campaign.missing_values_render_as_empty_text.0a6bce45" : "i18n:govoplan-campaign.missing_values_remain_visible_and_can_trigger_te.31464ee2"} />
|
||||
|
||||
</PolicyTable>
|
||||
</Card>
|
||||
|
||||
<Card title="Attachment policy" collapsible>
|
||||
<PolicyTable>
|
||||
<Card title="i18n:govoplan-campaign.attachment_policy.2fd471d4" collapsible>
|
||||
<PolicyTable className="campaign-policy-table" rowClassName="campaign-policy-row" headerClassName="campaign-policy-row-header" fieldLabel="i18n:govoplan-campaign.policy.bb9cf141" settingLabel="i18n:govoplan-campaign.setting.fb449f71" effectiveLabel="i18n:govoplan-campaign.effective_behavior.3daa11c2" showEffectiveColumn>
|
||||
<PolicyRow
|
||||
label="Default missing behavior"
|
||||
note="Used by attachment rules that do not override missing-file behavior."
|
||||
control={<PolicySelectControl value={getText(attachments, "missing_behavior", "ask")} disabled={locked} onChange={(value) => patch(["attachments", "missing_behavior"], value)} />}
|
||||
effective={behaviorSummary(getText(attachments, "missing_behavior", "ask"))}
|
||||
/>
|
||||
className="campaign-policy-row"
|
||||
labelClassName="campaign-policy-label"
|
||||
controlClassName="campaign-policy-control"
|
||||
effectiveClassName="campaign-policy-effective-note"
|
||||
label="i18n:govoplan-campaign.default_missing_behavior.ffbd9cd7"
|
||||
note="i18n:govoplan-campaign.used_by_attachment_rules_that_do_not_override_mi.ab8bc0d5"
|
||||
control={<PolicySelectControl value={getText(attachments, "missing_behavior", "ask")} disabled={locked} onChange={(value) => patch(["attachments", "missing_behavior"], value)} />}
|
||||
effective={behaviorSummary(getText(attachments, "missing_behavior", "ask"))} />
|
||||
|
||||
<PolicyRow
|
||||
label="Default ambiguous behavior"
|
||||
note="Used by attachment rules that do not override ambiguous-match behavior."
|
||||
control={<PolicySelectControl value={getText(attachments, "ambiguous_behavior", "ask")} disabled={locked} onChange={(value) => patch(["attachments", "ambiguous_behavior"], value)} />}
|
||||
effective={behaviorSummary(getText(attachments, "ambiguous_behavior", "ask"))}
|
||||
/>
|
||||
className="campaign-policy-row"
|
||||
labelClassName="campaign-policy-label"
|
||||
controlClassName="campaign-policy-control"
|
||||
effectiveClassName="campaign-policy-effective-note"
|
||||
label="i18n:govoplan-campaign.default_ambiguous_behavior.878b9345"
|
||||
note="i18n:govoplan-campaign.used_by_attachment_rules_that_do_not_override_am.c31b4e9f"
|
||||
control={<PolicySelectControl value={getText(attachments, "ambiguous_behavior", "ask")} disabled={locked} onChange={(value) => patch(["attachments", "ambiguous_behavior"], value)} />}
|
||||
effective={behaviorSummary(getText(attachments, "ambiguous_behavior", "ask"))} />
|
||||
|
||||
<PolicyRow
|
||||
label="Send without attachments"
|
||||
note="Campaign-wide fallback when no attachment is available."
|
||||
control={<ToggleSwitch label="Allowed" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />}
|
||||
effective={getBool(attachments, "send_without_attachments", true) ? "Messages may be sent when attachment rules allow it." : "Missing attachment coverage blocks sending."}
|
||||
/>
|
||||
className="campaign-policy-row"
|
||||
labelClassName="campaign-policy-label"
|
||||
controlClassName="campaign-policy-control"
|
||||
effectiveClassName="campaign-policy-effective-note"
|
||||
label="i18n:govoplan-campaign.send_without_attachments.ead6d030"
|
||||
note="i18n:govoplan-campaign.campaign_wide_fallback_when_no_attachment_is_ava.84ffa0bc"
|
||||
control={<ToggleSwitch label="i18n:govoplan-campaign.allowed.77c7b490" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />}
|
||||
effective={getBool(attachments, "send_without_attachments", true) ? "i18n:govoplan-campaign.messages_may_be_sent_when_attachment_rules_allow.e6bf1aed" : "i18n:govoplan-campaign.missing_attachment_coverage_blocks_sending.05ff02a1"} />
|
||||
|
||||
</PolicyTable>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
</> :
|
||||
|
||||
<>
|
||||
{data.campaign && <CampaignAccessCard settings={settings} campaign={data.campaign} onChanged={reload} onError={setError} />}
|
||||
|
||||
<div className="dashboard-grid below-grid">
|
||||
<Card title="Delivery defaults" collapsible>
|
||||
<Card title="i18n:govoplan-campaign.delivery_defaults.33cc3b97" collapsible>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<FormField label="Messages per minute"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
|
||||
<FormField label="Concurrency"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
|
||||
<FormField label="Max attempts"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
|
||||
<ToggleSwitch label="Status tracking" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
|
||||
<FormField label="i18n:govoplan-campaign.messages_per_minute.bea49348"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
|
||||
<FormField label="i18n:govoplan-campaign.concurrency.2ec390bf"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
|
||||
<FormField label="i18n:govoplan-campaign.max_attempts.f684fef4"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
|
||||
<ToggleSwitch label="i18n:govoplan-campaign.status_tracking.15f61f88" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Opt-ins and local assistance" collapsible>
|
||||
<Card title="i18n:govoplan-campaign.opt_ins_and_local_assistance.d0d23635" collapsible>
|
||||
<div className="toggle-grid">
|
||||
<ToggleSwitch label="Suggest addresses from this campaign" checked={getBool(optIns, "campaign_address_suggestions", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "campaign_address_suggestions"], checked)} />
|
||||
<ToggleSwitch label="Remember newly used addresses" checked={getBool(optIns, "remember_used_addresses")} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "remember_used_addresses"], checked)} />
|
||||
<ToggleSwitch label="Show guided warnings while editing" checked={getBool(optIns, "inline_guidance", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "inline_guidance"], checked)} />
|
||||
<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)} />
|
||||
<ToggleSwitch label="i18n:govoplan-campaign.remember_newly_used_addresses.aebe8adb" checked={getBool(optIns, "remember_used_addresses")} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "remember_used_addresses"], checked)} />
|
||||
<ToggleSwitch label="i18n:govoplan-campaign.show_guided_warnings_while_editing.bc5dba85" checked={getBool(optIns, "inline_guidance", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "inline_guidance"], checked)} />
|
||||
</div>
|
||||
<p className="muted small-note">These opt-ins are stored in the draft editor metadata for now. A later backend patch can make address-book storage tenant/user aware.</p>
|
||||
<p className="muted small-note">i18n:govoplan-campaign.these_opt_ins_are_stored_in_the_draft_editor_met.dc6ffbfd</p>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function PolicyTable({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="campaign-policy-table policy-table">
|
||||
<div className="campaign-policy-row policy-row campaign-policy-row-header policy-row-header">
|
||||
<span>Policy</span>
|
||||
<span>Setting</span>
|
||||
<span>Effective behavior</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyRow({ label, note, control, effective }: { label: string; note: string; control: ReactNode; effective: string }) {
|
||||
return (
|
||||
<div className="campaign-policy-row policy-row">
|
||||
<div className="campaign-policy-label policy-field-label">
|
||||
<strong>{label}</strong>
|
||||
<small>{note}</small>
|
||||
</div>
|
||||
<div className="campaign-policy-control policy-control">{control}</div>
|
||||
<p className="muted small-note campaign-policy-effective-note policy-effective-note">{effective}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicySelectControl({ value, disabled, onChange, options = behaviorOptions }: { value: string; disabled?: boolean; onChange: (value: string) => void; options?: string[] }) {
|
||||
function PolicySelectControl({ value, disabled, onChange, options = behaviorOptions }: {value: string;disabled?: boolean;onChange: (value: string) => void;options?: string[];}) {
|
||||
return (
|
||||
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
);
|
||||
</select>);
|
||||
|
||||
}
|
||||
|
||||
function behaviorSummary(value: string): string {
|
||||
if (value === "block") return "Blocks the affected message.";
|
||||
if (value === "ask") return "Marks the message for review.";
|
||||
if (value === "drop") return "Excludes the affected message.";
|
||||
if (value === "continue") return "Continues without a review issue.";
|
||||
if (value === "warn") return "Keeps sending possible with a warning.";
|
||||
return "Uses the configured behavior.";
|
||||
if (value === "block") return "i18n:govoplan-campaign.blocks_the_affected_message.0157342a";
|
||||
if (value === "ask") return "i18n:govoplan-campaign.marks_the_message_for_review.d63beb24";
|
||||
if (value === "drop") return "i18n:govoplan-campaign.excludes_the_affected_message.73be963f";
|
||||
if (value === "continue") return "i18n:govoplan-campaign.continues_without_a_review_issue.99ab0684";
|
||||
if (value === "warn") return "i18n:govoplan-campaign.keeps_sending_possible_with_a_warning.66c84a54";
|
||||
return "i18n:govoplan-campaign.uses_the_configured_behavior.ea6e82a3";
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { PageTitle } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
|
||||
import {
|
||||
createMailServerProfile,
|
||||
getMailProfilePolicy,
|
||||
@@ -21,8 +21,8 @@ import {
|
||||
testSmtpSettings,
|
||||
type MailProfilePolicy,
|
||||
type MailSecurity,
|
||||
type MailServerProfile
|
||||
} from "../../api/mail";
|
||||
type MailServerProfile } from
|
||||
"../../api/mail";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
@@ -67,10 +67,10 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
reload,
|
||||
setError,
|
||||
currentStep: isPolicyView ? "mail-policy" : "mail-settings",
|
||||
unsavedTitle: isPolicyView ? "Unsaved mail policy changes" : "Unsaved mail settings",
|
||||
unsavedMessage: isPolicyView
|
||||
? "Mail policy changes have unsaved draft changes. Save them before leaving, or discard them and continue."
|
||||
: "Mail settings have unsaved changes. Save them before leaving, or discard them and continue.",
|
||||
unsavedTitle: isPolicyView ? "i18n:govoplan-campaign.unsaved_mail_policy_changes.c9327491" : "i18n:govoplan-campaign.unsaved_mail_settings.38e1536b",
|
||||
unsavedMessage: isPolicyView ?
|
||||
"i18n:govoplan-campaign.mail_policy_changes_have_unsaved_draft_changes_s.5aee7d4e" :
|
||||
"i18n:govoplan-campaign.mail_settings_have_unsaved_changes_save_them_bef.52644559",
|
||||
transformDraftBeforeSave: normalizeMailSettingsBeforeSave
|
||||
});
|
||||
const server = asRecord(displayDraft.server);
|
||||
@@ -93,9 +93,9 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
const campaignProfilesAllowed = mailPolicyState.campaignProfilesAllowed;
|
||||
const inlineMailSettingsBlocked = mailPolicyState.inlineMailSettingsBlocked;
|
||||
const smtpDisabled = locked || usingMailProfile || inlineMailSettingsBlocked;
|
||||
const smtpCredentialDisabled = locked || inlineMailSettingsBlocked || (usingMailProfile && smtpCredentialsInherited);
|
||||
const smtpCredentialDisabled = locked || inlineMailSettingsBlocked || usingMailProfile && smtpCredentialsInherited;
|
||||
const imapServerDisabled = locked || inlineMailSettingsBlocked || usingMailProfile;
|
||||
const imapCredentialDisabled = locked || inlineMailSettingsBlocked || imapUnavailable || (usingMailProfile && imapCredentialsInherited);
|
||||
const imapCredentialDisabled = locked || inlineMailSettingsBlocked || imapUnavailable || usingMailProfile && imapCredentialsInherited;
|
||||
const imapDisabled = locked || inlineMailSettingsBlocked || imapUnavailable;
|
||||
const imapAppendEnabled = getBool(imapAppend, "enabled");
|
||||
const appendTargetFolderDisabled = imapDisabled || !imapAppendEnabled;
|
||||
@@ -129,7 +129,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
sent_folder: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.sent_folder ?? "auto" : getText(imap, "sent_folder", "auto"),
|
||||
timeout_seconds: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.timeout_seconds ?? 30 : getNumber(imap, "timeout_seconds", 30)
|
||||
};
|
||||
const selectedProfileNeedsLocalCredentials = usingMailProfile && (!smtpCredentialsInherited || (selectedProfileHasImap && !imapCredentialsInherited));
|
||||
const selectedProfileNeedsLocalCredentials = usingMailProfile && (!smtpCredentialsInherited || selectedProfileHasImap && !imapCredentialsInherited);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mailModuleInstalled) {
|
||||
@@ -149,10 +149,10 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
setProfileError("");
|
||||
try {
|
||||
const [allowedProfiles, visibleProfiles, policyResponse] = await Promise.all([
|
||||
listMailServerProfiles(settings, false, campaignId),
|
||||
listMailServerProfiles(settings, true),
|
||||
getMailProfilePolicy(settings, "campaign", campaignId, campaignId)
|
||||
]);
|
||||
listMailServerProfiles(settings, false, campaignId),
|
||||
listMailServerProfiles(settings, true),
|
||||
getMailProfilePolicy(settings, "campaign", campaignId, campaignId)]
|
||||
);
|
||||
setMailProfiles(allowedProfiles);
|
||||
setPolicyProfiles(visibleProfiles);
|
||||
setEffectiveMailPolicy(policyResponse.effective_policy ?? null);
|
||||
@@ -182,11 +182,11 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
}
|
||||
|
||||
function normalizeProfileCredentialProtocol(
|
||||
serverValue: Record<string, unknown>,
|
||||
credentialsValue: Record<string, unknown>,
|
||||
protocol: "smtp" | "imap",
|
||||
inherit: boolean
|
||||
) {
|
||||
serverValue: Record<string, unknown>,
|
||||
credentialsValue: Record<string, unknown>,
|
||||
protocol: "smtp" | "imap",
|
||||
inherit: boolean)
|
||||
{
|
||||
serverValue["inherit_" + protocol + "_credentials"] = inherit;
|
||||
if (inherit === false) return;
|
||||
|
||||
@@ -283,21 +283,21 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
function smtpServerPayload() {
|
||||
return mailSmtpSettingsPayload<MailSecurity>(
|
||||
{ host: getText(smtp, "host"), port: getNumber(smtp, "port", 587), security: getText(smtp, "security", "starttls"), timeout_seconds: getNumber(smtp, "timeout_seconds", 30) },
|
||||
{ fallbackSecurity: "starttls", allowedSecurity: securityOptions },
|
||||
{ fallbackSecurity: "starttls", allowedSecurity: securityOptions }
|
||||
);
|
||||
}
|
||||
|
||||
function imapServerPayload() {
|
||||
return mailImapSettingsPayload<MailSecurity>(
|
||||
{ host: getText(imap, "host"), port: getNumber(imap, "port", 993), security: getText(imap, "security", "tls"), sent_folder: getText(imap, "sent_folder", "auto"), timeout_seconds: getNumber(imap, "timeout_seconds", 30) },
|
||||
{ fallbackSecurity: "tls", allowedSecurity: securityOptions },
|
||||
{ fallbackSecurity: "tls", allowedSecurity: securityOptions }
|
||||
);
|
||||
}
|
||||
|
||||
function mailProfileCredentialsPayload(preserveBlankPassword: boolean) {
|
||||
return {
|
||||
smtp: mailTransportCredentialsPayloadFromRecords(smtpCredentials, smtp, preserveBlankPassword),
|
||||
imap: mailTransportCredentialsPayloadFromRecords(imapCredentials, imap, preserveBlankPassword),
|
||||
imap: mailTransportCredentialsPayloadFromRecords(imapCredentials, imap, preserveBlankPassword)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -324,16 +324,16 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
|
||||
async function runSmtpTest() {
|
||||
if (!mailModuleInstalled) {
|
||||
setSmtpTestResult({ ok: false, protocol: "smtp", message: "Install and enable the Mail module to test SMTP settings.", details: {} });
|
||||
setSmtpTestResult({ ok: false, protocol: "smtp", message: "i18n:govoplan-campaign.install_and_enable_the_mail_module_to_test_smtp_.a7ce04e1", details: {} });
|
||||
return;
|
||||
}
|
||||
if (locked || inlineMailSettingsBlocked) return;
|
||||
setMailActionState("smtp");
|
||||
setLocalError("");
|
||||
try {
|
||||
setSmtpTestResult(selectedProfileId && smtpCredentialsInherited
|
||||
? await testMailProfileSmtp(settings, selectedProfileId)
|
||||
: await testSmtpSettings(settings, rawSmtpPayload()));
|
||||
setSmtpTestResult(selectedProfileId && smtpCredentialsInherited ?
|
||||
await testMailProfileSmtp(settings, selectedProfileId) :
|
||||
await testSmtpSettings(settings, rawSmtpPayload()));
|
||||
} catch (err) {
|
||||
setSmtpTestResult({ ok: false, protocol: "smtp", message: err instanceof Error ? err.message : String(err), details: {} });
|
||||
} finally {
|
||||
@@ -343,16 +343,16 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
|
||||
async function runImapTest() {
|
||||
if (!mailModuleInstalled) {
|
||||
setImapTestResult({ ok: false, protocol: "imap", message: "Install and enable the Mail module to test IMAP settings.", details: {} });
|
||||
setImapTestResult({ ok: false, protocol: "imap", message: "i18n:govoplan-campaign.install_and_enable_the_mail_module_to_test_imap_.d6537dfd", details: {} });
|
||||
return;
|
||||
}
|
||||
if (imapDisabled) return;
|
||||
setMailActionState("imap");
|
||||
setLocalError("");
|
||||
try {
|
||||
setImapTestResult(selectedProfileId && imapCredentialsInherited
|
||||
? await testMailProfileImap(settings, selectedProfileId)
|
||||
: await testImapSettings(settings, rawImapPayload()));
|
||||
setImapTestResult(selectedProfileId && imapCredentialsInherited ?
|
||||
await testMailProfileImap(settings, selectedProfileId) :
|
||||
await testImapSettings(settings, rawImapPayload()));
|
||||
} catch (err) {
|
||||
setImapTestResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), details: {} });
|
||||
} finally {
|
||||
@@ -362,16 +362,16 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
|
||||
async function runFolderLookup() {
|
||||
if (!mailModuleInstalled) {
|
||||
setFolderResult({ ok: false, protocol: "imap", message: "Install and enable the Mail module to inspect IMAP folders.", folders: [], details: {} });
|
||||
setFolderResult({ ok: false, protocol: "imap", message: "i18n:govoplan-campaign.install_and_enable_the_mail_module_to_inspect_im.52535774", folders: [], details: {} });
|
||||
return;
|
||||
}
|
||||
if (appendTargetFolderDisabled) return;
|
||||
setMailActionState("folders");
|
||||
setLocalError("");
|
||||
try {
|
||||
setFolderResult(selectedProfileId && imapCredentialsInherited
|
||||
? await listMailProfileImapFolders(settings, selectedProfileId)
|
||||
: await listImapFolders(settings, rawImapPayload()));
|
||||
setFolderResult(selectedProfileId && imapCredentialsInherited ?
|
||||
await listMailProfileImapFolders(settings, selectedProfileId) :
|
||||
await listImapFolders(settings, rawImapPayload()));
|
||||
} catch (err) {
|
||||
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
|
||||
} finally {
|
||||
@@ -389,89 +389,89 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>{isPolicyView ? "Mail policy" : "Mail settings"}</PageTitle>
|
||||
<PageTitle loading={loading}>{isPolicyView ? "i18n:govoplan-campaign.mail_policy.3eb5d32a" : "i18n:govoplan-campaign.mail_settings.19e07f55"}</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
{!isPolicyView && <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft || inlineMailSettingsBlocked}>{dirty ? "Save now" : "Saved"}</Button>}
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
{!isPolicyView && <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft || inlineMailSettingsBlocked}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||
<>
|
||||
{!mailModuleInstalled && (
|
||||
<DismissibleAlert tone="info" dismissible={false}>
|
||||
The Mail module is not installed. Inline SMTP and IMAP values can be edited in the campaign draft, but reusable profiles, connection tests and sending require the Mail module.
|
||||
{!mailModuleInstalled &&
|
||||
<DismissibleAlert tone="info" dismissible={false}>
|
||||
i18n:govoplan-campaign.the_mail_module_is_not_installed_inline_smtp_and.8e0f802e
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
}
|
||||
|
||||
{isPolicyView && mailModuleInstalled && MailProfilePolicyEditor && (
|
||||
<MailProfilePolicyEditor
|
||||
settings={settings}
|
||||
scopeType="campaign"
|
||||
scopeId={campaignId}
|
||||
campaignId={campaignId}
|
||||
profiles={policyProfiles}
|
||||
ownerUserId={data.campaign?.owner_user_id}
|
||||
ownerGroupId={data.campaign?.owner_group_id}
|
||||
canWrite={!locked}
|
||||
locked={locked}
|
||||
title="Campaign-local mail policy"
|
||||
description="Campaign-local mail limits applied after system, tenant and owner policies."
|
||||
onSaved={refreshMailProfiles}
|
||||
/>
|
||||
)}
|
||||
{isPolicyView && mailModuleInstalled && MailProfilePolicyEditor &&
|
||||
<MailProfilePolicyEditor
|
||||
settings={settings}
|
||||
scopeType="campaign"
|
||||
scopeId={campaignId}
|
||||
campaignId={campaignId}
|
||||
profiles={policyProfiles}
|
||||
ownerUserId={data.campaign?.owner_user_id}
|
||||
ownerGroupId={data.campaign?.owner_group_id}
|
||||
canWrite={!locked}
|
||||
locked={locked}
|
||||
title="i18n:govoplan-campaign.campaign_local_mail_policy.94f59da0"
|
||||
description="i18n:govoplan-campaign.campaign_local_mail_limits_applied_after_system_.e7f9bb31"
|
||||
onSaved={refreshMailProfiles} />
|
||||
|
||||
{isPolicyView && mailModuleInstalled && !MailProfilePolicyEditor && (
|
||||
<DismissibleAlert tone="warning" dismissible={false}>The Mail module did not expose profile-management UI capabilities.</DismissibleAlert>
|
||||
)}
|
||||
}
|
||||
|
||||
{!isPolicyView && mailModuleInstalled && (
|
||||
{isPolicyView && mailModuleInstalled && !MailProfilePolicyEditor &&
|
||||
<DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_mail_module_did_not_expose_profile_managemen.2cdb57e1</DismissibleAlert>
|
||||
}
|
||||
|
||||
{!isPolicyView && mailModuleInstalled &&
|
||||
<Card
|
||||
title="Reusable mail profile"
|
||||
title="i18n:govoplan-campaign.reusable_mail_profile.f9c9aab1"
|
||||
actions={
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "Loading…" : "Reload profiles"}</Button>
|
||||
<Button variant="primary" onClick={() => void saveCurrentSettingsAsProfile()} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed}>{profilesLoading ? "Saving…" : "Save current settings as profile"}</Button>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "i18n:govoplan-campaign.loading.33ce4174" : "i18n:govoplan-campaign.reload_profiles.0fe100d1"}</Button>
|
||||
<Button variant="primary" onClick={() => void saveCurrentSettingsAsProfile()} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed}>{profilesLoading ? "i18n:govoplan-campaign.saving.56a2285c" : "i18n:govoplan-campaign.save_current_settings_as_profile.7578a50b"}</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
}>
|
||||
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<FormField label="Profile">
|
||||
<FormField label="i18n:govoplan-campaign.profile.ff4fc027">
|
||||
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
|
||||
<option value="" disabled={mailPolicyState.inlineOptionDisabled}>Inline SMTP/IMAP settings</option>
|
||||
<option value="" disabled={mailPolicyState.inlineOptionDisabled}>i18n:govoplan-campaign.inline_smtp_imap_settings.cf16c421</option>
|
||||
{mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="New profile name">
|
||||
<input value={profileName} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed} onChange={(event) => setProfileName(event.target.value)} placeholder={data.campaign?.name ? `${data.campaign.name} campaign-local profile` : "Campaign-local profile"} />
|
||||
<FormField label="i18n:govoplan-campaign.new_profile_name.393313b6">
|
||||
<input value={profileName} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed} onChange={(event) => setProfileName(event.target.value)} placeholder={data.campaign?.name ? i18nMessage("i18n:govoplan-campaign.value_campaign_local_profile.70cd9d43", { value0: data.campaign.name }) : "i18n:govoplan-campaign.campaign_local_profile.c24cf2d1"} />
|
||||
</FormField>
|
||||
</div>
|
||||
{!campaignProfilesAllowed && <p className="muted small-note">Campaign-local mail settings are blocked by the effective mail policy. Select an allowed reusable profile.</p>}
|
||||
{selectedProfile && (
|
||||
<p className="muted small-note">Using {selectedProfile.name} ({profileScopeLabel(selectedProfile)}). SMTP credentials: {smtpCredentialsInherited ? "inherited from profile" : "local credentials required"}. IMAP credentials: {selectedProfile.imap ? (imapCredentialsInherited ? "inherited from profile" : "local credentials required") : "not configured"}.</p>
|
||||
)}
|
||||
{selectedProfileNeedsLocalCredentials && (
|
||||
<p className="muted small-note">The selected profile supplies the server settings. Enter the required campaign-local credentials in the mail server settings panel below.</p>
|
||||
)}
|
||||
{!campaignProfilesAllowed && <p className="muted small-note">i18n:govoplan-campaign.campaign_local_mail_settings_are_blocked_by_the_.0b2510aa</p>}
|
||||
{selectedProfile &&
|
||||
<p className="muted small-note">i18n:govoplan-campaign.using.c25de2e8 {selectedProfile.name} ({profileScopeLabel(selectedProfile)}i18n:govoplan-campaign.smtp_credentials.10f75c8a {smtpCredentialsInherited ? "i18n:govoplan-campaign.inherited_from_profile.1947c2f3" : "i18n:govoplan-campaign.local_credentials_required.fdc9af1c"}i18n:govoplan-campaign.imap_credentials.7442b238 {selectedProfile.imap ? imapCredentialsInherited ? "i18n:govoplan-campaign.inherited_from_profile.1947c2f3" : "i18n:govoplan-campaign.local_credentials_required.fdc9af1c" : "i18n:govoplan-campaign.not_configured.67f2141f"}.</p>
|
||||
}
|
||||
{selectedProfileNeedsLocalCredentials &&
|
||||
<p className="muted small-note">i18n:govoplan-campaign.the_selected_profile_supplies_the_server_setting.bdc830db</p>
|
||||
}
|
||||
{profileMessage && <DismissibleAlert tone="success" resetKey={profileMessage} floating>{profileMessage}</DismissibleAlert>}
|
||||
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} dismissStorageKey={`campaign:${campaignId}:mail-settings:profile-error`} floating>{profileError}</DismissibleAlert>}
|
||||
</Card>
|
||||
)}
|
||||
}
|
||||
|
||||
{!isPolicyView && (
|
||||
<Card title="Mail server settings" collapsible>
|
||||
{inlinePolicyMessages.length > 0 && (
|
||||
<DismissibleAlert tone="warning" resetKey={inlinePolicyMessages.map((item) => `${item.key}:${item.value}`).join("|")} dismissible={false}>
|
||||
<strong>Effective mail policy blocks the current inline settings.</strong>
|
||||
{!isPolicyView &&
|
||||
<Card title="i18n:govoplan-campaign.mail_server_settings.6db620b0" collapsible>
|
||||
{inlinePolicyMessages.length > 0 &&
|
||||
<DismissibleAlert tone="warning" resetKey={inlinePolicyMessages.map((item) => `${item.key}:${item.value}`).join("|")} dismissible={false}>
|
||||
<strong>i18n:govoplan-campaign.effective_mail_policy_blocks_the_current_inline_.99c34c6b</strong>
|
||||
<ul>{inlinePolicyMessages.map((item) => <li key={`${item.key}:${item.value}`}>{item.message}</li>)}</ul>
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
}
|
||||
<MailServerSettingsPanel
|
||||
smtp={displayedSmtp}
|
||||
imap={displayedImap}
|
||||
@@ -497,8 +497,8 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
onEnabledChange: (checked) => patch(["delivery", "imap_append_sent", "enabled"], checked),
|
||||
onFolderChange: (folder) => patch(["delivery", "imap_append_sent", "folder"], folder)
|
||||
}}
|
||||
smtpTestLabel={usingMailProfile ? "Test profile SMTP" : "Test SMTP login"}
|
||||
imapTestLabel={usingMailProfile ? "Test profile IMAP" : "Test IMAP login"}
|
||||
smtpTestLabel={usingMailProfile ? "i18n:govoplan-campaign.test_profile_smtp.884a0e66" : "i18n:govoplan-campaign.test_smtp_login.a1359755"}
|
||||
imapTestLabel={usingMailProfile ? "i18n:govoplan-campaign.test_profile_imap.e1cec0e0" : "i18n:govoplan-campaign.test_imap_login.c32e316e"}
|
||||
busyAction={mailActionState}
|
||||
onTestSmtp={runSmtpTest}
|
||||
onTestImap={runImapTest}
|
||||
@@ -508,15 +508,15 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
folderLookupResult={folderResult}
|
||||
onUseDetectedFolder={useDetectedSentFolder}
|
||||
useDetectedFolderDisabled={appendTargetFolderDisabled}
|
||||
floatingResults
|
||||
/>
|
||||
floatingResults />
|
||||
|
||||
</Card>
|
||||
)}
|
||||
}
|
||||
|
||||
</>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,11 +19,11 @@ import { importedRowsNeedAttachmentSource, materializeRecipientImport, type Reci
|
||||
import { buildTemplatePreviewContext } from "./utils/templatePlaceholders";
|
||||
import { RecipientImportDialog } from "./RecipientDataPage";
|
||||
import { addressesFromValue } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
|
||||
|
||||
export default function RecipientDetailsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
export default function RecipientDetailsPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
@@ -40,8 +40,8 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
||||
reload,
|
||||
setError,
|
||||
currentStep: "recipient-data",
|
||||
unsavedTitle: "Unsaved recipient data changes",
|
||||
unsavedMessage: "Recipient field values or attachments have unsaved changes. Save them before leaving, or discard them and continue."
|
||||
unsavedTitle: "i18n:govoplan-campaign.unsaved_recipient_data_changes.c810507e",
|
||||
unsavedMessage: "i18n:govoplan-campaign.recipient_field_values_or_attachments_have_unsav.49ab1870"
|
||||
});
|
||||
const entries = asRecord(displayDraft.entries);
|
||||
const inlineEntries = asArray(entries.inline).map(asRecord);
|
||||
@@ -95,16 +95,16 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
||||
}
|
||||
|
||||
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath });
|
||||
const nextDraft = needsAttachmentSource
|
||||
? {
|
||||
...importedDraft,
|
||||
attachments: {
|
||||
...asRecord(importedDraft.attachments),
|
||||
base_paths: nextBasePaths,
|
||||
base_path: nextBasePaths[0]?.path || "."
|
||||
}
|
||||
const nextDraft = needsAttachmentSource ?
|
||||
{
|
||||
...importedDraft,
|
||||
attachments: {
|
||||
...asRecord(importedDraft.attachments),
|
||||
base_paths: nextBasePaths,
|
||||
base_path: nextBasePaths[0]?.path || "."
|
||||
}
|
||||
: importedDraft;
|
||||
} :
|
||||
importedDraft;
|
||||
setDraft(nextDraft);
|
||||
markDirty();
|
||||
setImportOpen(false);
|
||||
@@ -115,65 +115,65 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Recipient data</PageTitle>
|
||||
<PageTitle loading={loading}>i18n:govoplan-campaign.recipient_data.c2baaf10</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||
<>
|
||||
<Card title="Recipient field values and attachments" actions={<Button disabled={locked} onClick={() => setImportOpen(true)}>Import</Button>}>
|
||||
{inlineEntries.length === 0 && !source.type && <p className="muted">No recipient profiles are stored in the current version yet. Add recipients first, then maintain their data here.</p>}
|
||||
{inlineEntries.length === 0 && Boolean(source.type) && (
|
||||
<DismissibleAlert tone="info">This campaign references an external recipient source. A parsed data preview will be added when file/source preview support is implemented.</DismissibleAlert>
|
||||
)}
|
||||
{inlineEntries.length > 0 && (
|
||||
<div className="admin-table-surface recipient-data-table-surface">
|
||||
<Card title="i18n:govoplan-campaign.recipient_field_values_and_attachments.96e670ca" actions={<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>}>
|
||||
{inlineEntries.length === 0 && !source.type && <p className="muted">i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.e9f9a224</p>}
|
||||
{inlineEntries.length === 0 && Boolean(source.type) &&
|
||||
<DismissibleAlert tone="info">i18n:govoplan-campaign.this_campaign_references_an_external_recipient_s.0d1ae252</DismissibleAlert>
|
||||
}
|
||||
{inlineEntries.length > 0 &&
|
||||
<div className="admin-table-surface recipient-data-table-surface">
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-recipient-data`}
|
||||
rows={inlineEntries}
|
||||
columns={recipientDataColumns({ settings, campaignId, draft: displayDraft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField })}
|
||||
getRowKey={(entry, index) => String(entry.id || index)}
|
||||
emptyText="No recipient data found."
|
||||
className="recipient-table-wrap recipient-data-table-wrap recipient-data-table"
|
||||
pagination={{
|
||||
page: recipientDataPage,
|
||||
pageSize: recipientDataPageSize,
|
||||
pageSizeOptions: [10, 25, 50, 100, 250],
|
||||
onPageChange: setRecipientDataPage,
|
||||
onPageSizeChange: (pageSize) => {
|
||||
setRecipientDataPageSize(pageSize);
|
||||
setRecipientDataPage(1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
id={`campaign-${campaignId}-recipient-data`}
|
||||
rows={inlineEntries}
|
||||
columns={recipientDataColumns({ settings, campaignId, draft: displayDraft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField })}
|
||||
getRowKey={(entry, index) => String(entry.id || index)}
|
||||
emptyText="i18n:govoplan-campaign.no_recipient_data_found.a12be7d0"
|
||||
className="recipient-table-wrap recipient-data-table-wrap recipient-data-table"
|
||||
pagination={{
|
||||
page: recipientDataPage,
|
||||
pageSize: recipientDataPageSize,
|
||||
pageSizeOptions: [10, 25, 50, 100, 250],
|
||||
onPageChange: setRecipientDataPage,
|
||||
onPageSizeChange: (pageSize) => {
|
||||
setRecipientDataPageSize(pageSize);
|
||||
setRecipientDataPage(1);
|
||||
}
|
||||
}} />
|
||||
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
</Card>
|
||||
</>
|
||||
</LoadingFrame>
|
||||
|
||||
{importOpen && (
|
||||
<RecipientImportDialog
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
existingEntries={inlineEntries}
|
||||
existingFields={fieldDefinitions}
|
||||
defaultAttachmentBasePath={defaultImportBasePath}
|
||||
onCancel={() => setImportOpen(false)}
|
||||
onImport={applyRecipientImport}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{importOpen &&
|
||||
<RecipientImportDialog
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
existingEntries={inlineEntries}
|
||||
existingFields={fieldDefinitions}
|
||||
defaultAttachmentBasePath={defaultImportBasePath}
|
||||
onCancel={() => setImportOpen(false)}
|
||||
onImport={applyRecipientImport} />
|
||||
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
type RecipientDataColumnContext = {
|
||||
@@ -191,72 +191,72 @@ type RecipientDataColumnContext = {
|
||||
|
||||
function recipientDataColumns({ settings, campaignId, draft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
||||
return [
|
||||
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small recipient-index-cell">{index + 1}</span>, value: (_entry, index) => index + 1 },
|
||||
{
|
||||
id: "recipient",
|
||||
header: "Recipient",
|
||||
width: 260,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
sticky: "start",
|
||||
render: (entry) => (
|
||||
<Link className="recipient-data-identity" to="../recipients" title="Open recipient address profile">
|
||||
<span className="recipient-data-address">{firstRecipientEmail(entry) || "No To address"}</span>
|
||||
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small recipient-index-cell">{index + 1}</span>, value: (_entry, index) => index + 1 },
|
||||
{
|
||||
id: "recipient",
|
||||
header: "i18n:govoplan-campaign.recipient.90343260",
|
||||
width: 260,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
sticky: "start",
|
||||
render: (entry) =>
|
||||
<Link className="recipient-data-identity" to="../recipients" title="i18n:govoplan-campaign.open_recipient_address_profile.f7daa676">
|
||||
<span className="recipient-data-address">{firstRecipientEmail(entry) || "i18n:govoplan-campaign.no_to_address.683350f9"}</span>
|
||||
{extraRecipientCount(entry) > 0 && <span className="recipient-extra-bubble">+{extraRecipientCount(entry)}</span>}
|
||||
</Link>
|
||||
),
|
||||
value: firstRecipientEmail
|
||||
</Link>,
|
||||
|
||||
value: firstRecipientEmail
|
||||
},
|
||||
{
|
||||
id: "attachments",
|
||||
header: "i18n:govoplan-campaign.attachments.6771ade6",
|
||||
width: 180,
|
||||
filterable: true,
|
||||
render: (entry, index) => {
|
||||
const attachments = normalizeAttachmentRules(entry.attachments);
|
||||
return (
|
||||
<AttachmentRulesOverlay
|
||||
title={i18nMessage("i18n:govoplan-campaign.attachments_for_recipient_value.9a8df82d", { value0: index + 1 })}
|
||||
rules={attachments}
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
disabled={locked}
|
||||
buttonLabel={`entries: ${attachments.length}`}
|
||||
basePaths={individualAttachmentBasePaths}
|
||||
zipConfig={zipConfig}
|
||||
filesModuleInstalled={filesModuleInstalled}
|
||||
previewContext={buildTemplatePreviewContext(draft, entry)}
|
||||
onChange={(rules) => updateEntryAttachments(index, rules)} />);
|
||||
|
||||
|
||||
},
|
||||
{
|
||||
id: "attachments",
|
||||
header: "Attachments",
|
||||
width: 180,
|
||||
filterable: true,
|
||||
render: (entry, index) => {
|
||||
const attachments = normalizeAttachmentRules(entry.attachments);
|
||||
return (
|
||||
<AttachmentRulesOverlay
|
||||
title={`Attachments for recipient ${index + 1}`}
|
||||
rules={attachments}
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
disabled={locked}
|
||||
buttonLabel={`entries: ${attachments.length}`}
|
||||
basePaths={individualAttachmentBasePaths}
|
||||
zipConfig={zipConfig}
|
||||
filesModuleInstalled={filesModuleInstalled}
|
||||
previewContext={buildTemplatePreviewContext(draft, entry)}
|
||||
onChange={(rules) => updateEntryAttachments(index, rules)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
value: (entry) => normalizeAttachmentRules(entry.attachments).map((rule) => `${rule.label ?? ""} ${rule.file_filter ?? ""}`).join(", ")
|
||||
value: (entry) => normalizeAttachmentRules(entry.attachments).map((rule) => `${rule.label ?? ""} ${rule.file_filter ?? ""}`).join(", ")
|
||||
},
|
||||
...fieldDefinitions.map((field): DataGridColumn<Record<string, unknown>> => ({
|
||||
id: `field-${field.name}`,
|
||||
header: field.label || field.name,
|
||||
width: 190,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterType: field.type === "integer" ? "integer" : field.type === "double" ? "number" : field.type === "date" ? "date" : "text",
|
||||
render: (entry, index) => {
|
||||
const fields = asRecord(entry.fields);
|
||||
return (
|
||||
<FieldValueInput
|
||||
className="recipient-field-input"
|
||||
fieldType={field.type}
|
||||
value={fields[field.name]}
|
||||
disabled={locked || field.can_override === false}
|
||||
placeholder={field.can_override === false ? "i18n:govoplan-campaign.uses_global_value.98eae5e0" : undefined}
|
||||
onChange={(value) => updateEntryField(index, field.name, value)} />);
|
||||
|
||||
|
||||
},
|
||||
...fieldDefinitions.map((field): DataGridColumn<Record<string, unknown>> => ({
|
||||
id: `field-${field.name}`,
|
||||
header: field.label || field.name,
|
||||
width: 190,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterType: field.type === "integer" ? "integer" : field.type === "double" ? "number" : field.type === "date" ? "date" : "text",
|
||||
render: (entry, index) => {
|
||||
const fields = asRecord(entry.fields);
|
||||
return (
|
||||
<FieldValueInput
|
||||
className="recipient-field-input"
|
||||
fieldType={field.type}
|
||||
value={fields[field.name]}
|
||||
disabled={locked || field.can_override === false}
|
||||
placeholder={field.can_override === false ? "Uses global value" : undefined}
|
||||
onChange={(value) => updateEntryField(index, field.name, value)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
value: (entry) => String(asRecord(entry.fields)[field.name] ?? "")
|
||||
}))
|
||||
];
|
||||
value: (entry) => String(asRecord(entry.fields)[field.name] ?? "")
|
||||
}))];
|
||||
|
||||
}
|
||||
|
||||
function firstRecipientEmail(entry: Record<string, unknown>): string {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import { Card } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert, SegmentedControl, i18nMessage } from "@govoplan/core-webui";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
||||
@@ -23,7 +23,7 @@ type TemplateBodyMode = "text" | "html" | "both";
|
||||
type BodyEditorMode = "text" | "html";
|
||||
type EditorTarget = "subject" | "text" | "html";
|
||||
|
||||
export default function TemplateDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
export default function TemplateDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [activeBodyEditor, setActiveBodyEditor] = useState<BodyEditorMode>("text");
|
||||
const [activeEditor, setActiveEditor] = useState<EditorTarget>("text");
|
||||
@@ -47,9 +47,9 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
reload,
|
||||
setError,
|
||||
currentStep: "template",
|
||||
unsavedTitle: "Unsaved template changes",
|
||||
unsavedMessage: "The template has unsaved changes. Save them before leaving, or discard them and continue.",
|
||||
loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${formatDateTime(loadedVersion.autosaved_at)}` : "Loaded",
|
||||
unsavedTitle: "i18n:govoplan-campaign.unsaved_template_changes.4209cdec",
|
||||
unsavedMessage: "i18n:govoplan-campaign.the_template_has_unsaved_changes_save_them_befor.89f74fc1",
|
||||
loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${formatDateTime(loadedVersion.autosaved_at)}` : "i18n:govoplan-campaign.loaded.6db90a0a",
|
||||
onLoaded: () => setPreviewIndex(0)
|
||||
});
|
||||
const template = asRecord(displayDraft.template);
|
||||
@@ -78,10 +78,10 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
const previewEntry = previewSelection.entry;
|
||||
const ignoreEmptyFields = getBool(asRecord(displayDraft.validation_policy), "ignore_empty_fields", false);
|
||||
const templateText = [
|
||||
getText(template, "subject"),
|
||||
templateBodyMode !== "html" ? getText(template, "text") : "",
|
||||
templateBodyMode !== "text" ? getText(template, "html") : ""
|
||||
].join("\n");
|
||||
getText(template, "subject"),
|
||||
templateBodyMode !== "html" ? getText(template, "text") : "",
|
||||
templateBodyMode !== "text" ? getText(template, "html") : ""].
|
||||
join("\n");
|
||||
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]);
|
||||
const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]);
|
||||
const undefinedPlaceholders = useMemo(
|
||||
@@ -122,19 +122,19 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
void previewCampaignAttachments(settings, campaignId, version.id, {
|
||||
include_unmatched: false,
|
||||
campaign_json: campaignJsonForAttachmentPreview(displayDraft)
|
||||
})
|
||||
.then((response) => {
|
||||
if (!cancelled) setAttachmentPreviewRules(response.rules);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!cancelled) {
|
||||
setAttachmentPreviewRules([]);
|
||||
setAttachmentPreviewError(reason instanceof Error ? reason.message : String(reason));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setAttachmentPreviewLoading(false);
|
||||
});
|
||||
}).
|
||||
then((response) => {
|
||||
if (!cancelled) setAttachmentPreviewRules(response.rules);
|
||||
}).
|
||||
catch((reason: unknown) => {
|
||||
if (!cancelled) {
|
||||
setAttachmentPreviewRules([]);
|
||||
setAttachmentPreviewError(reason instanceof Error ? reason.message : String(reason));
|
||||
}
|
||||
}).
|
||||
finally(() => {
|
||||
if (!cancelled) setAttachmentPreviewLoading(false);
|
||||
});
|
||||
}, 120);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -179,15 +179,15 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
const alreadyDefined = existingFields.some((item) => String(item.name || item.id || "") === field.name);
|
||||
if (!alreadyDefined) {
|
||||
patch(["fields"], [
|
||||
...existingFields,
|
||||
{
|
||||
name: field.name,
|
||||
label: humanizeFieldName(field.name),
|
||||
type: "string",
|
||||
required: false,
|
||||
can_override: true
|
||||
}
|
||||
]);
|
||||
...existingFields,
|
||||
{
|
||||
name: field.name,
|
||||
label: humanizeFieldName(field.name),
|
||||
type: "string",
|
||||
required: false,
|
||||
can_override: true
|
||||
}]
|
||||
);
|
||||
}
|
||||
setUndefinedDialog(null);
|
||||
}
|
||||
@@ -228,106 +228,123 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Template</PageTitle>
|
||||
<PageTitle loading={loading}>i18n:govoplan-campaign.template.3ec1ae06</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>Manage templates</Button>
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
|
||||
<Button disabled>i18n:govoplan-campaign.manage_templates.23688071</Button>
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||
<>
|
||||
<div className="dashboard-grid template-editor-grid">
|
||||
<Card title="Editable template" actions={<Button onClick={() => setPreviewOpen(true)}>Preview</Button>}>
|
||||
<Card title="i18n:govoplan-campaign.editable_template.5e747a1e" actions={<Button onClick={() => setPreviewOpen(true)}>i18n:govoplan-campaign.preview.f1fbb2b4</Button>}>
|
||||
<div className="form-grid">
|
||||
<FormField label="Subject">
|
||||
<FormField label="i18n:govoplan-campaign.subject.8d183dbd">
|
||||
<input
|
||||
ref={subjectRef}
|
||||
value={getText(template, "subject")}
|
||||
disabled={locked}
|
||||
onFocus={() => setActiveEditor("subject")}
|
||||
onChange={(event) => patchTemplateText("subject", event.target.value)}
|
||||
onChange={(event) => patchTemplateText("subject", event.target.value)} />
|
||||
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-campaign.message_body_format.5fec42d2">
|
||||
<SegmentedControl
|
||||
className="template-body-mode"
|
||||
size="content"
|
||||
width="inline"
|
||||
ariaLabel="i18n:govoplan-campaign.message_body_format.5fec42d2"
|
||||
value={templateBodyMode}
|
||||
disabled={locked}
|
||||
onChange={patchTemplateBodyMode}
|
||||
options={[
|
||||
{ id: "text", label: "i18n:govoplan-campaign.text_only.9ccbd022" },
|
||||
{ id: "html", label: "i18n:govoplan-campaign.html_only.1c4fbcb1" },
|
||||
{ id: "both", label: "i18n:govoplan-campaign.both.1f469838" }
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Message body format">
|
||||
<div className="template-body-mode" role="tablist" aria-label="Message body format">
|
||||
<button type="button" className={templateBodyMode === "text" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("text")}>Text only</button>
|
||||
<button type="button" className={templateBodyMode === "html" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("html")}>HTML only</button>
|
||||
<button type="button" className={templateBodyMode === "both" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("both")}>Both</button>
|
||||
</div>
|
||||
</FormField>
|
||||
{templateBodyMode === "both" && (
|
||||
<div className="template-body-mode template-editor-mode" role="tablist" aria-label="Body editor">
|
||||
<button type="button" className={visibleBodyEditor === "text" ? "active" : ""} onClick={() => { setActiveBodyEditor("text"); setActiveEditor("text"); }}>Plain text</button>
|
||||
<button type="button" className={visibleBodyEditor === "html" ? "active" : ""} onClick={() => { setActiveBodyEditor("html"); setActiveEditor("html"); }}>HTML</button>
|
||||
</div>
|
||||
)}
|
||||
{visibleBodyEditor === "text" && (
|
||||
<FormField label="Plain text body">
|
||||
{templateBodyMode === "both" &&
|
||||
<SegmentedControl
|
||||
className="template-body-mode template-editor-mode"
|
||||
size="content"
|
||||
width="inline"
|
||||
ariaLabel="i18n:govoplan-campaign.body_editor.8615dd7e"
|
||||
value={visibleBodyEditor}
|
||||
onChange={(mode) => {setActiveBodyEditor(mode);setActiveEditor(mode);}}
|
||||
options={[
|
||||
{ id: "text", label: "i18n:govoplan-campaign.plain_text.9580fcbc" },
|
||||
{ id: "html", label: "i18n:govoplan-campaign.html.9f738ce8" }
|
||||
]}
|
||||
/>
|
||||
}
|
||||
{visibleBodyEditor === "text" &&
|
||||
<FormField label="i18n:govoplan-campaign.plain_text_body.030a0da0">
|
||||
<textarea
|
||||
ref={textRef}
|
||||
rows={16}
|
||||
value={getText(template, "text")}
|
||||
disabled={locked}
|
||||
onFocus={() => { setActiveBodyEditor("text"); setActiveEditor("text"); }}
|
||||
onChange={(event) => patchTemplateText("text", event.target.value)}
|
||||
/>
|
||||
ref={textRef}
|
||||
rows={16}
|
||||
value={getText(template, "text")}
|
||||
disabled={locked}
|
||||
onFocus={() => {setActiveBodyEditor("text");setActiveEditor("text");}}
|
||||
onChange={(event) => patchTemplateText("text", event.target.value)} />
|
||||
|
||||
</FormField>
|
||||
)}
|
||||
{visibleBodyEditor === "html" && (
|
||||
<FormField label="HTML body">
|
||||
}
|
||||
{visibleBodyEditor === "html" &&
|
||||
<FormField label="i18n:govoplan-campaign.html_body.77b5ba37">
|
||||
<textarea
|
||||
ref={htmlRef}
|
||||
rows={16}
|
||||
value={getText(template, "html")}
|
||||
disabled={locked}
|
||||
onFocus={() => { setActiveBodyEditor("html"); setActiveEditor("html"); }}
|
||||
onChange={(event) => patchTemplateText("html", event.target.value)}
|
||||
/>
|
||||
ref={htmlRef}
|
||||
rows={16}
|
||||
value={getText(template, "html")}
|
||||
disabled={locked}
|
||||
onFocus={() => {setActiveBodyEditor("html");setActiveEditor("html");}}
|
||||
onChange={(event) => patchTemplateText("html", event.target.value)} />
|
||||
|
||||
</FormField>
|
||||
)}
|
||||
}
|
||||
<div className="button-row template-editor-actions">
|
||||
<Button disabled>Load from library</Button>
|
||||
<Button disabled>Save to library</Button>
|
||||
<Button disabled>i18n:govoplan-campaign.load_from_library.327ada7c</Button>
|
||||
<Button disabled>i18n:govoplan-campaign.save_to_library.396649bf</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="template-side-stack">
|
||||
<Card title="Fields">
|
||||
{invalidNamespacePlaceholders.length > 0 && (
|
||||
<DismissibleAlert tone="warning" resetKey={invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(",")}>Undefined placeholder namespace detected: {invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(", ")}.</DismissibleAlert>
|
||||
)}
|
||||
{usedPlaceholders.length === 0 && <p className="muted">No template placeholders detected yet.</p>}
|
||||
<p className="muted small-note">Click a field to insert it at the current cursor position as a namespaced placeholder.</p>
|
||||
<Card title="i18n:govoplan-campaign.fields.e8b68527">
|
||||
{invalidNamespacePlaceholders.length > 0 &&
|
||||
<DismissibleAlert tone="warning" resetKey={invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(",")}>i18n:govoplan-campaign.undefined_placeholder_namespace_detected.2ef5c282 {invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(", ")}.</DismissibleAlert>
|
||||
}
|
||||
{usedPlaceholders.length === 0 && <p className="muted">i18n:govoplan-campaign.no_template_placeholders_detected_yet.56bba9d1</p>}
|
||||
<p className="muted small-note">i18n:govoplan-campaign.click_a_field_to_insert_it_at_the_current_cursor.643aa7bc</p>
|
||||
|
||||
<h3 className="section-mini-heading">Global fields</h3>
|
||||
<h3 className="section-mini-heading">i18n:govoplan-campaign.global_fields.07f84ea4</h3>
|
||||
<TemplateFieldChipList
|
||||
namespace="global"
|
||||
fields={globalFieldOptions}
|
||||
usedPlaceholders={usedPlaceholders}
|
||||
empty="No campaign fields or global values defined."
|
||||
onInsert={insertPlaceholder}
|
||||
/>
|
||||
empty="i18n:govoplan-campaign.no_campaign_fields_or_global_values_defined.3888623d"
|
||||
onInsert={insertPlaceholder} />
|
||||
|
||||
|
||||
<h3 className="section-mini-heading">Local fields</h3>
|
||||
<p className="muted small-note">Address fields use the effective merged or overridden headers. Use <code>to[2]</code> or <code>to[2].email</code> for a specific additional address.</p>
|
||||
<h3 className="section-mini-heading">i18n:govoplan-campaign.local_fields.c81bb4de</h3>
|
||||
<p className="muted small-note">i18n:govoplan-campaign.address_fields_use_the_effective_merged_or_overr.d6930da6 <code>i18n:govoplan-campaign.to_2.8c1db0c7</code> or <code>i18n:govoplan-campaign.to_2_email.5ad61c1a</code> i18n:govoplan-campaign.for_a_specific_additional_address.1199883f</p>
|
||||
<TemplateFieldChipList
|
||||
namespace="local"
|
||||
fields={localFieldOptions}
|
||||
usedPlaceholders={usedPlaceholders}
|
||||
empty="No campaign fields defined."
|
||||
onInsert={insertPlaceholder}
|
||||
/>
|
||||
empty="i18n:govoplan-campaign.no_campaign_fields_defined.8c2ea4b2"
|
||||
onInsert={insertPlaceholder} />
|
||||
|
||||
|
||||
<h3 className="section-mini-heading">Used in template, but undefined</h3>
|
||||
<h3 className="section-mini-heading">i18n:govoplan-campaign.used_in_template_but_undefined.57b5f3da</h3>
|
||||
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
|
||||
</Card>
|
||||
</div>
|
||||
@@ -336,84 +353,89 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
</>
|
||||
</LoadingFrame>
|
||||
|
||||
{previewOpen && (
|
||||
<CampaignMessagePreviewOverlay
|
||||
title="Template preview"
|
||||
bodyMode={visibleBodyEditor}
|
||||
subject={previewSubject}
|
||||
text={templateBodyMode === "html" ? null : previewText}
|
||||
html={templateBodyMode === "text" ? null : previewHtml}
|
||||
metaItems={templatePreviewMetaItems(previewContext)}
|
||||
recipientLabel={activePreviewEntries.length > 0 ? recipientLabel(previewEntry, previewSelection.sourceIndex - 1) : "Global preview"}
|
||||
recipientNote={activePreviewEntries.length > 0 ? `${Math.min(previewIndex, previewEntries.length - 1) + 1} of ${previewEntries.length}` : inlineEntries.length > 0 ? "No recipient preview is available." : "No inline recipients are available yet."}
|
||||
attachments={previewAttachments}
|
||||
navigation={{
|
||||
index: Math.min(previewIndex, previewEntries.length - 1),
|
||||
total: previewEntries.length,
|
||||
onFirst: () => setPreviewIndex(0),
|
||||
onPrevious: () => setPreviewIndex((value) => Math.max(0, value - 1)),
|
||||
onNext: () => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 1)),
|
||||
onLast: () => setPreviewIndex(previewEntries.length - 1)
|
||||
}}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{previewOpen &&
|
||||
<CampaignMessagePreviewOverlay
|
||||
title="i18n:govoplan-campaign.template_preview.ea0aa8b2"
|
||||
bodyMode={visibleBodyEditor}
|
||||
subject={previewSubject}
|
||||
text={templateBodyMode === "html" ? null : previewText}
|
||||
html={templateBodyMode === "text" ? null : previewHtml}
|
||||
metaItems={templatePreviewMetaItems(previewContext)}
|
||||
recipientLabel={activePreviewEntries.length > 0 ? recipientLabel(previewEntry, previewSelection.sourceIndex - 1) : "i18n:govoplan-campaign.global_preview.f81f09b2"}
|
||||
recipientNote={activePreviewEntries.length > 0 ? `${Math.min(previewIndex, previewEntries.length - 1) + 1} of ${previewEntries.length}` : inlineEntries.length > 0 ? "i18n:govoplan-campaign.no_recipient_preview_is_available.9e93a97d" : "i18n:govoplan-campaign.no_inline_recipients_are_available_yet.e405bacb"}
|
||||
attachments={previewAttachments}
|
||||
navigation={{
|
||||
index: Math.min(previewIndex, previewEntries.length - 1),
|
||||
total: previewEntries.length,
|
||||
onFirst: () => setPreviewIndex(0),
|
||||
onPrevious: () => setPreviewIndex((value) => Math.max(0, value - 1)),
|
||||
onNext: () => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 1)),
|
||||
onLast: () => setPreviewIndex(previewEntries.length - 1)
|
||||
}}
|
||||
onClose={() => setPreviewOpen(false)} />
|
||||
|
||||
}
|
||||
|
||||
<UndefinedPlaceholderDecisionDialog
|
||||
field={undefinedDialog}
|
||||
contextLabel="template"
|
||||
removeLabel="Remove from template"
|
||||
removeLabel="i18n:govoplan-campaign.remove_from_template.5fb2827b"
|
||||
onCancel={() => setUndefinedDialog(null)}
|
||||
onRemove={removePlaceholder}
|
||||
onReplace={replacePlaceholder}
|
||||
onAddField={addUndefinedField}
|
||||
localFields={localFieldOptions}
|
||||
globalFields={globalFieldOptions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
globalFields={globalFieldOptions} />
|
||||
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function mapResolvedAttachmentsToPreviewBoxes(
|
||||
rules: CampaignAttachmentPreviewRule[],
|
||||
loading: boolean,
|
||||
error: string,
|
||||
draft: Record<string, unknown>
|
||||
): CampaignMessagePreviewAttachment[] {
|
||||
rules: CampaignAttachmentPreviewRule[],
|
||||
loading: boolean,
|
||||
error: string,
|
||||
draft: Record<string, unknown>)
|
||||
: CampaignMessagePreviewAttachment[] {
|
||||
if (loading) {
|
||||
return [{ filename: "Resolving attachment patterns…", detail: "Managed files are being checked for this recipient." }];
|
||||
return [{
|
||||
filename: i18nMessage("i18n:govoplan-campaign.resolving_attachment_patterns.87d7d21b"),
|
||||
detail: i18nMessage("i18n:govoplan-campaign.managed_files_are_being_checked_for_this_recipie.489ecefd")
|
||||
}];
|
||||
}
|
||||
if (error) {
|
||||
return [{ filename: "Attachment preview unavailable", detail: error }];
|
||||
return [{ filename: i18nMessage("i18n:govoplan-campaign.attachment_preview_unavailable.62211756"), detail: error }];
|
||||
}
|
||||
return rules.flatMap((rule) => {
|
||||
const zipProtection = zipProtectionForRule(rule, draft);
|
||||
const detailParts = [
|
||||
rule.source === "global" ? "Global" : "Recipient",
|
||||
rule.label,
|
||||
rule.required ? "required" : "optional",
|
||||
rule.pattern
|
||||
].filter(Boolean);
|
||||
rule.source === "global" ? i18nMessage("i18n:govoplan-campaign.global.5f1184f7") : i18nMessage("i18n:govoplan-campaign.recipient.90343260"),
|
||||
rule.label,
|
||||
rule.required ? i18nMessage("i18n:govoplan-campaign.required.eed6bfb4") : i18nMessage("i18n:govoplan-campaign.optional.0c6c4102"),
|
||||
rule.pattern].
|
||||
filter(Boolean);
|
||||
const detail = detailParts.join(" · ");
|
||||
const fallbackArchiveLabel = i18nMessage("i18n:govoplan-campaign.recipient_attachments_zip.79d436c7");
|
||||
if (rule.matches.length > 0) {
|
||||
return rule.matches.map((match) => ({
|
||||
filename: match.filename || match.display_path,
|
||||
label: rule.label,
|
||||
detail: `${detail}${match.display_path ? ` · ${match.display_path}` : ""}`,
|
||||
detail: i18nMessage("i18n:govoplan-campaign.value_value.a8618e4a", { value0: detail, value1: match.display_path ? ` · ${match.display_path}` : "" }),
|
||||
contentType: match.content_type,
|
||||
sizeBytes: match.size_bytes,
|
||||
archiveGroup: rule.zip_included ? (rule.zip_filename || "recipient-attachments.zip") : null,
|
||||
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null,
|
||||
archiveGroup: rule.zip_included ? rule.zip_filename || "recipient-attachments.zip" : null,
|
||||
archiveLabel: rule.zip_included ? rule.zip_filename || fallbackArchiveLabel : null,
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note
|
||||
}));
|
||||
}
|
||||
const pattern = rule.pattern || i18nMessage("i18n:govoplan-campaign.attachment_pattern.3540c952");
|
||||
return [{
|
||||
filename: `No file matched ${rule.pattern || "attachment pattern"}`,
|
||||
filename: i18nMessage("i18n:govoplan-campaign.no_file_matched_value", { value0: pattern }),
|
||||
label: rule.label,
|
||||
detail: `${detail}${rule.status !== "ok" ? ` · ${rule.status}` : ""}`,
|
||||
archiveGroup: rule.zip_included ? (rule.zip_filename || "recipient-attachments.zip") : null,
|
||||
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null,
|
||||
detail: i18nMessage("i18n:govoplan-campaign.value_value.a8618e4a", { value0: detail, value1: rule.status !== "ok" ? ` · ${rule.status}` : "" }),
|
||||
archiveGroup: rule.zip_included ? rule.zip_filename || "recipient-attachments.zip" : null,
|
||||
archiveLabel: rule.zip_included ? rule.zip_filename || fallbackArchiveLabel : null,
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note
|
||||
}];
|
||||
@@ -422,22 +444,22 @@ function mapResolvedAttachmentsToPreviewBoxes(
|
||||
|
||||
function templatePreviewMetaItems(context: Record<string, string>) {
|
||||
return [
|
||||
{ label: "From", value: context["local:from"] || null },
|
||||
{ label: "To", value: context["local:all_to"] || null },
|
||||
{ label: "Reply-To", value: context["local:all_reply_to"] || null },
|
||||
{ label: "Cc", value: context["local:all_cc"] || null },
|
||||
{ label: "Bcc", value: context["local:all_bcc"] || null }
|
||||
];
|
||||
{ label: "i18n:govoplan-campaign.from.3f66052a", value: context["local:from"] || null },
|
||||
{ label: "i18n:govoplan-campaign.to.ae79ea1e", value: context["local:all_to"] || null },
|
||||
{ label: "i18n:govoplan-campaign.reply_to.c1733667", value: context["local:all_reply_to"] || null },
|
||||
{ label: "i18n:govoplan-campaign.cc.1fd6a880", value: context["local:all_cc"] || null },
|
||||
{ label: "i18n:govoplan-campaign.bcc.8431acad", value: context["local:all_bcc"] || null }];
|
||||
|
||||
}
|
||||
|
||||
function zipProtectionForRule(rule: CampaignAttachmentPreviewRule, draft: Record<string, unknown>): { protected: boolean; note: string | null } {
|
||||
function zipProtectionForRule(rule: CampaignAttachmentPreviewRule, draft: Record<string, unknown>): {protected: boolean;note: string | null;} {
|
||||
if (!rule.zip_included) return { protected: false, note: null };
|
||||
const zipConfig = asRecord(asRecord(draft.attachments).zip);
|
||||
const archives = asArray(zipConfig.archives).map(asRecord);
|
||||
const archive = archives.find((item) => {
|
||||
const id = getText(item, "id");
|
||||
const name = getText(item, "name", getText(item, "filename_template"));
|
||||
return (rule.zip_archive_id && id === rule.zip_archive_id) || (rule.zip_filename && name === rule.zip_filename);
|
||||
return rule.zip_archive_id && id === rule.zip_archive_id || rule.zip_filename && name === rule.zip_filename;
|
||||
}) ?? archives.find((item) => getBool(item, "standard")) ?? archives[0];
|
||||
if (!archive) return { protected: false, note: null };
|
||||
const legacyMode = getText(archive, "password_mode");
|
||||
@@ -445,13 +467,18 @@ function zipProtectionForRule(rule: CampaignAttachmentPreviewRule, draft: Record
|
||||
if (!protectedArchive) return { protected: false, note: null };
|
||||
const field = getText(archive, "password_field");
|
||||
const scope = getText(archive, "password_scope") === "global" ? "global" : "local";
|
||||
const method = getText(archive, "method", "aes") === "zip_standard" ? "ZipCrypto" : "AES";
|
||||
const source = field ? `${humanizeScope(scope)} field "${field}"` : "";
|
||||
return { protected: true, note: [source, `Encryption: ${method}`].filter(Boolean).join(" · ") };
|
||||
const method = getText(archive, "method", "aes") === "zip_standard" ? "i18n:govoplan-campaign.zipcrypto.03bf7fb4" : "i18n:govoplan-campaign.aes.41f215a6";
|
||||
const source = field ? i18nMessage("i18n:govoplan-campaign.scope_field_value", { value0: humanizeScope(scope), value1: field }) : "";
|
||||
return {
|
||||
protected: true,
|
||||
note: source ?
|
||||
i18nMessage("i18n:govoplan-campaign.value_encryption_value", { value0: source, value1: method }) :
|
||||
i18nMessage("i18n:govoplan-campaign.encryption_value", { value0: method })
|
||||
};
|
||||
}
|
||||
|
||||
function humanizeScope(scope: string): string {
|
||||
return scope === "global" ? "Global" : "Local";
|
||||
return scope === "global" ? "i18n:govoplan-campaign.global.5f1184f7" : "i18n:govoplan-campaign.local.dc99d54d";
|
||||
}
|
||||
|
||||
function recipientLabel(entry: Record<string, unknown>, index: number): string {
|
||||
@@ -460,7 +487,7 @@ function recipientLabel(entry: Record<string, unknown>, index: number): string {
|
||||
if (name && email) return `${name} <${email}>`;
|
||||
if (name) return name;
|
||||
if (email) return email;
|
||||
return `Recipient ${index + 1}`;
|
||||
return i18nMessage("i18n:govoplan-campaign.recipient_value.d0233fb6", { value0: index + 1 });
|
||||
}
|
||||
|
||||
function normalizeTemplateBodyMode(value: string): TemplateBodyMode {
|
||||
|
||||
@@ -8,7 +8,7 @@ import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { getBool, getText } from "../utils/draftEditor";
|
||||
import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments";
|
||||
import { insertAfter, moveArrayItem } from "@govoplan/core-webui";
|
||||
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
||||
import { asRecord } from "../utils/campaignView";
|
||||
import { renderTemplatePreviewText } from "../utils/templatePlaceholders";
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function AttachmentRulesOverlay({
|
||||
campaignId,
|
||||
disabled = false,
|
||||
buttonLabel,
|
||||
emptyText = "No attachment files or matching rules configured yet.",
|
||||
emptyText = "i18n:govoplan-campaign.no_attachment_files_or_matching_rules_configured.c6948b49",
|
||||
basePaths = [],
|
||||
zipConfig = { enabled: false, archives: [] },
|
||||
filesModuleInstalled = false,
|
||||
@@ -91,13 +91,13 @@ export default function AttachmentRulesOverlay({
|
||||
className="attachment-rules-modal"
|
||||
bodyClassName="attachment-rules-body"
|
||||
onClose={cancelOverlay}
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={cancelOverlay}>Cancel</Button>
|
||||
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>Save</Button>
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={cancelOverlay}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
||||
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
}>
|
||||
|
||||
<AttachmentRulesTable
|
||||
rules={draftRules}
|
||||
settings={settings}
|
||||
@@ -109,20 +109,20 @@ export default function AttachmentRulesOverlay({
|
||||
filesModuleInstalled={filesModuleInstalled}
|
||||
previewContext={previewContext}
|
||||
activeChooserRuleIndex={null}
|
||||
onChange={setDraftRules}
|
||||
/>
|
||||
onChange={setDraftRules} />
|
||||
|
||||
</Dialog>,
|
||||
document.body
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button className="attachment-summary-button" onClick={openOverlay} disabled={disabled && rules.length === 0} title={`${summary.direct} direct file(s), ${summary.rules} rule(s) / pattern(s)`}>
|
||||
<Button className="attachment-summary-button" onClick={openOverlay} disabled={disabled && rules.length === 0} title={i18nMessage("i18n:govoplan-campaign.value_direct_file_s_value_rule_s_pattern_s.df9b46d9", { value0: summary.direct, value1: summary.rules })}>
|
||||
{label}
|
||||
</Button>
|
||||
{dialog}
|
||||
</>
|
||||
);
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
function cloneAttachmentRules(rules: AttachmentRule[]): AttachmentRule[] {
|
||||
@@ -141,8 +141,8 @@ export function AttachmentRulesTable({
|
||||
<div className="attachment-rules-main">
|
||||
<AttachmentRulesDataGrid {...tableProps} onChange={onChange} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function AttachmentRulesDataGrid({
|
||||
@@ -150,7 +150,7 @@ export function AttachmentRulesDataGrid({
|
||||
settings,
|
||||
campaignId,
|
||||
disabled = false,
|
||||
emptyText = "No attachment files or matching rules configured yet.",
|
||||
emptyText = "i18n:govoplan-campaign.no_attachment_files_or_matching_rules_configured.c6948b49",
|
||||
basePaths = [],
|
||||
id = "attachment-rules",
|
||||
activeChooserRuleIndex = null,
|
||||
@@ -196,10 +196,10 @@ export function AttachmentRulesDataGrid({
|
||||
const currentPath = getText(rule, "base_dir");
|
||||
const currentBasePathId = getText(rule, "base_path_id");
|
||||
const explicitlyReferenced = Boolean(currentBasePathId || currentPath);
|
||||
const basePath = basePaths.find((item) => item.id === currentBasePathId)
|
||||
?? basePaths.find((item) => item.path === currentPath)
|
||||
?? (!explicitlyReferenced ? basePaths[0] : undefined)
|
||||
?? null;
|
||||
const basePath = basePaths.find((item) => item.id === currentBasePathId) ??
|
||||
basePaths.find((item) => item.path === currentPath) ?? (
|
||||
!explicitlyReferenced ? basePaths[0] : undefined) ??
|
||||
null;
|
||||
if (!basePath) return;
|
||||
setFileChooser({ ruleIndex, basePath });
|
||||
}
|
||||
@@ -225,30 +225,30 @@ export function AttachmentRulesDataGrid({
|
||||
rows={rules}
|
||||
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled: managedFilesAvailable, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
|
||||
getRowKey={(rule, index) => String(rule.id ?? index)}
|
||||
emptyText={basePaths.length === 0 ? "No attachment source is enabled for individual attachments." : emptyText}
|
||||
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="Add first attachment" />}
|
||||
emptyText={basePaths.length === 0 ? "i18n:govoplan-campaign.no_attachment_source_is_enabled_for_individual_a.818a2820" : emptyText}
|
||||
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="i18n:govoplan-campaign.add_first_attachment.025fbf31" />}
|
||||
className="attachment-rules-table-wrap attachment-rules-table"
|
||||
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
|
||||
/>
|
||||
{fileChooser && ManagedFileChooser && (
|
||||
<ManagedFileChooser
|
||||
open
|
||||
settings={settings}
|
||||
storageScope={campaignId}
|
||||
linkTarget={{ type: "campaign", id: campaignId, label: "campaign" }}
|
||||
mode="attachment"
|
||||
source={fileChooser.basePath?.source}
|
||||
basePath={fileChooser.basePath?.path ?? "."}
|
||||
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
|
||||
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
|
||||
previewContext={previewContext}
|
||||
renderPatternPreview={(pattern, context) => renderTemplatePreviewText(pattern, context, false)}
|
||||
onClose={() => setFileChooser(null)}
|
||||
onSelectAttachment={selectAttachment}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined} />
|
||||
|
||||
{fileChooser && ManagedFileChooser &&
|
||||
<ManagedFileChooser
|
||||
open
|
||||
settings={settings}
|
||||
storageScope={campaignId}
|
||||
linkTarget={{ type: "campaign", id: campaignId, label: "campaign" }}
|
||||
mode="attachment"
|
||||
source={fileChooser.basePath?.source}
|
||||
basePath={fileChooser.basePath?.path ?? "."}
|
||||
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
|
||||
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
|
||||
previewContext={previewContext}
|
||||
renderPatternPreview={(pattern, context) => renderTemplatePreviewText(pattern, context, false)}
|
||||
onClose={() => setFileChooser(null)}
|
||||
onSelectAttachment={selectAttachment} />
|
||||
|
||||
}
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
type AttachmentRuleColumnContext = {
|
||||
@@ -267,122 +267,154 @@ type AttachmentRuleColumnContext = {
|
||||
|
||||
function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled, activeChooserRuleIndex: _activeChooserRuleIndex, patchRule, addRule, moveRule, openFileChooser, removeRule }: AttachmentRuleColumnContext): DataGridColumn<AttachmentRule>[] {
|
||||
return [
|
||||
{ id: "label", header: "Label", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (rule, index) => <input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} />, value: (rule) => getText(rule, "label") },
|
||||
{
|
||||
id: "base_path",
|
||||
header: "Base path",
|
||||
width: 250,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
columnType: "from-list",
|
||||
list: { options: basePaths.map((basePath) => ({ value: basePath.path, label: basePath.name || basePath.path })) },
|
||||
render: (rule, index) => {
|
||||
const currentBasePathValue = getText(rule, "base_dir");
|
||||
const currentBasePathId = getText(rule, "base_path_id");
|
||||
const explicitlyReferenced = Boolean(currentBasePathId || currentBasePathValue);
|
||||
const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId)
|
||||
?? basePaths.find((basePath) => basePath.path === currentBasePathValue)
|
||||
?? (!explicitlyReferenced ? basePaths[0] : undefined);
|
||||
return (
|
||||
<select
|
||||
value={selectedBasePath?.id ?? ""}
|
||||
disabled={disabled || basePaths.length === 0}
|
||||
onChange={(event) => {
|
||||
const nextBasePath = basePaths.find((basePath) => basePath.id === event.target.value);
|
||||
if (nextBasePath) patchRule(index, { base_path_id: nextBasePath.id, base_dir: nextBasePath.path });
|
||||
}}
|
||||
>
|
||||
{!selectedBasePath && (
|
||||
<option value="" disabled>{basePaths.length === 0 ? "No individual attachment source" : "Unavailable attachment source"}</option>
|
||||
)}
|
||||
{ id: "label", header: "i18n:govoplan-campaign.label.74341e3c", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (rule, index) => <input value={getText(rule, "label")} disabled={disabled} placeholder="i18n:govoplan-campaign.attachment_label.a340f70e" onChange={(event) => patchRule(index, { label: event.target.value })} />, value: (rule) => getText(rule, "label") },
|
||||
{
|
||||
id: "base_path",
|
||||
header: "i18n:govoplan-campaign.base_path.6a4867ca",
|
||||
width: 250,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
columnType: "from-list",
|
||||
list: { options: basePaths.map((basePath) => ({ value: basePath.path, label: basePath.name || basePath.path })) },
|
||||
render: (rule, index) => {
|
||||
const currentBasePathValue = getText(rule, "base_dir");
|
||||
const currentBasePathId = getText(rule, "base_path_id");
|
||||
const explicitlyReferenced = Boolean(currentBasePathId || currentBasePathValue);
|
||||
const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId) ??
|
||||
basePaths.find((basePath) => basePath.path === currentBasePathValue) ?? (
|
||||
!explicitlyReferenced ? basePaths[0] : undefined);
|
||||
return (
|
||||
<select
|
||||
value={selectedBasePath?.id ?? ""}
|
||||
disabled={disabled || basePaths.length === 0}
|
||||
onChange={(event) => {
|
||||
const nextBasePath = basePaths.find((basePath) => basePath.id === event.target.value);
|
||||
if (nextBasePath) patchRule(index, { base_path_id: nextBasePath.id, base_dir: nextBasePath.path });
|
||||
}}>
|
||||
|
||||
{!selectedBasePath &&
|
||||
<option value="" disabled>{basePaths.length === 0 ? "i18n:govoplan-campaign.no_individual_attachment_source.6b54176a" : "i18n:govoplan-campaign.unavailable_attachment_source.7ff1096e"}</option>
|
||||
}
|
||||
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.id}>{basePath.name || basePath.path}</option>)}
|
||||
</select>
|
||||
);
|
||||
},
|
||||
value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "")
|
||||
</select>);
|
||||
|
||||
},
|
||||
{
|
||||
id: "file_filter",
|
||||
header: "File / pattern",
|
||||
width: "minmax(260px, 1fr)",
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
render: (rule, index) => (
|
||||
<div className="field-with-action split-field-action">
|
||||
value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "")
|
||||
},
|
||||
{
|
||||
id: "file_filter",
|
||||
header: "i18n:govoplan-campaign.file_pattern.86320b07",
|
||||
width: "minmax(260px, 1fr)",
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
render: (rule, index) =>
|
||||
<div className="field-with-action split-field-action">
|
||||
<input
|
||||
className="chooser-display-input"
|
||||
value={getText(rule, "file_filter")}
|
||||
disabled={disabled || basePaths.length === 0}
|
||||
readOnly={filesModuleInstalled}
|
||||
tabIndex={filesModuleInstalled ? -1 : undefined}
|
||||
placeholder={filesModuleInstalled ? "Choose a managed file or pattern" : "file.pdf or **/*.pdf"}
|
||||
onChange={(event) => {
|
||||
if (!filesModuleInstalled) patchRule(index, { file_filter: event.target.value });
|
||||
}}
|
||||
onClick={() => filesModuleInstalled && !disabled && openFileChooser(index)}
|
||||
onKeyDown={(event) => {
|
||||
if (filesModuleInstalled && !disabled && (event.key === "Enter" || event.key === " ")) {
|
||||
event.preventDefault();
|
||||
openFileChooser(index);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{filesModuleInstalled && <Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>Choose</Button>}
|
||||
</div>
|
||||
),
|
||||
value: (rule) => getText(rule, "file_filter")
|
||||
className="chooser-display-input"
|
||||
value={getText(rule, "file_filter")}
|
||||
disabled={disabled || basePaths.length === 0}
|
||||
readOnly={filesModuleInstalled}
|
||||
tabIndex={filesModuleInstalled ? -1 : undefined}
|
||||
placeholder={filesModuleInstalled ? "i18n:govoplan-campaign.choose_a_managed_file_or_pattern.96bb3bfb" : "file.pdf or **/*.pdf"}
|
||||
onChange={(event) => {
|
||||
if (!filesModuleInstalled) patchRule(index, { file_filter: event.target.value });
|
||||
}}
|
||||
onClick={() => filesModuleInstalled && !disabled && openFileChooser(index)}
|
||||
onKeyDown={(event) => {
|
||||
if (filesModuleInstalled && !disabled && (event.key === "Enter" || event.key === " ")) {
|
||||
event.preventDefault();
|
||||
openFileChooser(index);
|
||||
}
|
||||
}} />
|
||||
|
||||
{filesModuleInstalled && <Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>i18n:govoplan-campaign.choose.78b7c9f6</Button>}
|
||||
</div>,
|
||||
|
||||
value: (rule) => getText(rule, "file_filter")
|
||||
},
|
||||
{
|
||||
id: "message_filename_template",
|
||||
header: "i18n:govoplan-campaign.message_filename_template.0b7ac934",
|
||||
width: 230,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
render: (rule, index) =>
|
||||
<input
|
||||
value={getText(rule, "message_filename_template")}
|
||||
disabled={disabled}
|
||||
placeholder="i18n:govoplan-campaign.keep_original_filename.9b805045"
|
||||
onChange={(event) => patchRule(index, { message_filename_template: event.target.value })} />,
|
||||
|
||||
value: (rule) => getText(rule, "message_filename_template")
|
||||
},
|
||||
{
|
||||
id: "zip_entry_name_template",
|
||||
header: "i18n:govoplan-campaign.zip_entry_name_template.83772a73",
|
||||
width: 230,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
render: (rule, index) =>
|
||||
<input
|
||||
value={getText(rule, "zip_entry_name_template")}
|
||||
disabled={disabled}
|
||||
placeholder="i18n:govoplan-campaign.keep_original_zip_entry_name.d51558c4"
|
||||
onChange={(event) => patchRule(index, { zip_entry_name_template: event.target.value })} />,
|
||||
|
||||
value: (rule) => getText(rule, "zip_entry_name_template")
|
||||
},
|
||||
...(zipConfig.enabled ? [{
|
||||
id: "zip",
|
||||
header: "i18n:govoplan-campaign.zip_archive.5a2430dd",
|
||||
width: 240,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
columnType: "from-list",
|
||||
list: {
|
||||
options: [
|
||||
{ value: "exclude", label: "i18n:govoplan-campaign.exclude_from_zip.a6422da1" },
|
||||
{ value: "inherit", label: "i18n:govoplan-campaign.campaign_standard.c88ce44c" },
|
||||
...zipConfig.archives.map((archive) => ({ value: archive.id, label: archive.name }))]
|
||||
|
||||
},
|
||||
...(zipConfig.enabled ? [{
|
||||
id: "zip",
|
||||
header: "ZIP archive",
|
||||
width: 240,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
columnType: "from-list",
|
||||
list: {
|
||||
options: [
|
||||
{ value: "exclude", label: "Exclude from ZIP" },
|
||||
{ value: "inherit", label: "Campaign standard" },
|
||||
...zipConfig.archives.map((archive) => ({ value: archive.id, label: archive.name }))
|
||||
]
|
||||
},
|
||||
render: (rule: AttachmentRule, index: number) => {
|
||||
const selection = attachmentRuleZipSelection(rule);
|
||||
return (
|
||||
<select
|
||||
value={selection}
|
||||
disabled={disabled || zipConfig.archives.length === 0}
|
||||
aria-label={`ZIP archive for ${getText(rule, "label") || `attachment ${index + 1}`}`}
|
||||
onChange={(event) => patchRule(index, { zip: { ...asRecord(rule.zip), archive_id: event.target.value } })}
|
||||
>
|
||||
<option value="exclude">Exclude from ZIP</option>
|
||||
<option value="inherit">Campaign standard</option>
|
||||
render: (rule: AttachmentRule, index: number) => {
|
||||
const selection = attachmentRuleZipSelection(rule);
|
||||
return (
|
||||
<select
|
||||
value={selection}
|
||||
disabled={disabled || zipConfig.archives.length === 0}
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.zip_archive_for_value.3dfaf812", { value0: getText(rule, "label") || `attachment ${index + 1}` })}
|
||||
onChange={(event) => patchRule(index, { zip: { ...asRecord(rule.zip), archive_id: event.target.value } })}>
|
||||
|
||||
<option value="exclude">i18n:govoplan-campaign.exclude_from_zip.a6422da1</option>
|
||||
<option value="inherit">i18n:govoplan-campaign.campaign_standard.c88ce44c</option>
|
||||
{zipConfig.archives.map((archive) => <option key={archive.id} value={archive.id}>{archive.name}</option>)}
|
||||
</select>
|
||||
);
|
||||
},
|
||||
value: (rule: AttachmentRule) => attachmentRuleZipSelection(rule)
|
||||
} satisfies DataGridColumn<AttachmentRule>] : []),
|
||||
{ id: "required", header: "Required", width: 175, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "Required" }, { value: "optional", label: "Optional" }] }, render: (rule, index) => <ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} />, value: (rule) => getBool(rule, "required", true) ? "required" : "optional" },
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 180,
|
||||
sticky: "end",
|
||||
render: (_rule, index) => (
|
||||
<DataGridRowActions
|
||||
disabled={disabled}
|
||||
onAddBelow={() => addRule(index)}
|
||||
onRemove={() => removeRule(index)}
|
||||
onMoveUp={index > 0 ? () => moveRule(index, index - 1) : undefined}
|
||||
onMoveDown={index < rules.length - 1 ? () => moveRule(index, index + 1) : undefined}
|
||||
addLabel="Add attachment below"
|
||||
removeLabel="Remove attachment"
|
||||
moveUpLabel="Move attachment up"
|
||||
moveDownLabel="Move attachment down"
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
</select>);
|
||||
|
||||
},
|
||||
value: (rule: AttachmentRule) => attachmentRuleZipSelection(rule)
|
||||
} satisfies DataGridColumn<AttachmentRule>] : []),
|
||||
{ id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 175, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "i18n:govoplan-campaign.required.eed6bfb4" }, { value: "optional", label: "i18n:govoplan-campaign.optional.0c6c4102" }] }, render: (rule, index) => <ToggleSwitch label="i18n:govoplan-campaign.required.eed6bfb4" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} />, value: (rule) => getBool(rule, "required", true) ? "required" : "optional" },
|
||||
{
|
||||
id: "actions",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 180,
|
||||
sticky: "end",
|
||||
render: (_rule, index) =>
|
||||
<DataGridRowActions
|
||||
disabled={disabled}
|
||||
onAddBelow={() => addRule(index)}
|
||||
onRemove={() => removeRule(index)}
|
||||
onMoveUp={index > 0 ? () => moveRule(index, index - 1) : undefined}
|
||||
onMoveDown={index < rules.length - 1 ? () => moveRule(index, index + 1) : undefined}
|
||||
addLabel="i18n:govoplan-campaign.add_attachment_below.c7b66ffd"
|
||||
removeLabel="i18n:govoplan-campaign.remove_attachment.0fcc6594"
|
||||
moveUpLabel="i18n:govoplan-campaign.move_attachment_up.28614804"
|
||||
moveDownLabel="i18n:govoplan-campaign.move_attachment_down.a4d6604f" />
|
||||
|
||||
|
||||
}];
|
||||
|
||||
}
|
||||
|
||||
@@ -7,15 +7,15 @@ import {
|
||||
updateCampaignOwner,
|
||||
upsertCampaignShare,
|
||||
type CampaignShare,
|
||||
type CampaignShareTargets
|
||||
} from "../../../api/campaigns";
|
||||
type CampaignShareTargets } from
|
||||
"../../../api/campaigns";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { StatusBadge, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
type TargetType = "user" | "group";
|
||||
|
||||
export default function CampaignAccessCard({
|
||||
@@ -23,12 +23,12 @@ export default function CampaignAccessCard({
|
||||
campaign,
|
||||
onChanged,
|
||||
onError
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
campaign: CampaignListItem;
|
||||
onChanged: () => Promise<void>;
|
||||
onError: (message: string) => void;
|
||||
}) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;campaign: CampaignListItem;onChanged: () => Promise<void>;onError: (message: string) => void;}) {
|
||||
const [available, setAvailable] = useState<boolean | null>(null);
|
||||
const [targets, setTargets] = useState<CampaignShareTargets>({ users: [], groups: [] });
|
||||
const [shares, setShares] = useState<CampaignShare[]>([]);
|
||||
@@ -41,20 +41,33 @@ export default function CampaignAccessCard({
|
||||
const [shareTargetId, setShareTargetId] = useState("");
|
||||
const [sharePermission, setSharePermission] = useState<"read" | "write">("read");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const currentOwnerType: TargetType = campaign.owner_group_id ? "group" : "user";
|
||||
const currentOwnerId = campaign.owner_group_id || campaign.owner_user_id || "";
|
||||
const ownerDirty = ownerOpen && (ownerType !== currentOwnerType || ownerId !== currentOwnerId);
|
||||
const shareDirty = shareOpen && (shareType !== "user" || Boolean(shareTargetId) || sharePermission !== "read");
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: ownerDirty || shareDirty,
|
||||
onSave: saveActiveDialog,
|
||||
onDiscard: () => {
|
||||
closeOwnerDialog();
|
||||
closeShareDialog();
|
||||
}
|
||||
});
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [nextTargets, nextShares] = await Promise.all([
|
||||
getCampaignShareTargets(settings, campaign.id),
|
||||
getCampaignShares(settings, campaign.id)
|
||||
]);
|
||||
getCampaignShareTargets(settings, campaign.id),
|
||||
getCampaignShares(settings, campaign.id)]
|
||||
);
|
||||
setTargets(nextTargets);
|
||||
setShares(nextShares.filter((item) => !item.revoked_at));
|
||||
setAvailable(true);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.startsWith("403 ")) setAvailable(false);
|
||||
else onError(message);
|
||||
if (message.startsWith("403 ")) setAvailable(false);else
|
||||
onError(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,38 +80,59 @@ export default function CampaignAccessCard({
|
||||
const targetOptions = ownerType === "user" ? targets.users : targets.groups;
|
||||
const shareTargetOptions = shareType === "user" ? targets.users : targets.groups;
|
||||
const targetMap = useMemo(() => new Map([
|
||||
...targets.users.map((item) => [`user:${item.id}`, item] as const),
|
||||
...targets.groups.map((item) => [`group:${item.id}`, item] as const)
|
||||
]), [targets]);
|
||||
...targets.users.map((item) => [`user:${item.id}`, item] as const),
|
||||
...targets.groups.map((item) => [`group:${item.id}`, item] as const)]
|
||||
), [targets]);
|
||||
|
||||
const ownerLabel = campaign.owner_group_id
|
||||
? targetMap.get(`group:${campaign.owner_group_id}`)?.name || "Group owner"
|
||||
: targetMap.get(`user:${campaign.owner_user_id || ""}`)?.name || "User owner";
|
||||
const ownerLabel = campaign.owner_group_id ?
|
||||
targetMap.get(`group:${campaign.owner_group_id}`)?.name || "i18n:govoplan-campaign.group_owner.55630670" :
|
||||
targetMap.get(`user:${campaign.owner_user_id || ""}`)?.name || "i18n:govoplan-campaign.user_owner.86e3faab";
|
||||
|
||||
async function saveOwner() {
|
||||
if (!ownerId) return;
|
||||
function closeOwnerDialog() {
|
||||
setOwnerOpen(false);
|
||||
setOwnerType(currentOwnerType);
|
||||
setOwnerId(currentOwnerId);
|
||||
}
|
||||
|
||||
function closeShareDialog() {
|
||||
setShareOpen(false);
|
||||
setShareType("user");
|
||||
setShareTargetId("");
|
||||
setSharePermission("read");
|
||||
}
|
||||
|
||||
async function saveActiveDialog(): Promise<boolean> {
|
||||
if (ownerDirty) return saveOwner();
|
||||
if (shareDirty) return saveShare();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function saveOwner(): Promise<boolean> {
|
||||
if (!ownerId) return false;
|
||||
setBusy(true);
|
||||
try {
|
||||
await updateCampaignOwner(settings, campaign.id, ownerType === "user"
|
||||
? { owner_user_id: ownerId, owner_group_id: null }
|
||||
: { owner_user_id: null, owner_group_id: ownerId });
|
||||
await updateCampaignOwner(settings, campaign.id, ownerType === "user" ?
|
||||
{ owner_user_id: ownerId, owner_group_id: null } :
|
||||
{ owner_user_id: null, owner_group_id: ownerId });
|
||||
setOwnerOpen(false);
|
||||
await onChanged();
|
||||
await load();
|
||||
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
|
||||
finally { setBusy(false); }
|
||||
return true;
|
||||
} catch (err) {onError(err instanceof Error ? err.message : String(err));return false;} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
async function saveShare() {
|
||||
if (!shareTargetId) return;
|
||||
async function saveShare(): Promise<boolean> {
|
||||
if (!shareTargetId) return false;
|
||||
setBusy(true);
|
||||
try {
|
||||
await upsertCampaignShare(settings, campaign.id, { target_type: shareType, target_id: shareTargetId, permission: sharePermission });
|
||||
setShareOpen(false);
|
||||
setShareTargetId("");
|
||||
await load();
|
||||
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
|
||||
finally { setBusy(false); }
|
||||
return true;
|
||||
} catch (err) {onError(err instanceof Error ? err.message : String(err));return false;} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
async function revoke() {
|
||||
@@ -108,52 +142,53 @@ export default function CampaignAccessCard({
|
||||
await revokeCampaignShare(settings, campaign.id, revokeTarget.id);
|
||||
setRevokeTarget(null);
|
||||
await load();
|
||||
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
|
||||
finally { setBusy(false); }
|
||||
} catch (err) {onError(err instanceof Error ? err.message : String(err));} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<CampaignShare>[]>(() => [
|
||||
{
|
||||
id: "target",
|
||||
header: "Shared with",
|
||||
width: "minmax(220px, 1fr)",
|
||||
minWidth: 190,
|
||||
resizable: true,
|
||||
sticky: "start",
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => targetMap.get(`${row.target_type}:${row.target_id}`)?.name || row.target_id,
|
||||
render: (row) => {
|
||||
const target = targetMap.get(`${row.target_type}:${row.target_id}`);
|
||||
return <div><strong>{target?.name || row.target_id}</strong><div className="muted small-note">{row.target_type}{target?.secondary ? ` · ${target.secondary}` : ""}</div></div>;
|
||||
}
|
||||
},
|
||||
{ id: "permission", header: "Access", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "Can edit" : "Can view"} /> },
|
||||
{ id: "actions", header: "Actions", width: 110, resizable: false, sticky: "end", align: "right", render: (row) => <Button variant="danger" onClick={() => setRevokeTarget(row)}>Remove</Button> }
|
||||
], [targetMap]);
|
||||
{
|
||||
id: "target",
|
||||
header: "i18n:govoplan-campaign.shared_with.6203f449",
|
||||
width: "minmax(220px, 1fr)",
|
||||
minWidth: 190,
|
||||
resizable: true,
|
||||
sticky: "start",
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => targetMap.get(`${row.target_type}:${row.target_id}`)?.name || row.target_id,
|
||||
render: (row) => {
|
||||
const target = targetMap.get(`${row.target_type}:${row.target_id}`);
|
||||
return <div><strong>{target?.name || row.target_id}</strong><div className="muted small-note">{row.target_type}{target?.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: target.secondary }) : ""}</div></div>;
|
||||
}
|
||||
},
|
||||
{ id: "permission", header: "i18n:govoplan-campaign.access.2f81a22d", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "i18n:govoplan-campaign.can_edit.cb0ab3da" : "i18n:govoplan-campaign.can_view.1b3e4006"} /> },
|
||||
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, resizable: false, sticky: "end", align: "right", render: (row) => <Button variant="danger" onClick={() => setRevokeTarget(row)}>i18n:govoplan-campaign.remove.e963907d</Button> }],
|
||||
[targetMap]);
|
||||
|
||||
if (available === false) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card title="Ownership and sharing" actions={<div className="button-row compact-actions"><Button onClick={() => setOwnerOpen(true)} disabled={available !== true}>Change owner</Button><Button onClick={() => setShareOpen(true)} disabled={available !== true}>Share</Button></div>}>
|
||||
<p><strong>Owner:</strong> {ownerLabel}</p>
|
||||
<p className="muted small-note">Permissions define what a role may do; ownership and explicit shares determine which campaigns that permission applies to.</p>
|
||||
<DataGrid id={`campaign-${campaign.id}-shares`} rows={shares} columns={columns} getRowKey={(row) => row.id} emptyText={available === null ? "Loading campaign access…" : "This campaign has no explicit shares."} />
|
||||
<Card title="i18n:govoplan-campaign.ownership_and_sharing.867283c0" actions={<div className="button-row compact-actions"><Button onClick={() => setOwnerOpen(true)} disabled={available !== true}>i18n:govoplan-campaign.change_owner.d3ce16a8</Button><Button onClick={() => setShareOpen(true)} disabled={available !== true}>i18n:govoplan-campaign.share.09ca55ca</Button></div>}>
|
||||
<p><strong>i18n:govoplan-campaign.owner.719379ae</strong> {ownerLabel}</p>
|
||||
<p className="muted small-note">i18n:govoplan-campaign.permissions_define_what_a_role_may_do_ownership_.9f8baaa2</p>
|
||||
<DataGrid id={`campaign-${campaign.id}-shares`} rows={shares} columns={columns} getRowKey={(row) => row.id} emptyText={available === null ? "i18n:govoplan-campaign.loading_campaign_access.056299e3" : "i18n:govoplan-campaign.this_campaign_has_no_explicit_shares.978012d1"} />
|
||||
</Card>
|
||||
|
||||
<Dialog open={ownerOpen} className="campaign-access-dialog" title="Change campaign owner" onClose={() => !busy && setOwnerOpen(false)} footer={<><Button onClick={() => setOwnerOpen(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId}>Save owner</Button></>}>
|
||||
<FormField label="Owner type"><select value={ownerType} onChange={(event) => { const next = event.target.value as TargetType; setOwnerType(next); setOwnerId((next === "user" ? targets.users : targets.groups)[0]?.id || ""); }}><option value="user">User</option><option value="group">Group</option></select></FormField>
|
||||
<FormField label="Owner"><select value={ownerId} onChange={(event) => setOwnerId(event.target.value)}>{targetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? ` · ${item.secondary}` : ""}</option>)}</select></FormField>
|
||||
<Dialog open={ownerOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.change_campaign_owner.63f80aef" onClose={() => !busy && closeOwnerDialog()} footer={<><Button onClick={closeOwnerDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId}>i18n:govoplan-campaign.save_owner.b6763847</Button></>}>
|
||||
<FormField label="i18n:govoplan-campaign.owner_type.6b86eacc"><select value={ownerType} onChange={(event) => {const next = event.target.value as TargetType;setOwnerType(next);setOwnerId((next === "user" ? targets.users : targets.groups)[0]?.id || "");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
|
||||
<FormField label="i18n:govoplan-campaign.owner.89ff3122"><select value={ownerId} onChange={(event) => setOwnerId(event.target.value)}>{targetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: item.secondary }) : ""}</option>)}</select></FormField>
|
||||
<p className="muted small-note">Changing the owner clears a selected reusable mail profile from the editable current version and requires profile reselection plus validation before live delivery.</p>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={shareOpen} className="campaign-access-dialog" title="Share campaign" onClose={() => !busy && setShareOpen(false)} footer={<><Button onClick={() => setShareOpen(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveShare()} disabled={busy || !shareTargetId}>Save share</Button></>}>
|
||||
<FormField label="Target type"><select value={shareType} onChange={(event) => { const next = event.target.value as TargetType; setShareType(next); setShareTargetId((next === "user" ? targets.users : targets.groups)[0]?.id || ""); }}><option value="user">User</option><option value="group">Group</option></select></FormField>
|
||||
<FormField label="User or group"><select value={shareTargetId} onChange={(event) => setShareTargetId(event.target.value)}><option value="">Select…</option>{shareTargetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? ` · ${item.secondary}` : ""}</option>)}</select></FormField>
|
||||
<FormField label="Access"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">Can view</option><option value="write">Can edit and operate</option></select></FormField>
|
||||
<Dialog open={shareOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.share_campaign.b605982b" onClose={() => !busy && closeShareDialog()} footer={<><Button onClick={closeShareDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveShare()} disabled={busy || !shareTargetId}>i18n:govoplan-campaign.save_share.bcf6ed94</Button></>}>
|
||||
<FormField label="i18n:govoplan-campaign.target_type.a45f8055"><select value={shareType} onChange={(event) => {const next = event.target.value as TargetType;setShareType(next);setShareTargetId((next === "user" ? targets.users : targets.groups)[0]?.id || "");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
|
||||
<FormField label="i18n:govoplan-campaign.user_or_group.53406ef0"><select value={shareTargetId} onChange={(event) => setShareTargetId(event.target.value)}><option value="">i18n:govoplan-campaign.select.349ac8fb</option>{shareTargetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: item.secondary }) : ""}</option>)}</select></FormField>
|
||||
<FormField label="i18n:govoplan-campaign.access.2f81a22d"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">i18n:govoplan-campaign.can_view.1b3e4006</option><option value="write">i18n:govoplan-campaign.can_edit_and_operate.77acb15e</option></select></FormField>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(revokeTarget)} title="Remove campaign share" message="Remove this user's or group's explicit access to the campaign? Ownership and other group shares still apply." confirmLabel="Remove share" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} />
|
||||
</>
|
||||
);
|
||||
<ConfirmDialog open={Boolean(revokeTarget)} title="i18n:govoplan-campaign.remove_campaign_share.2c2d42eb" message="i18n:govoplan-campaign.remove_this_user_s_or_group_s_explicit_access_to.5ff07672" confirmLabel="i18n:govoplan-campaign.remove_share.69c932e1" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} />
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
@@ -9,16 +9,16 @@ import {
|
||||
unlockCampaignVersionUserLock,
|
||||
unlockCampaignVersionValidation,
|
||||
type CampaignVersionDetail,
|
||||
type CampaignVersionListItem,
|
||||
} from "../../../api/campaigns";
|
||||
type CampaignVersionListItem } from
|
||||
"../../../api/campaigns";
|
||||
import {
|
||||
canUnlockValidationVersion,
|
||||
formatDateTime,
|
||||
isFinalLockedVersion,
|
||||
isHistoricalCampaignVersion,
|
||||
isPermanentUserLockedVersion,
|
||||
isTemporaryUserLockedVersion,
|
||||
} from "../utils/campaignView";
|
||||
isTemporaryUserLockedVersion } from
|
||||
"../utils/campaignView";
|
||||
|
||||
type LockedVersionNoticeProps = {
|
||||
settings: ApiSettings;
|
||||
@@ -48,7 +48,7 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
|
||||
validationLock,
|
||||
temporaryUserLock,
|
||||
permanentUserLock,
|
||||
finalLock,
|
||||
finalLock
|
||||
});
|
||||
|
||||
async function runAction(action: Exclude<ConfirmAction, null>) {
|
||||
@@ -59,13 +59,13 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
|
||||
try {
|
||||
if (action === "unlock-validation") {
|
||||
await unlockCampaignVersionValidation(settings, campaignId, version.id);
|
||||
setLocalMessage("Validation lock removed. Validation, build and generated queue state were invalidated.");
|
||||
setLocalMessage("i18n:govoplan-campaign.validation_lock_removed_validation_build_and_gen.ea26975f");
|
||||
} else if (action === "unlock-user") {
|
||||
await unlockCampaignVersionUserLock(settings, campaignId, version.id);
|
||||
setLocalMessage("Temporary user lock removed. The version is editable again.");
|
||||
setLocalMessage("i18n:govoplan-campaign.temporary_user_lock_removed_the_version_is_edita.0ca4c587");
|
||||
} else {
|
||||
await lockCampaignVersionPermanently(settings, campaignId, version.id);
|
||||
setLocalMessage("Version permanently locked. Future changes require an editable copy.");
|
||||
setLocalMessage("i18n:govoplan-campaign.version_permanently_locked_future_changes_requir.fd718a84");
|
||||
}
|
||||
await reload();
|
||||
} catch (err) {
|
||||
@@ -83,7 +83,7 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
|
||||
try {
|
||||
const result = await forkCampaignVersion(settings, campaignId, version.id, {
|
||||
current_flow: "manual",
|
||||
current_step: version.current_step ?? null,
|
||||
current_step: version.current_step ?? null
|
||||
});
|
||||
setLocalMessage(`Created editable version #${result.version.version_number}.`);
|
||||
await reload();
|
||||
@@ -105,26 +105,26 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
|
||||
{localError && <span className="locked-version-error"> {localError}</span>}
|
||||
</div>
|
||||
<div className="button-row compact-actions locked-version-actions">
|
||||
{validationLock && (
|
||||
<Button onClick={() => setConfirmAction("unlock-validation")} disabled={!version || busy}>
|
||||
{busy ? "Working…" : "Unlock validation"}
|
||||
{validationLock &&
|
||||
<Button onClick={() => setConfirmAction("unlock-validation")} disabled={!version || busy}>
|
||||
{busy ? "i18n:govoplan-campaign.working.13b7bfca" : "i18n:govoplan-campaign.unlock_validation.f01952b6"}
|
||||
</Button>
|
||||
)}
|
||||
{temporaryUserLock && (
|
||||
<>
|
||||
}
|
||||
{temporaryUserLock &&
|
||||
<>
|
||||
<Button onClick={() => setConfirmAction("unlock-user")} disabled={!version || busy}>
|
||||
{busy ? "Working…" : "Unlock"}
|
||||
{busy ? "i18n:govoplan-campaign.working.13b7bfca" : "i18n:govoplan-campaign.unlock.1526a17e"}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => setConfirmAction("permanent")} disabled={!version || busy}>
|
||||
Lock permanently
|
||||
i18n:govoplan-campaign.lock_permanently.cc0ce9e7
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canCreateEditableCopy && (
|
||||
<Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}>
|
||||
{busy ? "Creating copy…" : "Create editable copy"}
|
||||
}
|
||||
{canCreateEditableCopy &&
|
||||
<Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}>
|
||||
{busy ? "i18n:govoplan-campaign.creating_copy.4247f587" : "i18n:govoplan-campaign.create_editable_copy.92d6d91b"}
|
||||
</Button>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -139,10 +139,10 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
|
||||
const action = confirmAction;
|
||||
setConfirmAction(null);
|
||||
if (action) void runAction(action);
|
||||
}}
|
||||
/>
|
||||
</DismissibleAlert>
|
||||
);
|
||||
}} />
|
||||
|
||||
</DismissibleAlert>);
|
||||
|
||||
}
|
||||
|
||||
type LockFlags = {
|
||||
@@ -154,82 +154,82 @@ type LockFlags = {
|
||||
};
|
||||
|
||||
function lockPresentation(
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
flags: LockFlags,
|
||||
): { kind: string; title: string; description: string; info: string } {
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
flags: LockFlags)
|
||||
: {kind: string;title: string;description: string;info: string;} {
|
||||
if (flags.historicalVersion) {
|
||||
return {
|
||||
kind: "historical",
|
||||
title: "Historical version.",
|
||||
description: "This version is review-only. Only the campaign's current working version may be edited in place.",
|
||||
info: `Version #${version?.version_number ?? "—"}.`,
|
||||
title: "i18n:govoplan-campaign.historical_version.adac5dba",
|
||||
description: "i18n:govoplan-campaign.this_version_is_review_only_only_the_campaign_s_.9e68f6a0",
|
||||
info: `Version #${version?.version_number ?? "—"}.`
|
||||
};
|
||||
}
|
||||
if (flags.temporaryUserLock) {
|
||||
return {
|
||||
kind: "temporary-user",
|
||||
title: "Temporarily locked version.",
|
||||
description: "Campaign data is read-only, but an authorized user may unlock it or make the lock permanent.",
|
||||
info: version?.user_locked_at ? `Locked ${formatDateTime(version.user_locked_at)}.` : "",
|
||||
title: "i18n:govoplan-campaign.temporarily_locked_version.696f85e3",
|
||||
description: "i18n:govoplan-campaign.campaign_data_is_read_only_but_an_authorized_use.f2e513b1",
|
||||
info: version?.user_locked_at ? `Locked ${formatDateTime(version.user_locked_at)}.` : ""
|
||||
};
|
||||
}
|
||||
if (flags.permanentUserLock) {
|
||||
return {
|
||||
kind: "permanent-user",
|
||||
title: "Permanently locked version.",
|
||||
description: "This user-requested snapshot cannot be unlocked. Create an editable copy for future changes.",
|
||||
info: version?.user_locked_at || version?.published_at
|
||||
? `Locked ${formatDateTime(version.user_locked_at ?? version.published_at)}.`
|
||||
: "",
|
||||
title: "i18n:govoplan-campaign.permanently_locked_version.248c2eeb",
|
||||
description: "i18n:govoplan-campaign.this_user_requested_snapshot_cannot_be_unlocked_.9e91210c",
|
||||
info: version?.user_locked_at || version?.published_at ?
|
||||
`Locked ${formatDateTime(version.user_locked_at ?? version.published_at)}.` :
|
||||
""
|
||||
};
|
||||
}
|
||||
if (flags.validationLock) {
|
||||
return {
|
||||
kind: "validation",
|
||||
title: "Temporarily locked after validation.",
|
||||
description: "This protects the validated build input. Unlocking invalidates validation, build and generated queue state.",
|
||||
info: version?.locked_at ? `Validated ${formatDateTime(version.locked_at)}.` : "",
|
||||
title: "i18n:govoplan-campaign.temporarily_locked_after_validation.c558ce56",
|
||||
description: "i18n:govoplan-campaign.this_protects_the_validated_build_input_unlockin.ed890772",
|
||||
info: version?.locked_at ? `Validated ${formatDateTime(version.locked_at)}.` : ""
|
||||
};
|
||||
}
|
||||
if (flags.finalLock) {
|
||||
return {
|
||||
kind: "delivery",
|
||||
title: "Permanently locked delivery snapshot.",
|
||||
description: "Delivery has started or the version is in a final state, so it cannot be unlocked in place.",
|
||||
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : "",
|
||||
title: "i18n:govoplan-campaign.permanently_locked_delivery_snapshot.c3670daa",
|
||||
description: "i18n:govoplan-campaign.delivery_has_started_or_the_version_is_in_a_fina.571e86ed",
|
||||
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : ""
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "other",
|
||||
title: "Locked version.",
|
||||
description: "This version is read-only. Create an editable copy to continue working.",
|
||||
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : "",
|
||||
title: "i18n:govoplan-campaign.locked_version.e755b179",
|
||||
description: "i18n:govoplan-campaign.this_version_is_read_only_create_an_editable_cop.9e0afd29",
|
||||
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : ""
|
||||
};
|
||||
}
|
||||
|
||||
function confirmDialogTitle(action: ConfirmAction): string {
|
||||
if (action === "unlock-validation") return "Unlock validation?";
|
||||
if (action === "unlock-user") return "Unlock temporary lock?";
|
||||
if (action === "permanent") return "Lock permanently?";
|
||||
return "Confirm action";
|
||||
if (action === "unlock-validation") return "i18n:govoplan-campaign.unlock_validation.e3066247";
|
||||
if (action === "unlock-user") return "i18n:govoplan-campaign.unlock_temporary_lock.8a3ad468";
|
||||
if (action === "permanent") return "i18n:govoplan-campaign.lock_permanently.24d5b74d";
|
||||
return "i18n:govoplan-campaign.confirm_action.50649c5c";
|
||||
}
|
||||
|
||||
function confirmDialogMessage(action: ConfirmAction): string {
|
||||
if (action === "unlock-validation") {
|
||||
return "This makes the version editable and clears validation, build results, review acknowledgement and generated jobs for this version.";
|
||||
return "i18n:govoplan-campaign.this_makes_the_version_editable_and_clears_valid.89a6cd1b";
|
||||
}
|
||||
if (action === "unlock-user") {
|
||||
return "This removes the reversible user lock. Campaign data and existing workflow results are not otherwise changed.";
|
||||
return "i18n:govoplan-campaign.this_removes_the_reversible_user_lock_campaign_d.fcf8cb1d";
|
||||
}
|
||||
if (action === "permanent") {
|
||||
return "This lock cannot be removed by any role. The version remains available for review and audit, but future changes require an editable copy.";
|
||||
return "i18n:govoplan-campaign.this_lock_cannot_be_removed_by_any_role_the_vers.3054b076";
|
||||
}
|
||||
return "Continue?";
|
||||
return "i18n:govoplan-campaign.continue.4e6302ed";
|
||||
}
|
||||
|
||||
function confirmDialogLabel(action: ConfirmAction): string {
|
||||
if (action === "unlock-validation") return "Unlock validation";
|
||||
if (action === "unlock-user") return "Unlock";
|
||||
if (action === "permanent") return "Lock permanently";
|
||||
return "Confirm";
|
||||
}
|
||||
if (action === "unlock-validation") return "i18n:govoplan-campaign.unlock_validation.f01952b6";
|
||||
if (action === "unlock-user") return "i18n:govoplan-campaign.unlock.1526a17e";
|
||||
if (action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
|
||||
return "i18n:govoplan-campaign.confirm.04a21221";
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export type CampaignMessagePreviewOverlayProps = {
|
||||
};
|
||||
|
||||
export default function CampaignMessagePreviewOverlay({
|
||||
title = "Message preview",
|
||||
title = "i18n:govoplan-campaign.message_preview.58de1450",
|
||||
subject,
|
||||
bodyMode = "text",
|
||||
text,
|
||||
@@ -51,12 +51,12 @@ export default function CampaignMessagePreviewOverlay({
|
||||
metaItems = [],
|
||||
attachments = [],
|
||||
raw,
|
||||
rawLabel = "Raw MIME",
|
||||
rawLabel = "i18n:govoplan-campaign.raw_mime.82c612d4",
|
||||
navigation,
|
||||
closeLabel = "Close",
|
||||
closeLabel = "i18n:govoplan-campaign.close.bbfa773e",
|
||||
onClose
|
||||
}: CampaignMessagePreviewOverlayProps) {
|
||||
const shownSubject = subject?.trim() || "No subject";
|
||||
const shownSubject = subject?.trim() || "i18n:govoplan-campaign.no_subject.7b4e8035";
|
||||
const fields = metaItems.map((item) => ({ label: item.label, value: item.value }));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -95,23 +95,23 @@ export default function CampaignMessagePreviewOverlay({
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</header>
|
||||
<div className="modal-body">
|
||||
{(recipientLabel || recipientNote || navigation) && (
|
||||
<div className="template-preview-toolbar">
|
||||
{(recipientLabel || recipientNote || navigation) &&
|
||||
<div className="template-preview-toolbar">
|
||||
<div>
|
||||
{recipientLabel && <strong>{recipientLabel}</strong>}
|
||||
{recipientNote && <p className="muted small-note">{recipientNote}</p>}
|
||||
</div>
|
||||
{navigation && (
|
||||
<div className="button-row compact-actions template-preview-nav" aria-label="Preview message navigation">
|
||||
<button type="button" className="version-arrow" onClick={navigation.onFirst} disabled={navigation.index <= 0} title="First message" aria-label="First message"><ArrowBigLeftDash aria-hidden="true" /></button>
|
||||
<button type="button" className="version-arrow" onClick={navigation.onPrevious} disabled={navigation.index <= 0} title="Previous message" aria-label="Previous message"><ArrowBigLeft aria-hidden="true" /></button>
|
||||
{navigation &&
|
||||
<div className="button-row compact-actions template-preview-nav" aria-label="i18n:govoplan-campaign.preview_message_navigation.d28a8dc0">
|
||||
<button type="button" className="version-arrow" onClick={navigation.onFirst} disabled={navigation.index <= 0} title="i18n:govoplan-campaign.first_message.ffc124fd" aria-label="i18n:govoplan-campaign.first_message.ffc124fd"><ArrowBigLeftDash aria-hidden="true" /></button>
|
||||
<button type="button" className="version-arrow" onClick={navigation.onPrevious} disabled={navigation.index <= 0} title="i18n:govoplan-campaign.previous_message.93261bd8" aria-label="i18n:govoplan-campaign.previous_message.93261bd8"><ArrowBigLeft aria-hidden="true" /></button>
|
||||
<span className="template-preview-count">{navigation.index + 1} / {navigation.total}</span>
|
||||
<button type="button" className="version-arrow" onClick={navigation.onNext} disabled={navigation.index >= navigation.total - 1} title="Next message" aria-label="Next message"><ArrowBigRight aria-hidden="true" /></button>
|
||||
<button type="button" className="version-arrow" onClick={navigation.onLast} disabled={navigation.index >= navigation.total - 1} title="Last message" aria-label="Last message"><ArrowBigRightDash aria-hidden="true" /></button>
|
||||
<button type="button" className="version-arrow" onClick={navigation.onNext} disabled={navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.next_message.e3960a5d" aria-label="i18n:govoplan-campaign.next_message.e3960a5d"><ArrowBigRight aria-hidden="true" /></button>
|
||||
<button type="button" className="version-arrow" onClick={navigation.onLast} disabled={navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.last_message.83741110" aria-label="i18n:govoplan-campaign.last_message.83741110"><ArrowBigRightDash aria-hidden="true" /></button>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
|
||||
<MessageDisplayPanel
|
||||
title={shownSubject}
|
||||
@@ -121,20 +121,20 @@ export default function CampaignMessagePreviewOverlay({
|
||||
preferredBodyMode={bodyMode}
|
||||
deriveTextFromHtml={false}
|
||||
attachments={attachments}
|
||||
emptyText="No message content is available."
|
||||
/>
|
||||
emptyText="i18n:govoplan-campaign.no_message_content_is_available.54e8e7e6" />
|
||||
|
||||
|
||||
{raw && (
|
||||
<details className="message-preview-raw">
|
||||
{raw &&
|
||||
<details className="message-preview-raw">
|
||||
<summary>{rawLabel}</summary>
|
||||
<pre className="mock-message-raw">{raw}</pre>
|
||||
</details>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>{closeLabel}</Button></footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
@@ -146,4 +146,4 @@ function isEditableTarget(target: EventTarget | null): boolean {
|
||||
export type MessagePreviewAttachment = CampaignMessagePreviewAttachment;
|
||||
export type MessagePreviewMetaItem = CampaignMessagePreviewMetaItem;
|
||||
export type MessagePreviewNavigation = CampaignMessagePreviewNavigation;
|
||||
export type MessagePreviewOverlayProps = CampaignMessagePreviewOverlayProps;
|
||||
export type MessagePreviewOverlayProps = CampaignMessagePreviewOverlayProps;
|
||||
@@ -9,14 +9,14 @@ import {
|
||||
removePlaceholderFromText,
|
||||
replacePlaceholderInText,
|
||||
type TemplateNamespace,
|
||||
type UndefinedPlaceholder
|
||||
} from "../utils/templatePlaceholders";
|
||||
type UndefinedPlaceholder } from
|
||||
"../utils/templatePlaceholders";
|
||||
import {
|
||||
TemplateFieldChipList,
|
||||
UndefinedPlaceholderDecisionDialog,
|
||||
UndefinedPlaceholderList,
|
||||
type TemplateFieldOption
|
||||
} from "./TemplatePlaceholderControls";
|
||||
type TemplateFieldOption } from
|
||||
"./TemplatePlaceholderControls";
|
||||
|
||||
export default function TemplateExpressionEditorDialog({
|
||||
open,
|
||||
@@ -26,27 +26,27 @@ export default function TemplateExpressionEditorDialog({
|
||||
globalFields,
|
||||
placeholder,
|
||||
description,
|
||||
saveLabel = "Save",
|
||||
saveLabel = "i18n:govoplan-campaign.save.efc007a3",
|
||||
validate,
|
||||
onCancel,
|
||||
onSave,
|
||||
onAddField,
|
||||
canAddField = () => true
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
value: string;
|
||||
localFields: TemplateFieldOption[];
|
||||
globalFields: TemplateFieldOption[];
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
saveLabel?: string;
|
||||
validate?: (value: string) => string;
|
||||
onCancel: () => void;
|
||||
onSave: (value: string) => void;
|
||||
onAddField: (name: string) => void;
|
||||
canAddField?: (name: string) => boolean;
|
||||
}) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {open: boolean;title: string;value: string;localFields: TemplateFieldOption[];globalFields: TemplateFieldOption[];placeholder?: string;description?: string;saveLabel?: string;validate?: (value: string) => string;onCancel: () => void;onSave: (value: string) => void;onAddField: (name: string) => void;canAddField?: (name: string) => boolean;}) {
|
||||
const [draftValue, setDraftValue] = useState(value);
|
||||
const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -113,22 +113,22 @@ export default function TemplateExpressionEditorDialog({
|
||||
<Dialog
|
||||
open={open && !undefinedDialog}
|
||||
title={title}
|
||||
closeLabel="Cancel filename changes"
|
||||
closeLabel="i18n:govoplan-campaign.cancel_filename_changes.b558598e"
|
||||
className="template-expression-dialog"
|
||||
headerClassName="template-expression-dialog-header"
|
||||
bodyClassName="template-expression-dialog-body"
|
||||
footerClassName="template-expression-dialog-footer"
|
||||
onClose={onCancel}
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onCancel}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
||||
<Button variant="primary" onClick={requestSave} disabled={Boolean(validationMessage)}>{saveLabel}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
}>
|
||||
|
||||
{description && <p className="muted template-expression-description">{description}</p>}
|
||||
<label className="template-expression-value-field">
|
||||
<span>Archive filename</span>
|
||||
<span>i18n:govoplan-campaign.archive_filename.47becf3d</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draftValue}
|
||||
@@ -140,47 +140,47 @@ export default function TemplateExpressionEditorDialog({
|
||||
event.preventDefault();
|
||||
requestSave();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}} />
|
||||
|
||||
</label>
|
||||
{validationMessage && <DismissibleAlert tone="danger" resetKey={validationMessage}>{validationMessage}</DismissibleAlert>}
|
||||
|
||||
<h3 className="section-mini-heading">Recipient fields</h3>
|
||||
<p className="muted small-note">Select a field to insert it at the current cursor position. Address fields represent the first effective address; use <code>to[2]</code> or <code>to[2].email</code> for another single value.</p>
|
||||
<h3 className="section-mini-heading">i18n:govoplan-campaign.recipient_fields.fdbcd95b</h3>
|
||||
<p className="muted small-note">i18n:govoplan-campaign.select_a_field_to_insert_it_at_the_current_curso.1d1b51be <code>i18n:govoplan-campaign.to_2.8c1db0c7</code> or <code>i18n:govoplan-campaign.to_2_email.5ad61c1a</code> i18n:govoplan-campaign.for_another_single_value.ce57d76a</p>
|
||||
<TemplateFieldChipList
|
||||
namespace="local"
|
||||
fields={localFields}
|
||||
usedPlaceholders={usedPlaceholders}
|
||||
empty="No recipient fields are available."
|
||||
onInsert={insertPlaceholder}
|
||||
/>
|
||||
empty="i18n:govoplan-campaign.no_recipient_fields_are_available.5b7943ad"
|
||||
onInsert={insertPlaceholder} />
|
||||
|
||||
|
||||
<h3 className="section-mini-heading">Campaign fields</h3>
|
||||
<h3 className="section-mini-heading">i18n:govoplan-campaign.campaign_fields.969e7d80</h3>
|
||||
<TemplateFieldChipList
|
||||
namespace="global"
|
||||
fields={globalFields}
|
||||
usedPlaceholders={usedPlaceholders}
|
||||
empty="No campaign fields or global values are available."
|
||||
onInsert={insertPlaceholder}
|
||||
/>
|
||||
empty="i18n:govoplan-campaign.no_campaign_fields_or_global_values_are_availabl.b2e4089d"
|
||||
onInsert={insertPlaceholder} />
|
||||
|
||||
|
||||
<h3 className="section-mini-heading">Used, but undefined</h3>
|
||||
<h3 className="section-mini-heading">i18n:govoplan-campaign.used_but_undefined.3b829043</h3>
|
||||
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
|
||||
</Dialog>
|
||||
|
||||
<UndefinedPlaceholderDecisionDialog
|
||||
field={undefinedDialog}
|
||||
contextLabel="archive filename"
|
||||
removeLabel="Remove from filename"
|
||||
contextLabel="i18n:govoplan-campaign.archive_filename.de958cbd"
|
||||
removeLabel="i18n:govoplan-campaign.remove_from_filename.560f7f42"
|
||||
onCancel={() => setUndefinedDialog(null)}
|
||||
onRemove={removeUndefined}
|
||||
onReplace={replaceUndefined}
|
||||
onAddField={addUndefined}
|
||||
localFields={localFields}
|
||||
globalFields={globalFields}
|
||||
addDisabled={Boolean(undefinedDialog?.name) && !canAddField(undefinedDialog?.name ?? "")}
|
||||
/>
|
||||
addDisabled={Boolean(undefinedDialog?.name) && !canAddField(undefinedDialog?.name ?? "")} />
|
||||
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
|
||||
import type { TemplateNamespace, TemplatePlaceholder, UndefinedPlaceholder } from "../utils/templatePlaceholders";
|
||||
|
||||
export type TemplateFieldOption = {
|
||||
@@ -16,14 +16,14 @@ export function TemplateFieldChipList({
|
||||
empty,
|
||||
disabled = false,
|
||||
onInsert
|
||||
}: {
|
||||
namespace: TemplateNamespace;
|
||||
fields: TemplateFieldOption[];
|
||||
usedPlaceholders?: TemplatePlaceholder[];
|
||||
empty: string;
|
||||
disabled?: boolean;
|
||||
onInsert: (namespace: TemplateNamespace, name: string) => void;
|
||||
}) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {namespace: TemplateNamespace;fields: TemplateFieldOption[];usedPlaceholders?: TemplatePlaceholder[];empty: string;disabled?: boolean;onInsert: (namespace: TemplateNamespace, name: string) => void;}) {
|
||||
if (fields.length === 0) return <p className="muted">{empty}</p>;
|
||||
return (
|
||||
<div className="field-chip-list">
|
||||
@@ -35,46 +35,46 @@ export function TemplateFieldChipList({
|
||||
className={`field-chip field-chip-button ${used ? "used" : ""}`}
|
||||
key={`${namespace}:${field.name}`}
|
||||
disabled={disabled}
|
||||
onClick={() => onInsert(namespace, field.name)}
|
||||
>
|
||||
onClick={() => onInsert(namespace, field.name)}>
|
||||
|
||||
<span className="field-chip-namespace">{namespace}</span><span title={field.label !== field.name ? field.label : undefined}>{field.name}</span>
|
||||
</button>
|
||||
);
|
||||
</button>);
|
||||
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function UndefinedPlaceholderList({
|
||||
items,
|
||||
empty = "No undefined placeholders detected.",
|
||||
empty = "i18n:govoplan-campaign.no_undefined_placeholders_detected.1cbdf41b",
|
||||
onSelect
|
||||
}: {
|
||||
items: UndefinedPlaceholder[];
|
||||
empty?: string;
|
||||
onSelect: (item: UndefinedPlaceholder) => void;
|
||||
}) {
|
||||
|
||||
|
||||
|
||||
|
||||
}: {items: UndefinedPlaceholder[];empty?: string;onSelect: (item: UndefinedPlaceholder) => void;}) {
|
||||
if (items.length === 0) return <p className="muted">{empty}</p>;
|
||||
return (
|
||||
<div className="field-chip-list">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
className="field-chip field-chip-button undefined"
|
||||
key={`${item.raw}:${item.reason}`}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
{items.map((item) =>
|
||||
<button
|
||||
type="button"
|
||||
className="field-chip field-chip-button undefined"
|
||||
key={`${item.raw}:${item.reason}`}
|
||||
onClick={() => onSelect(item)}>
|
||||
|
||||
{item.display}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
)}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function UndefinedPlaceholderDecisionDialog({
|
||||
field,
|
||||
contextLabel = "template",
|
||||
removeLabel = "Remove placeholder",
|
||||
removeLabel = "i18n:govoplan-campaign.remove_placeholder.3f575c72",
|
||||
localFields = [],
|
||||
globalFields = [],
|
||||
onCancel,
|
||||
@@ -82,23 +82,23 @@ export function UndefinedPlaceholderDecisionDialog({
|
||||
onReplace,
|
||||
onAddField,
|
||||
addDisabled = false
|
||||
}: {
|
||||
field: UndefinedPlaceholder | null;
|
||||
contextLabel?: string;
|
||||
removeLabel?: string;
|
||||
localFields?: TemplateFieldOption[];
|
||||
globalFields?: TemplateFieldOption[];
|
||||
onCancel: () => void;
|
||||
onRemove: (field: UndefinedPlaceholder) => void;
|
||||
onReplace?: (field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) => void;
|
||||
onAddField: (field: UndefinedPlaceholder) => void;
|
||||
addDisabled?: boolean;
|
||||
}) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {field: UndefinedPlaceholder | null;contextLabel?: string;removeLabel?: string;localFields?: TemplateFieldOption[];globalFields?: TemplateFieldOption[];onCancel: () => void;onRemove: (field: UndefinedPlaceholder) => void;onReplace?: (field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) => void;onAddField: (field: UndefinedPlaceholder) => void;addDisabled?: boolean;}) {
|
||||
const replacementOptions = useMemo(
|
||||
() => [
|
||||
...localFields.map((item) => ({ namespace: "local" as const, ...item })),
|
||||
...globalFields.map((item) => ({ namespace: "global" as const, ...item }))
|
||||
],
|
||||
...localFields.map((item) => ({ namespace: "local" as const, ...item })),
|
||||
...globalFields.map((item) => ({ namespace: "global" as const, ...item }))],
|
||||
|
||||
[globalFields, localFields]
|
||||
);
|
||||
const [replacement, setReplacement] = useState("");
|
||||
@@ -121,42 +121,42 @@ export function UndefinedPlaceholderDecisionDialog({
|
||||
return (
|
||||
<Dialog
|
||||
open={Boolean(field)}
|
||||
title="Undefined field reference"
|
||||
closeLabel="Return to editing"
|
||||
title="i18n:govoplan-campaign.undefined_field_reference.4ff8e266"
|
||||
closeLabel="i18n:govoplan-campaign.return_to_editing.37d4a5f0"
|
||||
className="template-action-dialog"
|
||||
onClose={onCancel}
|
||||
footer={field ? (
|
||||
<>
|
||||
<Button onClick={onCancel}>Continue editing</Button>
|
||||
footer={field ?
|
||||
<>
|
||||
<Button onClick={onCancel}>i18n:govoplan-campaign.continue_editing.b101465f</Button>
|
||||
<Button onClick={() => onRemove(field)}>{removeLabel}</Button>
|
||||
{onReplace && replacementOptions.length > 0 && <Button onClick={replaceWithSelected}>Replace</Button>}
|
||||
<Button variant="primary" onClick={() => onAddField(field)} disabled={!field.name || field.reason === "invalid-namespace" || addDisabled}>Add field</Button>
|
||||
</>
|
||||
) : undefined}
|
||||
>
|
||||
{field && (
|
||||
<>
|
||||
<p>The {contextLabel} uses <code>{`{{${field.raw}}}`}</code>, but it cannot be matched to a known field.</p>
|
||||
{field.reason === "invalid-namespace" && (
|
||||
<DismissibleAlert tone="warning">Use the namespace <code>global:</code> or <code>local:</code>.</DismissibleAlert>
|
||||
)}
|
||||
{field.reason === "missing-field" && (
|
||||
<p className="muted">You can add <strong>{field.name}</strong> as a campaign field, remove this placeholder, or continue editing.</p>
|
||||
)}
|
||||
{onReplace && replacementOptions.length > 0 && (
|
||||
<label className="template-replacement-field">
|
||||
<span>Replace by existing field</span>
|
||||
{onReplace && replacementOptions.length > 0 && <Button onClick={replaceWithSelected}>i18n:govoplan-campaign.replace.a7cf7b25</Button>}
|
||||
<Button variant="primary" onClick={() => onAddField(field)} disabled={!field.name || field.reason === "invalid-namespace" || addDisabled}>i18n:govoplan-campaign.add_field.039c6315</Button>
|
||||
</> :
|
||||
undefined}>
|
||||
|
||||
{field &&
|
||||
<>
|
||||
<p>i18n:govoplan-campaign.the.93ef0dd8 {contextLabel} uses <code>{i18nMessage("i18n:govoplan-campaign.value.91317e63", { value0: field.raw })}</code>i18n:govoplan-campaign.but_it_cannot_be_matched_to_a_known_field.e75c4dde</p>
|
||||
{field.reason === "invalid-namespace" &&
|
||||
<DismissibleAlert tone="warning">i18n:govoplan-campaign.use_the_namespace.a4c2669d <code>global:</code> or <code>local:</code>.</DismissibleAlert>
|
||||
}
|
||||
{field.reason === "missing-field" &&
|
||||
<p className="muted">i18n:govoplan-campaign.you_can_add.66093419 <strong>{field.name}</strong> i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8</p>
|
||||
}
|
||||
{onReplace && replacementOptions.length > 0 &&
|
||||
<label className="template-replacement-field">
|
||||
<span>i18n:govoplan-campaign.replace_by_existing_field.ee3dc0eb</span>
|
||||
<select value={effectiveReplacement} onChange={(event) => setReplacement(event.target.value)}>
|
||||
{replacementOptions.map((option) => (
|
||||
<option key={`${option.namespace}:${option.name}`} value={`${option.namespace}:${option.name}`}>
|
||||
{replacementOptions.map((option) =>
|
||||
<option key={`${option.namespace}:${option.name}`} value={`${option.namespace}:${option.name}`}>
|
||||
{option.namespace}:{option.label || option.name}
|
||||
</option>
|
||||
))}
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
</Dialog>);
|
||||
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
|
||||
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
|
||||
import { useCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
|
||||
import { formatDateTime } from "../utils/campaignView";
|
||||
|
||||
type VersionLineProps = {
|
||||
@@ -13,8 +13,7 @@ type VersionLineProps = {
|
||||
|
||||
export default function VersionLine({ version, versions = [], status, loadedAt }: VersionLineProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { requestNavigation } = useCampaignUnsavedChanges();
|
||||
const navigate = useGuardedNavigate();
|
||||
const sorted = versions.slice().sort((a, b) => (a.version_number ?? 0) - (b.version_number ?? 0));
|
||||
const currentIndex = version ? sorted.findIndex((item) => item.id === version.id) : -1;
|
||||
const first = currentIndex > 0 ? sorted[0] : null;
|
||||
@@ -25,7 +24,7 @@ export default function VersionLine({ version, versions = [], status, loadedAt }
|
||||
|
||||
function openVersion(target: CampaignVersionListItem) {
|
||||
const targetUrl = versionTarget(location.pathname, location.search, target.id);
|
||||
requestNavigation(() => navigate(targetUrl));
|
||||
navigate(targetUrl);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -33,32 +32,32 @@ export default function VersionLine({ version, versions = [], status, loadedAt }
|
||||
<VersionArrow
|
||||
icon="first"
|
||||
target={first}
|
||||
fallbackLabel="Already at first version"
|
||||
onOpen={openVersion}
|
||||
/>
|
||||
fallbackLabel="i18n:govoplan-campaign.already_at_first_version.b90fad64"
|
||||
onOpen={openVersion} />
|
||||
|
||||
<VersionArrow
|
||||
icon="previous"
|
||||
target={previous}
|
||||
fallbackLabel="No older version"
|
||||
onOpen={openVersion}
|
||||
/>
|
||||
<span>Version {version ? `#${version.version_number}` : "—"}</span>
|
||||
fallbackLabel="i18n:govoplan-campaign.no_older_version.4355dee8"
|
||||
onOpen={openVersion} />
|
||||
|
||||
<span>i18n:govoplan-campaign.version.2da600bf {version ? i18nMessage("i18n:govoplan-campaign.value.44b8c76f", { value0: version.version_number }) : "—"}</span>
|
||||
<VersionArrow
|
||||
icon="next"
|
||||
target={next}
|
||||
fallbackLabel="No newer version"
|
||||
onOpen={openVersion}
|
||||
/>
|
||||
fallbackLabel="i18n:govoplan-campaign.no_newer_version.b210a41c"
|
||||
onOpen={openVersion} />
|
||||
|
||||
<VersionArrow
|
||||
icon="latest"
|
||||
target={latest}
|
||||
fallbackLabel="Already at latest version"
|
||||
onOpen={openVersion}
|
||||
/>
|
||||
fallbackLabel="i18n:govoplan-campaign.already_at_latest_version.7533bd9d"
|
||||
onOpen={openVersion} />
|
||||
|
||||
<span className="version-line-separator">·</span>
|
||||
<span>{suffix}</span>
|
||||
</p>
|
||||
);
|
||||
</p>);
|
||||
|
||||
}
|
||||
|
||||
type VersionArrowProps = {
|
||||
@@ -76,8 +75,8 @@ function VersionArrow({ icon, target, fallbackLabel, onOpen }: VersionArrowProps
|
||||
return (
|
||||
<span className="version-arrow disabled" title={fallbackLabel} aria-label={fallbackLabel}>
|
||||
<Icon aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
</span>);
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -86,11 +85,11 @@ function VersionArrow({ icon, target, fallbackLabel, onOpen }: VersionArrowProps
|
||||
className="version-arrow"
|
||||
onClick={() => onOpen(target)}
|
||||
title={actionLabel}
|
||||
aria-label={actionLabel}
|
||||
>
|
||||
aria-label={actionLabel}>
|
||||
|
||||
<Icon aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
</button>);
|
||||
|
||||
}
|
||||
|
||||
function getVersionIcon(icon: VersionArrowProps["icon"]) {
|
||||
@@ -110,13 +109,13 @@ function getVersionActionLabel(icon: VersionArrowProps["icon"], target: Campaign
|
||||
const versionLabel = target ? `version #${target.version_number}` : "version";
|
||||
switch (icon) {
|
||||
case "first":
|
||||
return `Open first ${versionLabel}`;
|
||||
return i18nMessage("i18n:govoplan-campaign.open_first_value.ea8f5c5b", { value0: versionLabel });
|
||||
case "previous":
|
||||
return `Open previous ${versionLabel}`;
|
||||
return i18nMessage("i18n:govoplan-campaign.open_previous_value.e8d4794b", { value0: versionLabel });
|
||||
case "next":
|
||||
return `Open next ${versionLabel}`;
|
||||
return i18nMessage("i18n:govoplan-campaign.open_next_value.1315037e", { value0: versionLabel });
|
||||
case "latest":
|
||||
return `Open latest ${versionLabel}`;
|
||||
return i18nMessage("i18n:govoplan-campaign.open_latest_value.40bdb320", { value0: versionLabel });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ function resolveStep(value: StepValue): string | null | undefined {
|
||||
}
|
||||
|
||||
function defaultLoadedLabel(version: CampaignVersionDetail): string {
|
||||
return version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded";
|
||||
return version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "i18n:govoplan-campaign.loaded.6db90a0a";
|
||||
}
|
||||
|
||||
export function useCampaignDraftEditor({
|
||||
@@ -70,7 +70,7 @@ export function useCampaignDraftEditor({
|
||||
|
||||
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saveState, setSaveState] = useState("Loaded");
|
||||
const [saveState, setSaveState] = useState("i18n:govoplan-campaign.loaded.6db90a0a");
|
||||
const [localError, setLocalError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -97,7 +97,7 @@ export function useCampaignDraftEditor({
|
||||
|
||||
const saveDraft = useCallback(async (_mode: "auto" | "manual" = "manual"): Promise<boolean> => {
|
||||
if (!draft || !version || locked) return false;
|
||||
setSaveState("Saving…");
|
||||
setSaveState("i18n:govoplan-campaign.saving.56a2285c");
|
||||
setError("");
|
||||
setLocalError("");
|
||||
try {
|
||||
@@ -119,7 +119,7 @@ export function useCampaignDraftEditor({
|
||||
} catch (err) {
|
||||
const text = err instanceof Error ? err.message : String(err);
|
||||
setLocalError(text);
|
||||
setSaveState("Save failed");
|
||||
setSaveState("i18n:govoplan-campaign.save_failed.0a444467");
|
||||
return false;
|
||||
}
|
||||
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, setError, settings, version, workflowState]);
|
||||
@@ -147,4 +147,4 @@ export function useCampaignDraftEditor({
|
||||
markDirty,
|
||||
saveDraft
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useDeltaWatermarks } from "@govoplan/core-webui";
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import {
|
||||
getCampaignWorkspace
|
||||
getCampaignWorkspaceDelta,
|
||||
type CampaignWorkspaceDeltaResponse
|
||||
} from "../../../api/campaigns";
|
||||
import type { CampaignWorkspaceData } from "../utils/campaignView";
|
||||
|
||||
@@ -34,6 +36,21 @@ export function useCampaignWorkspaceData(
|
||||
const [data, setData] = useState<CampaignWorkspaceData>(initialData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const dataRef = useRef<CampaignWorkspaceData>(initialData);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const queryKey = useMemo(
|
||||
() => JSON.stringify({
|
||||
campaignId,
|
||||
selectedVersionId,
|
||||
includeCurrentVersion,
|
||||
includeSummary,
|
||||
includeVersions,
|
||||
apiBaseUrl: settings.apiBaseUrl,
|
||||
apiKey: settings.apiKey,
|
||||
accessToken: settings.accessToken
|
||||
}),
|
||||
[campaignId, selectedVersionId, includeCurrentVersion, includeSummary, includeVersions, settings.apiBaseUrl, settings.apiKey, settings.accessToken]
|
||||
);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!campaignId) return;
|
||||
@@ -41,26 +58,39 @@ export function useCampaignWorkspaceData(
|
||||
setError("");
|
||||
try {
|
||||
const shouldLoadVersions = includeCurrentVersion || includeVersions;
|
||||
const response = await getCampaignWorkspace(settings, campaignId, {
|
||||
versionId: selectedVersionId,
|
||||
includeCurrentVersion,
|
||||
includeSummary,
|
||||
includeVersions: shouldLoadVersions,
|
||||
});
|
||||
|
||||
setData({
|
||||
campaign: response.campaign,
|
||||
versions: response.versions,
|
||||
currentVersion: response.current_version,
|
||||
summary: response.summary
|
||||
});
|
||||
let nextWatermark = getDeltaWatermark(queryKey);
|
||||
let merged: CampaignWorkspaceData = dataRef.current;
|
||||
let hasMore = false;
|
||||
do {
|
||||
const response = await getCampaignWorkspaceDelta(settings, campaignId, {
|
||||
versionId: selectedVersionId,
|
||||
includeCurrentVersion,
|
||||
includeSummary,
|
||||
includeVersions: shouldLoadVersions,
|
||||
since: nextWatermark,
|
||||
});
|
||||
merged = mergeWorkspaceDelta(merged, response);
|
||||
nextWatermark = response.watermark ?? null;
|
||||
hasMore = response.has_more;
|
||||
} while (hasMore);
|
||||
setDeltaWatermark(queryKey, nextWatermark);
|
||||
dataRef.current = merged;
|
||||
setData(merged);
|
||||
} catch (err) {
|
||||
dataRef.current = initialData;
|
||||
setData(initialData);
|
||||
resetDeltaWatermark(queryKey);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [settings, campaignId, includeCurrentVersion, includeSummary, includeVersions, selectedVersionId]);
|
||||
}, [settings, campaignId, includeCurrentVersion, includeSummary, includeVersions, selectedVersionId, queryKey, getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark]);
|
||||
|
||||
useEffect(() => {
|
||||
resetDeltaWatermark(queryKey);
|
||||
dataRef.current = initialData;
|
||||
setData(initialData);
|
||||
}, [queryKey, resetDeltaWatermark]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
@@ -68,3 +98,34 @@ export function useCampaignWorkspaceData(
|
||||
|
||||
return { data, loading, error, reload, setError };
|
||||
}
|
||||
|
||||
function mergeWorkspaceDelta(current: CampaignWorkspaceData, response: CampaignWorkspaceDeltaResponse): CampaignWorkspaceData {
|
||||
if (response.full) {
|
||||
return {
|
||||
campaign: response.campaign,
|
||||
versions: response.versions,
|
||||
currentVersion: response.current_version,
|
||||
summary: response.summary
|
||||
};
|
||||
}
|
||||
|
||||
const deletedVersionIds = new Set(
|
||||
response.deleted
|
||||
.filter((item) => item.resource_type === "campaign_version")
|
||||
.map((item) => item.id)
|
||||
);
|
||||
const versionMap = new Map(current.versions.map((version) => [version.id, version]));
|
||||
for (const version of response.versions) {
|
||||
versionMap.set(version.id, version);
|
||||
}
|
||||
for (const versionId of deletedVersionIds) {
|
||||
versionMap.delete(versionId);
|
||||
}
|
||||
const currentVersionDeleted = current.currentVersion ? deletedVersionIds.has(current.currentVersion.id) : false;
|
||||
return {
|
||||
campaign: response.campaign ?? current.campaign,
|
||||
versions: Array.from(versionMap.values()).sort((left, right) => (right.version_number ?? 0) - (left.version_number ?? 0)),
|
||||
currentVersion: response.current_version ?? (currentVersionDeleted ? null : current.currentVersion),
|
||||
summary: response.summary ?? current.summary
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const INLINE_MAIL_SETTINGS_BLOCKED_MESSAGE = "Inline SMTP/IMAP settings are blocked by the effective mail policy. Select an allowed reusable profile.";
|
||||
export const INLINE_MAIL_SETTINGS_BLOCKED_MESSAGE = "i18n:govoplan-campaign.inline_smtp_imap_settings_are_blocked_by_the_eff.90c94538";
|
||||
|
||||
export type CampaignMailPolicy = {
|
||||
allow_campaign_profiles?: boolean | null;
|
||||
@@ -17,11 +17,11 @@ export function campaignMailSettingsPolicyState({
|
||||
effectivePolicy,
|
||||
selectedProfileId,
|
||||
locked = false
|
||||
}: {
|
||||
effectivePolicy: CampaignMailPolicy | null | undefined;
|
||||
selectedProfileId: string | null | undefined;
|
||||
locked?: boolean;
|
||||
}): CampaignMailSettingsPolicyState {
|
||||
|
||||
|
||||
|
||||
|
||||
}: {effectivePolicy: CampaignMailPolicy | null | undefined;selectedProfileId: string | null | undefined;locked?: boolean;}): CampaignMailSettingsPolicyState {
|
||||
const campaignProfilesAllowed = effectivePolicy?.allow_campaign_profiles === true;
|
||||
const usingMailProfile = Boolean(selectedProfileId);
|
||||
const inlineMailSettingsBlocked = !campaignProfilesAllowed && !usingMailProfile;
|
||||
@@ -33,4 +33,4 @@ export function campaignMailSettingsPolicyState({
|
||||
canSelectInlineSettings: !locked && campaignProfilesAllowed,
|
||||
inlineBlockedMessage: INLINE_MAIL_SETTINGS_BLOCKED_MESSAGE
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ export function parseManagedAttachmentSource(value: unknown): ManagedAttachmentS
|
||||
if (typeof value !== "string" || !value.startsWith(MANAGED_ATTACHMENT_SOURCE_PREFIX)) return null;
|
||||
const [, ownerType, ...ownerIdParts] = value.split(":");
|
||||
const ownerId = ownerIdParts.join(":").trim();
|
||||
if ((ownerType !== "user" && ownerType !== "group") || !ownerId) return null;
|
||||
if (ownerType !== "user" && ownerType !== "group" || !ownerId) return null;
|
||||
return { ownerType, ownerId };
|
||||
}
|
||||
|
||||
@@ -135,12 +135,12 @@ export type MockAttachmentPathOption = Partial<AttachmentBasePath> & {
|
||||
};
|
||||
|
||||
export const mockAttachmentPathOptions: MockAttachmentPathOption[] = [
|
||||
{ label: "Campaign attachments", path: "attachments" },
|
||||
{ label: "Campaign root", path: "." },
|
||||
{ label: "Shared group files", path: "group/shared" },
|
||||
{ label: "Tenant templates", path: "tenant/templates" },
|
||||
{ label: "Personal upload area", path: "user/uploads" }
|
||||
];
|
||||
{ label: "i18n:govoplan-campaign.campaign_attachments.926bcbe6", path: "attachments" },
|
||||
{ label: "i18n:govoplan-campaign.campaign_root.7eccd949", path: "." },
|
||||
{ label: "i18n:govoplan-campaign.shared_group_files.fffa6e27", path: "group/shared" },
|
||||
{ label: "i18n:govoplan-campaign.tenant_templates.ac6653bf", path: "tenant/templates" },
|
||||
{ label: "i18n:govoplan-campaign.personal_upload_area.babd7b7f", path: "user/uploads" }];
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -205,11 +205,11 @@ export function attachmentRuleUsesBasePath(rule: AttachmentRule, basePath: Attac
|
||||
|
||||
export function countIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): number {
|
||||
const entries = asRecord(entriesValue);
|
||||
return asArray(entries.inline)
|
||||
.map(asRecord)
|
||||
.flatMap((entry) => normalizeAttachmentRules(entry.attachments))
|
||||
.filter((rule) => attachmentRuleUsesBasePath(rule, basePath))
|
||||
.length;
|
||||
return asArray(entries.inline).
|
||||
map(asRecord).
|
||||
flatMap((entry) => normalizeAttachmentRules(entry.attachments)).
|
||||
filter((rule) => attachmentRuleUsesBasePath(rule, basePath)).
|
||||
length;
|
||||
}
|
||||
|
||||
export function removeIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): Record<string, unknown> {
|
||||
@@ -261,10 +261,10 @@ export function summarizeAttachmentRules(rules: AttachmentRule[]): AttachmentSum
|
||||
|
||||
export function countIndividualAttachmentRules(entriesValue: unknown): number {
|
||||
const entries = asRecord(entriesValue);
|
||||
return asArray(entries.inline)
|
||||
.map(asRecord)
|
||||
.flatMap((entry) => asArray(entry.attachments))
|
||||
.length;
|
||||
return asArray(entries.inline).
|
||||
map(asRecord).
|
||||
flatMap((entry) => asArray(entry.attachments)).
|
||||
length;
|
||||
}
|
||||
|
||||
export function isDirectAttachmentRule(rule: AttachmentRule): boolean {
|
||||
|
||||
@@ -177,9 +177,9 @@ export function buildImportTable(text: string, options: CsvParseOptions): Recipi
|
||||
}
|
||||
|
||||
export function buildImportTableFromRows(
|
||||
sourceRows: string[][],
|
||||
options: { headerRows: number; delimiter?: "," | ";" | "\t" }
|
||||
): RecipientImportTable {
|
||||
sourceRows: string[][],
|
||||
options: {headerRows: number;delimiter?: "," | ";" | "\t";})
|
||||
: RecipientImportTable {
|
||||
const rows = sourceRows.map((row) => row.map((cell) => String(cell ?? "")));
|
||||
const normalizedHeaderRows = Math.max(0, Math.min(Math.floor(options.headerRows || 0), rows.length));
|
||||
const headerRows = rows.slice(0, normalizedHeaderRows);
|
||||
@@ -269,37 +269,37 @@ export function recipientImportHeaderFingerprints(headers: string[]): Pick<Recip
|
||||
|
||||
export function matchRecipientMappingProfiles(table: RecipientImportTable, profiles: RecipientMappingProfile[]): RecipientMappingProfileMatch[] {
|
||||
const fingerprints = recipientImportHeaderFingerprints(table.headers);
|
||||
return profiles
|
||||
.map((profile): RecipientMappingProfileMatch | null => {
|
||||
const diff = headerSetDiff(fingerprints.normalizedHeaders, profile.normalizedHeaders);
|
||||
const exactOrdered = fingerprints.orderedHeaderFingerprint === profile.orderedHeaderFingerprint;
|
||||
const exactUnordered = fingerprints.unorderedHeaderFingerprint === profile.unorderedHeaderFingerprint;
|
||||
const mode: RecipientMappingProfileMatchMode = exactOrdered ? "ordered" : exactUnordered ? "unordered" : "similar";
|
||||
const baseScore = exactOrdered ? 1 : exactUnordered ? 0.96 : diff.matchedHeaders / Math.max(fingerprints.normalizedHeaders.length, profile.normalizedHeaders.length, 1);
|
||||
const mappedMissingCount = missingMappedHeaders(profile, fingerprints.normalizedHeaders).length;
|
||||
const score = mode === "similar" ? Math.max(0, baseScore - Math.min(0.25, mappedMissingCount * 0.05)) : baseScore;
|
||||
if (mode === "similar" && score < 0.45) return null;
|
||||
return {
|
||||
profile,
|
||||
mode,
|
||||
score,
|
||||
matchedHeaders: diff.matchedHeaders,
|
||||
missingHeaders: diff.missingHeaders,
|
||||
extraHeaders: diff.extraHeaders
|
||||
};
|
||||
})
|
||||
.filter((match): match is RecipientMappingProfileMatch => match !== null)
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) return right.score - left.score;
|
||||
return right.profile.updatedAt.localeCompare(left.profile.updatedAt);
|
||||
});
|
||||
return profiles.
|
||||
map((profile): RecipientMappingProfileMatch | null => {
|
||||
const diff = headerSetDiff(fingerprints.normalizedHeaders, profile.normalizedHeaders);
|
||||
const exactOrdered = fingerprints.orderedHeaderFingerprint === profile.orderedHeaderFingerprint;
|
||||
const exactUnordered = fingerprints.unorderedHeaderFingerprint === profile.unorderedHeaderFingerprint;
|
||||
const mode: RecipientMappingProfileMatchMode = exactOrdered ? "ordered" : exactUnordered ? "unordered" : "similar";
|
||||
const baseScore = exactOrdered ? 1 : exactUnordered ? 0.96 : diff.matchedHeaders / Math.max(fingerprints.normalizedHeaders.length, profile.normalizedHeaders.length, 1);
|
||||
const mappedMissingCount = missingMappedHeaders(profile, fingerprints.normalizedHeaders).length;
|
||||
const score = mode === "similar" ? Math.max(0, baseScore - Math.min(0.25, mappedMissingCount * 0.05)) : baseScore;
|
||||
if (mode === "similar" && score < 0.45) return null;
|
||||
return {
|
||||
profile,
|
||||
mode,
|
||||
score,
|
||||
matchedHeaders: diff.matchedHeaders,
|
||||
missingHeaders: diff.missingHeaders,
|
||||
extraHeaders: diff.extraHeaders
|
||||
};
|
||||
}).
|
||||
filter((match): match is RecipientMappingProfileMatch => match !== null).
|
||||
sort((left, right) => {
|
||||
if (right.score !== left.score) return right.score - left.score;
|
||||
return right.profile.updatedAt.localeCompare(left.profile.updatedAt);
|
||||
});
|
||||
}
|
||||
|
||||
export function applyRecipientMappingProfile(
|
||||
table: RecipientImportTable,
|
||||
profile: RecipientMappingProfile,
|
||||
existingFields: ImportFieldDefinition[]
|
||||
): RecipientColumnMapping[] {
|
||||
table: RecipientImportTable,
|
||||
profile: RecipientMappingProfile,
|
||||
existingFields: ImportFieldDefinition[])
|
||||
: RecipientColumnMapping[] {
|
||||
const defaults = defaultColumnMappings(table.headers, existingFields);
|
||||
const fingerprints = recipientImportHeaderFingerprints(table.headers);
|
||||
const profileMappings = normalizeMappingList(profile.mappings, profile.columnCount || profile.headers.length);
|
||||
@@ -325,10 +325,10 @@ export function applyRecipientMappingProfile(
|
||||
}
|
||||
|
||||
export function buildRecipientImportPreview(
|
||||
table: RecipientImportTable,
|
||||
mappings: RecipientColumnMapping[],
|
||||
options: RecipientImportPreviewOptions
|
||||
): RecipientImportPreview {
|
||||
table: RecipientImportTable,
|
||||
mappings: RecipientColumnMapping[],
|
||||
options: RecipientImportPreviewOptions)
|
||||
: RecipientImportPreview {
|
||||
const existingFields = options.existingFields;
|
||||
const existingFieldNames = new Set(existingFields.map((field) => field.name));
|
||||
const existingIds = new Set((options.existingEntries ?? []).map((entry) => String(entry.id || "")).filter(Boolean));
|
||||
@@ -337,45 +337,45 @@ export function buildRecipientImportPreview(
|
||||
const mappingsByColumn = new Map(mappings.map((mapping) => [mapping.columnIndex, mapping]));
|
||||
const valueSeparators = options.valueSeparators || ",;|";
|
||||
|
||||
const rows = table.dataRows
|
||||
.map((cells, index): ImportedRecipientRow | null => {
|
||||
if (cells.every((cell) => !cell.trim())) return null;
|
||||
const rowNumber = table.firstDataRowNumber + index;
|
||||
const addresses: ImportedRecipientRow["addresses"] = { from: [], to: [], cc: [], bcc: [], reply_to: [] };
|
||||
const fields: Record<string, string> = {};
|
||||
const patterns: string[] = [];
|
||||
const issues: string[] = [];
|
||||
let preferredId = "";
|
||||
let name = "";
|
||||
let active = true;
|
||||
const rows = table.dataRows.
|
||||
map((cells, index): ImportedRecipientRow | null => {
|
||||
if (cells.every((cell) => !cell.trim())) return null;
|
||||
const rowNumber = table.firstDataRowNumber + index;
|
||||
const addresses: ImportedRecipientRow["addresses"] = { from: [], to: [], cc: [], bcc: [], reply_to: [] };
|
||||
const fields: Record<string, string> = {};
|
||||
const patterns: string[] = [];
|
||||
const issues: string[] = [];
|
||||
let preferredId = "";
|
||||
let name = "";
|
||||
let active = true;
|
||||
|
||||
cells.forEach((rawValue, columnIndex) => {
|
||||
const value = String(rawValue ?? "").trim();
|
||||
if (!value) return;
|
||||
const mapping = mappingsByColumn.get(columnIndex) ?? { columnIndex, kind: "ignore" as const };
|
||||
switch (mapping.kind) {
|
||||
case "id":
|
||||
preferredId = value;
|
||||
break;
|
||||
case "active":
|
||||
active = parseOptionalBoolean(value, true);
|
||||
break;
|
||||
case "name":
|
||||
name = value;
|
||||
break;
|
||||
case "from":
|
||||
case "to":
|
||||
case "cc":
|
||||
case "bcc":
|
||||
case "reply_to":
|
||||
addresses[mapping.kind].push(...parseAddressCell(value, valueSeparators));
|
||||
break;
|
||||
case "field": {
|
||||
cells.forEach((rawValue, columnIndex) => {
|
||||
const value = String(rawValue ?? "").trim();
|
||||
if (!value) return;
|
||||
const mapping = mappingsByColumn.get(columnIndex) ?? { columnIndex, kind: "ignore" as const };
|
||||
switch (mapping.kind) {
|
||||
case "id":
|
||||
preferredId = value;
|
||||
break;
|
||||
case "active":
|
||||
active = parseOptionalBoolean(value, true);
|
||||
break;
|
||||
case "name":
|
||||
name = value;
|
||||
break;
|
||||
case "from":
|
||||
case "to":
|
||||
case "cc":
|
||||
case "bcc":
|
||||
case "reply_to":
|
||||
addresses[mapping.kind].push(...parseAddressCell(value, valueSeparators));
|
||||
break;
|
||||
case "field":{
|
||||
const fieldName = mapping.fieldName?.trim();
|
||||
if (fieldName) fields[fieldName] = value;
|
||||
break;
|
||||
}
|
||||
case "new_field": {
|
||||
case "new_field":{
|
||||
const fieldName = sanitizeFieldName(mapping.newFieldName || table.headers[columnIndex] || `column_${columnIndex + 1}`);
|
||||
if (fieldName) {
|
||||
fields[fieldName] = value;
|
||||
@@ -383,41 +383,41 @@ export function buildRecipientImportPreview(
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "attachment_pattern":
|
||||
patterns.push(...splitCell(value, valueSeparators));
|
||||
break;
|
||||
case "ignore":
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
if (name && addresses.to.length === 1 && !addresses.to[0].name) addresses.to[0].name = name;
|
||||
const primaryAddress = addresses.to[0] ?? addresses.cc[0] ?? addresses.bcc[0] ?? addresses.reply_to[0] ?? addresses.from[0] ?? null;
|
||||
const email = primaryAddress?.email ?? "";
|
||||
const id = uniqueId(preferredId || idFromEmail(email) || `recipient-${rowNumber}`, usedIds);
|
||||
usedIds.add(id);
|
||||
|
||||
for (const [key, list] of Object.entries(addresses)) {
|
||||
for (const address of list) {
|
||||
if (!address.email.includes("@")) issues.push(`${address.email || key} must contain @`);
|
||||
}
|
||||
case "attachment_pattern":
|
||||
patterns.push(...splitCell(value, valueSeparators));
|
||||
break;
|
||||
case "ignore":
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (addresses.to.length === 0) issues.push("Missing To address");
|
||||
});
|
||||
|
||||
return {
|
||||
rowNumber,
|
||||
id,
|
||||
name: name || primaryAddress?.name || "",
|
||||
email,
|
||||
active,
|
||||
addresses,
|
||||
fields,
|
||||
patterns,
|
||||
issues: [...new Set(issues)]
|
||||
};
|
||||
})
|
||||
.filter((row): row is ImportedRecipientRow => row !== null);
|
||||
if (name && addresses.to.length === 1 && !addresses.to[0].name) addresses.to[0].name = name;
|
||||
const primaryAddress = addresses.to[0] ?? addresses.cc[0] ?? addresses.bcc[0] ?? addresses.reply_to[0] ?? addresses.from[0] ?? null;
|
||||
const email = primaryAddress?.email ?? "";
|
||||
const id = uniqueId(preferredId || idFromEmail(email) || `recipient-${rowNumber}`, usedIds);
|
||||
usedIds.add(id);
|
||||
|
||||
for (const [key, list] of Object.entries(addresses)) {
|
||||
for (const address of list) {
|
||||
if (!address.email.includes("@")) issues.push(`${address.email || key} must contain @`);
|
||||
}
|
||||
}
|
||||
if (addresses.to.length === 0) issues.push("i18n:govoplan-campaign.missing_to_address.d4ff53b4");
|
||||
|
||||
return {
|
||||
rowNumber,
|
||||
id,
|
||||
name: name || primaryAddress?.name || "",
|
||||
email,
|
||||
active,
|
||||
addresses,
|
||||
fields,
|
||||
patterns,
|
||||
issues: [...new Set(issues)]
|
||||
};
|
||||
}).
|
||||
filter((row): row is ImportedRecipientRow => row !== null);
|
||||
|
||||
return {
|
||||
table,
|
||||
@@ -430,16 +430,16 @@ export function buildRecipientImportPreview(
|
||||
}
|
||||
|
||||
export function materializeRecipientImport(
|
||||
draft: Record<string, unknown>,
|
||||
preview: RecipientImportPreview,
|
||||
options: MaterializeImportOptions
|
||||
): Record<string, unknown> {
|
||||
draft: Record<string, unknown>,
|
||||
preview: RecipientImportPreview,
|
||||
options: MaterializeImportOptions)
|
||||
: Record<string, unknown> {
|
||||
const currentEntries = asRecord(draft.entries);
|
||||
const currentInlineEntries = asArray(currentEntries.inline).map(asRecord);
|
||||
const previousImports = asArray(currentEntries.imports).map(asRecord);
|
||||
const importedEntries = preview.rows
|
||||
.filter((row) => row.issues.length === 0)
|
||||
.map((row) => importedRowToEntry(row, options.attachmentBasePath));
|
||||
const importedEntries = preview.rows.
|
||||
filter((row) => row.issues.length === 0).
|
||||
map((row) => importedRowToEntry(row, options.attachmentBasePath));
|
||||
const nextInlineEntries = options.mode === "append" ? [...currentInlineEntries, ...importedEntries] : importedEntries;
|
||||
const nextEntries: ImportRecord = { ...currentEntries, inline: nextInlineEntries };
|
||||
if (options.provenance) nextEntries.imports = [...previousImports, options.provenance];
|
||||
@@ -537,15 +537,15 @@ function importedRowToEntry(row: ImportedRecipientRow, attachmentBasePath?: Impo
|
||||
function mergeFieldDefinitions(fieldsValue: unknown, fieldNamesToCreate: string[]): Record<string, unknown>[] {
|
||||
const existing = asArray(fieldsValue).map(asRecord);
|
||||
const existingNames = new Set(existing.map((field) => getText(field, "name") || getText(field, "id")).filter(Boolean));
|
||||
const additions = fieldNamesToCreate
|
||||
.filter((name) => !existingNames.has(name))
|
||||
.map((name) => ({
|
||||
name,
|
||||
label: humanizeFieldName(name),
|
||||
type: "string",
|
||||
required: false,
|
||||
can_override: true
|
||||
}));
|
||||
const additions = fieldNamesToCreate.
|
||||
filter((name) => !existingNames.has(name)).
|
||||
map((name) => ({
|
||||
name,
|
||||
label: humanizeFieldName(name),
|
||||
type: "string",
|
||||
required: false,
|
||||
can_override: true
|
||||
}));
|
||||
return [...existing, ...additions];
|
||||
}
|
||||
|
||||
@@ -594,9 +594,9 @@ export function parseDelimitedText(text: string, delimiter: "," | ";" | "\t", qu
|
||||
function detectDelimiter(text: string, quoted: boolean): "," | ";" | "\t" {
|
||||
const firstLine = text.split(/\r?\n/, 1)[0] ?? "";
|
||||
const candidates: Array<"," | ";" | "\t"> = [",", ";", "\t"];
|
||||
return candidates
|
||||
.map((delimiter) => ({ delimiter, cells: parseDelimitedText(firstLine, delimiter, quoted)[0]?.length ?? 1 }))
|
||||
.sort((left, right) => right.cells - left.cells)[0]?.delimiter ?? ",";
|
||||
return candidates.
|
||||
map((delimiter) => ({ delimiter, cells: parseDelimitedText(firstLine, delimiter, quoted)[0]?.length ?? 1 })).
|
||||
sort((left, right) => right.cells - left.cells)[0]?.delimiter ?? ",";
|
||||
}
|
||||
|
||||
function headerForColumn(headerRows: string[][], columnIndex: number): string {
|
||||
@@ -636,7 +636,7 @@ function stableHash(value: string): string {
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
function headerSetDiff(currentHeaders: string[], profileHeaders: string[]): { matchedHeaders: number; missingHeaders: string[]; extraHeaders: string[] } {
|
||||
function headerSetDiff(currentHeaders: string[], profileHeaders: string[]): {matchedHeaders: number;missingHeaders: string[];extraHeaders: string[];} {
|
||||
const currentCounts = countHeaders(currentHeaders);
|
||||
const profileCounts = countHeaders(profileHeaders);
|
||||
let matchedHeaders = 0;
|
||||
@@ -664,10 +664,10 @@ function countHeaders(headers: string[]): Map<string, number> {
|
||||
|
||||
function missingMappedHeaders(profile: RecipientMappingProfile, currentHeaders: string[]): string[] {
|
||||
const currentCounts = countHeaders(currentHeaders);
|
||||
return normalizeMappingList(profile.mappings, profile.columnCount || profile.headers.length)
|
||||
.filter((mapping) => mapping.kind !== "ignore")
|
||||
.map((mapping) => profile.normalizedHeaders[mapping.columnIndex] ?? "")
|
||||
.filter((header) => header && !currentCounts.has(header));
|
||||
return normalizeMappingList(profile.mappings, profile.columnCount || profile.headers.length).
|
||||
filter((mapping) => mapping.kind !== "ignore").
|
||||
map((mapping) => profile.normalizedHeaders[mapping.columnIndex] ?? "").
|
||||
filter((header) => header && !currentCounts.has(header));
|
||||
}
|
||||
|
||||
function normalizeMappingList(mappings: RecipientColumnMapping[], columnCount: number): RecipientColumnMapping[] {
|
||||
@@ -684,12 +684,12 @@ function normalizeMappingShape(mapping: RecipientColumnMapping | undefined, colu
|
||||
}
|
||||
|
||||
function normalizeProfileMapping(
|
||||
mapping: RecipientColumnMapping | undefined,
|
||||
columnIndex: number,
|
||||
header: string,
|
||||
existingFields: ImportFieldDefinition[],
|
||||
fallback: RecipientColumnMapping | undefined
|
||||
): RecipientColumnMapping {
|
||||
mapping: RecipientColumnMapping | undefined,
|
||||
columnIndex: number,
|
||||
header: string,
|
||||
existingFields: ImportFieldDefinition[],
|
||||
fallback: RecipientColumnMapping | undefined)
|
||||
: RecipientColumnMapping {
|
||||
const fallbackMapping = fallback ? { ...fallback, columnIndex } : { columnIndex, kind: "ignore" as const };
|
||||
if (!mapping) return fallbackMapping;
|
||||
const existingFieldNames = new Set(existingFields.map((field) => field.name));
|
||||
@@ -707,25 +707,25 @@ function normalizeProfileMapping(
|
||||
}
|
||||
|
||||
function isPatternColumn(key: string): boolean {
|
||||
return key === "pattern"
|
||||
|| key === "patterns"
|
||||
|| key === "file"
|
||||
|| key === "files"
|
||||
|| key === "file_pattern"
|
||||
|| key === "file_patterns"
|
||||
|| key === "attachment"
|
||||
|| key === "attachments"
|
||||
|| key === "attachment_pattern"
|
||||
|| key === "attachment_patterns"
|
||||
|| /^pattern_\d+$/.test(key)
|
||||
|| /^file_\d+$/.test(key)
|
||||
|| /^attachment_\d+$/.test(key);
|
||||
return key === "pattern" ||
|
||||
key === "patterns" ||
|
||||
key === "file" ||
|
||||
key === "files" ||
|
||||
key === "file_pattern" ||
|
||||
key === "file_patterns" ||
|
||||
key === "attachment" ||
|
||||
key === "attachments" ||
|
||||
key === "attachment_pattern" ||
|
||||
key === "attachment_patterns" ||
|
||||
/^pattern_\d+$/.test(key) ||
|
||||
/^file_\d+$/.test(key) ||
|
||||
/^attachment_\d+$/.test(key);
|
||||
}
|
||||
|
||||
function parseAddressCell(value: string, separators: string): ImportedAddress[] {
|
||||
return splitCell(value, separators)
|
||||
.map((item) => parseAddress(item))
|
||||
.filter((address) => Boolean(address.email));
|
||||
return splitCell(value, separators).
|
||||
map((item) => parseAddress(item)).
|
||||
filter((address) => Boolean(address.email));
|
||||
}
|
||||
|
||||
function parseAddress(value: string): ImportedAddress {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatDateTime as formatPlatformDateTime } from "@govoplan/core-webui";
|
||||
import { formatDateTime as formatPlatformDateTime, i18nMessage } from "@govoplan/core-webui";
|
||||
import type { CampaignListItem } from "../../../types";
|
||||
import type { CampaignSummary, CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
|
||||
|
||||
@@ -54,8 +54,8 @@ export function getFields(version: CampaignVersionDetail | null): unknown[] {
|
||||
}
|
||||
|
||||
export function userLockState(
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null
|
||||
): "temporary" | "permanent" | null {
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null)
|
||||
: "temporary" | "permanent" | null {
|
||||
if (!version) return null;
|
||||
if (version.user_lock_state === "temporary" || version.user_lock_state === "permanent") {
|
||||
return version.user_lock_state;
|
||||
@@ -79,18 +79,18 @@ export function isUserLockedVersion(version: CampaignVersionDetail | CampaignVer
|
||||
export function isFinalLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||
if (!version) return false;
|
||||
return [
|
||||
"queued",
|
||||
"sending",
|
||||
"sent",
|
||||
"completed",
|
||||
"partially_completed",
|
||||
"outcome_unknown",
|
||||
"failed",
|
||||
"failed_partial",
|
||||
"partially_sent",
|
||||
"archived",
|
||||
"cancelled"
|
||||
].includes((version.workflow_state ?? "").toLowerCase());
|
||||
"queued",
|
||||
"sending",
|
||||
"sent",
|
||||
"completed",
|
||||
"partially_completed",
|
||||
"outcome_unknown",
|
||||
"failed",
|
||||
"failed_partial",
|
||||
"partially_sent",
|
||||
"archived",
|
||||
"cancelled"].
|
||||
includes((version.workflow_state ?? "").toLowerCase());
|
||||
}
|
||||
|
||||
export function isVersionReadyForDelivery(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||
@@ -108,16 +108,16 @@ export function canUnlockValidationVersion(version: CampaignVersionDetail | Camp
|
||||
}
|
||||
|
||||
export function isHistoricalCampaignVersion(
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
currentVersionId?: string | null,
|
||||
): boolean {
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
currentVersionId?: string | null)
|
||||
: boolean {
|
||||
return Boolean(version && currentVersionId && version.id !== currentVersionId);
|
||||
}
|
||||
|
||||
export function isAuditLockedVersion(
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
currentVersionId?: string | null,
|
||||
): boolean {
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
currentVersionId?: string | null)
|
||||
: boolean {
|
||||
if (!version) return false;
|
||||
if (isHistoricalCampaignVersion(version, currentVersionId)) return true;
|
||||
if (version.locked_at || isUserLockedVersion(version)) return true;
|
||||
@@ -125,31 +125,31 @@ export function isAuditLockedVersion(
|
||||
}
|
||||
|
||||
export function versionLockReason(
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
currentVersionId?: string | null,
|
||||
): string {
|
||||
if (!version) return "No campaign version is loaded.";
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
currentVersionId?: string | null)
|
||||
: string {
|
||||
if (!version) return "i18n:govoplan-campaign.no_campaign_version_is_loaded.93f4835b";
|
||||
if (isHistoricalCampaignVersion(version, currentVersionId)) {
|
||||
return "Historical campaign versions are review-only. Continue work in the current version or create a new working copy after the current version becomes immutable.";
|
||||
return "i18n:govoplan-campaign.historical_campaign_versions_are_review_only_con.c35b96af";
|
||||
}
|
||||
if (isTemporaryUserLockedVersion(version)) {
|
||||
return `Temporarily user-locked at ${formatDateTime(version.user_locked_at)}. Authorized users may unlock it or make the lock permanent.`;
|
||||
return i18nMessage("i18n:govoplan-campaign.temporarily_user_locked_at_value_authorized_user.7154865b", { value0: formatDateTime(version.user_locked_at) });
|
||||
}
|
||||
if (isPermanentUserLockedVersion(version)) {
|
||||
return `Permanently user-locked at ${formatDateTime(version.user_locked_at ?? version.published_at)}. It cannot be unlocked; create an editable copy.`;
|
||||
return i18nMessage("i18n:govoplan-campaign.permanently_user_locked_at_value_it_cannot_be_un.bf843a0f", { value0: formatDateTime(version.user_locked_at ?? version.published_at) });
|
||||
}
|
||||
if (canUnlockValidationVersion(version)) {
|
||||
return `Temporarily locked by validation at ${formatDateTime(version.locked_at)}. Unlocking invalidates validation, build and queue state.`;
|
||||
return i18nMessage("i18n:govoplan-campaign.temporarily_locked_by_validation_at_value_unlock.2f1ace87", { value0: formatDateTime(version.locked_at) });
|
||||
}
|
||||
if (isFinalLockedVersion(version)) return `Permanently locked by delivery/final state: ${humanize(version.workflow_state ?? "locked")}.`;
|
||||
if (version.locked_at) return `Temporarily locked at ${formatDateTime(version.locked_at)}.`;
|
||||
return "Editable working version.";
|
||||
if (isFinalLockedVersion(version)) return i18nMessage("i18n:govoplan-campaign.permanently_locked_by_delivery_final_state_value.e31278ae", { value0: humanize(version.workflow_state ?? "locked") });
|
||||
if (version.locked_at) return i18nMessage("i18n:govoplan-campaign.temporarily_locked_at_value.3ce9192e", { value0: formatDateTime(version.locked_at) });
|
||||
return "i18n:govoplan-campaign.editable_working_version.379f898a";
|
||||
}
|
||||
|
||||
export function currentStepLabel(version: CampaignVersionDetail | CampaignVersionListItem | null): string {
|
||||
if (!version) return "—";
|
||||
const flow = version.current_flow || "manual";
|
||||
const step = version.current_step || "not set";
|
||||
const step = version.current_step || "i18n:govoplan-campaign.not_set.ef374c57";
|
||||
return `${humanize(flow)} / ${humanize(step)}`;
|
||||
}
|
||||
|
||||
@@ -197,10 +197,10 @@ export function stringifyPreview(value: unknown, maxLength = 220): string {
|
||||
}
|
||||
|
||||
export function cloneCampaignJsonForCopy(
|
||||
source: Record<string, unknown>,
|
||||
campaign: CampaignListItem | null,
|
||||
stamp: string
|
||||
): { externalId: string; name: string; description: string; rawJson: Record<string, unknown> } {
|
||||
source: Record<string, unknown>,
|
||||
campaign: CampaignListItem | null,
|
||||
stamp: string)
|
||||
: {externalId: string;name: string;description: string;rawJson: Record<string, unknown>;} {
|
||||
const rawJson = JSON.parse(JSON.stringify(source)) as Record<string, unknown>;
|
||||
const campaignSection = asRecord(rawJson.campaign);
|
||||
const baseId = String(campaignSection.id || campaign?.external_id || campaign?.id || "campaign");
|
||||
|
||||
@@ -14,7 +14,7 @@ export type ImportFileLinkCapability = Pick<Required<FilesFileExplorerUiCapabili
|
||||
export type ImportFileLinkResolution = {
|
||||
basePath: AttachmentBasePath;
|
||||
patterns: RenderedImportPattern[];
|
||||
matchedPatterns: { pattern: RenderedImportPattern; matches: FilesManagedFile[] }[];
|
||||
matchedPatterns: {pattern: RenderedImportPattern;matches: FilesManagedFile[];}[];
|
||||
unmatchedPatterns: RenderedImportPattern[];
|
||||
files: FilesManagedFile[];
|
||||
linkedFiles: FilesManagedFile[];
|
||||
@@ -22,15 +22,15 @@ export type ImportFileLinkResolution = {
|
||||
};
|
||||
|
||||
export async function resolveImportedAttachmentLinks(
|
||||
filesCapability: ImportFileLinkCapability,
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
basePath: AttachmentBasePath,
|
||||
preview: RecipientImportPreview
|
||||
): Promise<ImportFileLinkResolution> {
|
||||
filesCapability: ImportFileLinkCapability,
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
basePath: AttachmentBasePath,
|
||||
preview: RecipientImportPreview)
|
||||
: Promise<ImportFileLinkResolution> {
|
||||
const parsedSource = parseManagedAttachmentSource(basePath.source);
|
||||
if (!parsedSource) {
|
||||
throw new Error("The selected attachment source is not connected to a managed file space.");
|
||||
throw new Error("i18n:govoplan-campaign.the_selected_attachment_source_is_not_connected_.965a0056");
|
||||
}
|
||||
|
||||
const patterns = renderedImportPatterns(preview);
|
||||
@@ -40,25 +40,25 @@ export async function resolveImportedAttachmentLinks(
|
||||
|
||||
const root = normalizedPathPrefix(basePath.path);
|
||||
const [resolved, visibleFiles] = await Promise.all([
|
||||
filesCapability.resolveFilePatterns(settings, {
|
||||
patterns: patterns.map((item) => item.renderedPattern),
|
||||
owner_type: parsedSource.ownerType,
|
||||
owner_id: parsedSource.ownerId,
|
||||
path_prefix: root || undefined,
|
||||
include_unmatched: false
|
||||
}),
|
||||
filesCapability.listFiles(settings, {
|
||||
owner_type: parsedSource.ownerType,
|
||||
owner_id: parsedSource.ownerId,
|
||||
path_prefix: root || undefined
|
||||
})
|
||||
]);
|
||||
filesCapability.resolveFilePatterns(settings, {
|
||||
patterns: patterns.map((item) => item.renderedPattern),
|
||||
owner_type: parsedSource.ownerType,
|
||||
owner_id: parsedSource.ownerId,
|
||||
path_prefix: root || undefined,
|
||||
include_unmatched: false
|
||||
}),
|
||||
filesCapability.listFiles(settings, {
|
||||
owner_type: parsedSource.ownerType,
|
||||
owner_id: parsedSource.ownerId,
|
||||
path_prefix: root || undefined
|
||||
})]
|
||||
);
|
||||
|
||||
const visibleById = new Map(visibleFiles.files.map((file) => [file.id, file]));
|
||||
const fileById = new Map<string, FilesManagedFile>();
|
||||
const matchedPatterns = patterns.map((pattern, index) => {
|
||||
const matches = (resolved.patterns[index]?.matches ?? [])
|
||||
.map((file) => visibleById.get(file.id) ?? file);
|
||||
const matches = (resolved.patterns[index]?.matches ?? []).
|
||||
map((file) => visibleById.get(file.id) ?? file);
|
||||
matches.forEach((file) => fileById.set(file.id, file));
|
||||
return { pattern, matches };
|
||||
});
|
||||
@@ -86,16 +86,16 @@ export async function bulkLinkFilesToCampaign(filesCapability: ImportFileLinkCap
|
||||
|
||||
export function renderedImportPatterns(preview: RecipientImportPreview): RenderedImportPattern[] {
|
||||
const byKey = new Map<string, RenderedImportPattern>();
|
||||
preview.rows
|
||||
.filter((row) => row.issues.length === 0)
|
||||
.forEach((row) => {
|
||||
row.patterns.forEach((sourcePattern) => {
|
||||
const renderedPattern = renderPatternForImportedRow(sourcePattern, row).trim();
|
||||
if (!renderedPattern) return;
|
||||
const key = `${row.rowNumber}:${sourcePattern}:${renderedPattern}`;
|
||||
if (!byKey.has(key)) byKey.set(key, { key, rowNumber: row.rowNumber, sourcePattern, renderedPattern });
|
||||
});
|
||||
preview.rows.
|
||||
filter((row) => row.issues.length === 0).
|
||||
forEach((row) => {
|
||||
row.patterns.forEach((sourcePattern) => {
|
||||
const renderedPattern = renderPatternForImportedRow(sourcePattern, row).trim();
|
||||
if (!renderedPattern) return;
|
||||
const key = `${row.rowNumber}:${sourcePattern}:${renderedPattern}`;
|
||||
if (!byKey.has(key)) byKey.set(key, { key, rowNumber: row.rowNumber, sourcePattern, renderedPattern });
|
||||
});
|
||||
});
|
||||
return [...byKey.values()];
|
||||
}
|
||||
|
||||
@@ -142,4 +142,4 @@ function normalizedPathPrefix(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed || trimmed === "." || trimmed === "/") return "";
|
||||
return trimmed.replace(/^[\/]+|[\/]+$/g, "");
|
||||
}
|
||||
}
|
||||
53
webui/src/features/campaigns/utils/jobDeltas.ts
Normal file
53
webui/src/features/campaigns/utils/jobDeltas.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { mergeDeltaRows } from "@govoplan/core-webui";
|
||||
import type { CampaignJobsDeltaResponse, CampaignJobsResponse } from "../../../api/campaigns";
|
||||
|
||||
export function emptyCampaignJobsResponse(): CampaignJobsResponse {
|
||||
return {
|
||||
jobs: [],
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
total: 0,
|
||||
total_unfiltered: 0,
|
||||
pages: 0,
|
||||
cursor: null,
|
||||
next_cursor: null,
|
||||
counts: {},
|
||||
filtered_counts: {},
|
||||
review: {}
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeCampaignJobsDelta(current: CampaignJobsResponse, response: CampaignJobsDeltaResponse): CampaignJobsResponse {
|
||||
if (response.full) return campaignJobsResponseFromDelta(response);
|
||||
return {
|
||||
...campaignJobsResponseFromDelta(response),
|
||||
jobs: mergeDeltaRows(current.jobs, response.jobs, response.deleted, (row) => String(row.id ?? ""), {
|
||||
deletedResourceType: "campaign_job",
|
||||
sort: sortJobsByEntry
|
||||
}),
|
||||
cursor: response.cursor ?? current.cursor,
|
||||
next_cursor: response.next_cursor ?? current.next_cursor
|
||||
};
|
||||
}
|
||||
|
||||
export function campaignJobsResponseFromDelta(response: CampaignJobsDeltaResponse): CampaignJobsResponse {
|
||||
return {
|
||||
jobs: response.jobs,
|
||||
page: response.page,
|
||||
page_size: response.page_size,
|
||||
total: response.total,
|
||||
total_unfiltered: response.total_unfiltered,
|
||||
pages: response.pages,
|
||||
cursor: response.cursor,
|
||||
next_cursor: response.next_cursor,
|
||||
counts: response.counts,
|
||||
filtered_counts: response.filtered_counts,
|
||||
review: response.review
|
||||
};
|
||||
}
|
||||
|
||||
function sortJobsByEntry(left: Record<string, unknown>, right: Record<string, unknown>): number {
|
||||
const indexDelta = Number(left.entry_index ?? 0) - Number(right.entry_index ?? 0);
|
||||
if (indexDelta !== 0) return indexDelta;
|
||||
return String(left.id ?? "").localeCompare(String(right.id ?? ""));
|
||||
}
|
||||
@@ -14,17 +14,17 @@ import { useCampaignDraftEditor } from "../hooks/useCampaignDraftEditor";
|
||||
import { AttachmentsStep, BasicsStep, FieldsStep, RecipientsStep, ReviewStep, SenderStep, SendStep, TemplateStep } from "./steps/CreateWizardSteps";
|
||||
|
||||
const steps: WizardStep[] = [
|
||||
{ id: "basics", label: "Basics", description: "Name and scenario" },
|
||||
{ id: "sender", label: "Sender", description: "Mail account and headers" },
|
||||
{ id: "fields", label: "Fields", description: "Define campaign data" },
|
||||
{ id: "recipients", label: "Recipients", description: "Import and map source data" },
|
||||
{ id: "template", label: "Template", description: "Subject and body" },
|
||||
{ id: "attachments", label: "Attachments", description: "Rules and ZIP options" },
|
||||
{ id: "review", label: "Review", description: "Validate before build" },
|
||||
{ id: "send", label: "Send", description: "Test and queue" }
|
||||
];
|
||||
{ id: "basics", label: "i18n:govoplan-campaign.basics.5fcebeef", description: "i18n:govoplan-campaign.name_and_scenario.f2bc5241" },
|
||||
{ id: "sender", label: "i18n:govoplan-campaign.sender.17b874d2", description: "i18n:govoplan-campaign.mail_account_and_headers.9e269243" },
|
||||
{ id: "fields", label: "i18n:govoplan-campaign.fields.e8b68527", description: "i18n:govoplan-campaign.define_campaign_data.7045c0a4" },
|
||||
{ id: "recipients", label: "i18n:govoplan-campaign.recipients.78cbf8eb", description: "i18n:govoplan-campaign.import_and_map_source_data.b875d471" },
|
||||
{ id: "template", label: "i18n:govoplan-campaign.template.3ec1ae06", description: "i18n:govoplan-campaign.subject_and_body.915da39b" },
|
||||
{ id: "attachments", label: "i18n:govoplan-campaign.attachments.6771ade6", description: "i18n:govoplan-campaign.rules_and_zip_options.e3656774" },
|
||||
{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", description: "i18n:govoplan-campaign.validate_before_build.27723ad7" },
|
||||
{ id: "send", label: "i18n:govoplan-campaign.send.9bc2575c", description: "i18n:govoplan-campaign.test_and_queue.c3c940e4" }];
|
||||
|
||||
export default function CreateWizard({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
|
||||
export default function CreateWizard({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const [activeStep, setActiveStep] = useState("basics");
|
||||
const [localError, setLocalError] = useState("");
|
||||
const [validationMessage, setValidationMessage] = useState("");
|
||||
@@ -41,8 +41,8 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
||||
setError: setLocalError,
|
||||
currentFlow: "create",
|
||||
currentStep: () => activeStep,
|
||||
unsavedTitle: "Unsaved wizard changes",
|
||||
unsavedMessage: "This campaign wizard has unsaved changes. Save them before leaving, or discard them and continue.",
|
||||
unsavedTitle: "i18n:govoplan-campaign.unsaved_wizard_changes.ba907144",
|
||||
unsavedMessage: "i18n:govoplan-campaign.this_campaign_wizard_has_unsaved_changes_save_th.7e4be033",
|
||||
onLoaded: (loadedVersion) => {
|
||||
if (loadedVersion.current_step && steps.some((step) => step.id === loadedVersion.current_step)) {
|
||||
setActiveStep(loadedVersion.current_step);
|
||||
@@ -68,7 +68,7 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
||||
|
||||
async function validateCurrentStep() {
|
||||
if (!version || !draft) return;
|
||||
setValidationMessage("Validating…");
|
||||
setValidationMessage("i18n:govoplan-campaign.validating.c07434c9");
|
||||
try {
|
||||
const result = await validatePartial(settings, campaignId, version.id, { campaign_json: draft, section: activeStep });
|
||||
setValidationMessage(`${result.error_count} errors, ${result.warning_count} warnings, ${result.info_count} info messages.`);
|
||||
@@ -84,9 +84,9 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
||||
<div className="wizard-body standalone-wizard-body">
|
||||
<div className="wizard-heading">
|
||||
<div>
|
||||
<PageTitle>Create campaign</PageTitle>
|
||||
<PageTitle>i18n:govoplan-campaign.create_campaign.59812bbc</PageTitle>
|
||||
</div>
|
||||
<div className="save-state">Locked</div>
|
||||
<div className="save-state">i18n:govoplan-campaign.locked.a798882f</div>
|
||||
</div>
|
||||
<Card>
|
||||
<LockedVersionNotice
|
||||
@@ -95,16 +95,16 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
||||
version={data.currentVersion}
|
||||
currentVersionId={data.campaign?.current_version_id}
|
||||
reload={reload}
|
||||
message="This wizard is read-only for the selected version."
|
||||
/>
|
||||
message="i18n:govoplan-campaign.this_wizard_is_read_only_for_the_selected_versio.b0865947" />
|
||||
|
||||
<div className="button-row">
|
||||
<Link to="../.."><Button>Back to overview</Button></Link>
|
||||
<Link to="../.."><Button>i18n:govoplan-campaign.back_to_overview.ec986cba</Button></Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -114,7 +114,7 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
||||
<div className="wizard-body">
|
||||
<div className="wizard-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Create campaign</PageTitle>
|
||||
<PageTitle loading={loading}>i18n:govoplan-campaign.create_campaign.59812bbc</PageTitle>
|
||||
</div>
|
||||
<div className="save-state">{saveState}</div>
|
||||
</div>
|
||||
@@ -131,13 +131,13 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
||||
{draft && activeStep === "send" && <SendStep draft={draft} patch={patch} />}
|
||||
</Card>
|
||||
<div className="wizard-footer">
|
||||
<Button onClick={previousStep}>Back</Button>
|
||||
<Button onClick={() => saveDraft("manual")} disabled={!dirty}>{dirty ? "Save now" : "Saved"}</Button>
|
||||
<Button onClick={validateCurrentStep}>Validate step</Button>
|
||||
<Button variant="primary" onClick={nextStep}>Continue</Button>
|
||||
<Button onClick={previousStep}>i18n:govoplan-campaign.back.b52b36b7</Button>
|
||||
<Button onClick={() => saveDraft("manual")} disabled={!dirty}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>
|
||||
<Button onClick={validateCurrentStep}>i18n:govoplan-campaign.validate_step.6a8b527c</Button>
|
||||
<Button variant="primary" onClick={nextStep}>i18n:govoplan-campaign.continue.2e026239</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -3,20 +3,20 @@ import { MetricCard } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
export default function ReviewWizard() {
|
||||
return (
|
||||
<div className="content-pad">
|
||||
<div className="page-heading">
|
||||
<h1>Review Wizard</h1>
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading workspace-heading">
|
||||
<h1>i18n:govoplan-campaign.review_wizard.3d8bf0aa</h1>
|
||||
</div>
|
||||
<div className="metric-grid">
|
||||
<MetricCard label="Needs review" value="—" tone="warning" />
|
||||
<MetricCard label="Missing attachments" value="—" tone="warning" />
|
||||
<MetricCard label="Ambiguous matches" value="—" tone="info" />
|
||||
<MetricCard label="Blocked" value="—" tone="danger" />
|
||||
<MetricCard label="i18n:govoplan-campaign.needs_review.33a506cf" value="—" tone="warning" />
|
||||
<MetricCard label="i18n:govoplan-campaign.missing_attachments.729ad125" value="—" tone="warning" />
|
||||
<MetricCard label="i18n:govoplan-campaign.ambiguous_matches.dc658a9c" value="—" tone="info" />
|
||||
<MetricCard label="i18n:govoplan-campaign.blocked.99613c74" value="—" tone="danger" />
|
||||
</div>
|
||||
<Card title="Resolution workflow">
|
||||
<p className="muted">This wizard will guide users through issues one class at a time.</p>
|
||||
<Button variant="primary">Start review</Button>
|
||||
<Card title="i18n:govoplan-campaign.resolution_workflow.708d6c0b">
|
||||
<p className="muted">i18n:govoplan-campaign.this_wizard_will_guide_users_through_issues_one_.5cb212c3</p>
|
||||
<Button variant="primary">i18n:govoplan-campaign.start_review.d0bc5cdf</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
@@ -2,20 +2,20 @@ import { Card } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
export default function SendWizard() {
|
||||
return (
|
||||
<div className="content-pad">
|
||||
<div className="page-heading">
|
||||
<h1>Send Wizard</h1>
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading workspace-heading">
|
||||
<h1>i18n:govoplan-campaign.send_wizard.3c137422</h1>
|
||||
</div>
|
||||
<div className="dashboard-grid">
|
||||
<Card title="Test send">
|
||||
<p className="muted">Send one generated message to a test address.</p>
|
||||
<Button>Open test-send dialog</Button>
|
||||
<Card title="i18n:govoplan-campaign.test_send.03dfff38">
|
||||
<p className="muted">i18n:govoplan-campaign.send_one_generated_message_to_a_test_address.bc0a4e47</p>
|
||||
<Button>i18n:govoplan-campaign.open_test_send_dialog.661db713</Button>
|
||||
</Card>
|
||||
<Card title="Queue estimate">
|
||||
<p className="muted">Estimated duration will be based on ready jobs and rate limits.</p>
|
||||
<Button variant="primary">Queue dry run</Button>
|
||||
<Card title="i18n:govoplan-campaign.queue_estimate.5480288a">
|
||||
<p className="muted">i18n:govoplan-campaign.estimated_duration_will_be_based_on_ready_jobs_a.15b63283</p>
|
||||
<Button variant="primary">i18n:govoplan-campaign.queue_dry_run.800e1606</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
@@ -21,24 +21,24 @@ export function BasicsStep({ draft, patch }: WizardStepProps) {
|
||||
const campaign = asRecord(draft.campaign);
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<FormField label="Campaign name" help="A human-readable name shown in lists and reports.">
|
||||
<FormField label="i18n:govoplan-campaign.campaign_name.aa5d0e72" help="i18n:govoplan-campaign.a_human_readable_name_shown_in_lists_and_reports.afc23e7e">
|
||||
<input value={getText(campaign, "name")} onChange={(event) => patch(["campaign", "name"], event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Campaign ID" help="Stable technical identifier.">
|
||||
<FormField label="i18n:govoplan-campaign.campaign_id.4c4ed79e" help="i18n:govoplan-campaign.stable_technical_identifier.28c406b2">
|
||||
<input value={getText(campaign, "id")} onChange={(event) => patch(["campaign", "id"], event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Mode">
|
||||
<FormField label="i18n:govoplan-campaign.mode.a7b93d21">
|
||||
<select value={getText(campaign, "mode", "draft")} onChange={(event) => patch(["campaign", "mode"], event.target.value)}>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="test">Test</option>
|
||||
<option value="send">Send</option>
|
||||
<option value="draft">i18n:govoplan-campaign.draft.23d33e22</option>
|
||||
<option value="test">i18n:govoplan-campaign.test.640ab2ba</option>
|
||||
<option value="send">i18n:govoplan-campaign.send.9bc2575c</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Description">
|
||||
<FormField label="i18n:govoplan-campaign.description.55f8ebc8">
|
||||
<textarea rows={5} value={getText(campaign, "description")} onChange={(event) => patch(["campaign", "description"], event.target.value)} />
|
||||
</FormField>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function SenderStep({ draft, patch }: WizardStepProps) {
|
||||
@@ -55,86 +55,86 @@ export function SenderStep({ draft, patch }: WizardStepProps) {
|
||||
const imapAppend = asRecord(delivery.imap_append_sent);
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<FormField label="Default From address">
|
||||
<FormField label="i18n:govoplan-campaign.default_from_address.b9ee6d77">
|
||||
<EmailAddressInput
|
||||
value={from}
|
||||
suggestions={suggestions}
|
||||
allowMultiple
|
||||
addLabel="Add From"
|
||||
emptyText="No global From address configured."
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses)}
|
||||
/>
|
||||
addLabel="i18n:govoplan-campaign.add_from.a095bb50"
|
||||
emptyText="i18n:govoplan-campaign.no_global_from_address_configured.28141d2b"
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses)} />
|
||||
|
||||
</FormField>
|
||||
<FormField label="Global recipients">
|
||||
<FormField label="i18n:govoplan-campaign.global_recipients.d9aaa427">
|
||||
<EmailAddressInput
|
||||
value={globalTo}
|
||||
suggestions={suggestions}
|
||||
allowMultiple
|
||||
addLabel="Add recipient"
|
||||
emptyText="No global recipients configured."
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "to"], addresses)}
|
||||
/>
|
||||
addLabel="i18n:govoplan-campaign.add_recipient.a989d1f1"
|
||||
emptyText="i18n:govoplan-campaign.no_global_recipients_configured.d4e20e92"
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "to"], addresses)} />
|
||||
|
||||
</FormField>
|
||||
<FormField label="CC">
|
||||
<FormField label="i18n:govoplan-campaign.cc.c5a976de">
|
||||
<EmailAddressInput
|
||||
value={globalCc}
|
||||
suggestions={suggestions}
|
||||
allowMultiple
|
||||
addLabel="Add CC"
|
||||
emptyText="No global CC recipients configured."
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "cc"], addresses)}
|
||||
/>
|
||||
addLabel="i18n:govoplan-campaign.add_cc.bcb39ea3"
|
||||
emptyText="i18n:govoplan-campaign.no_global_cc_recipients_configured.8afb23c1"
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "cc"], addresses)} />
|
||||
|
||||
</FormField>
|
||||
<FormField label="BCC">
|
||||
<FormField label="i18n:govoplan-campaign.bcc.4c0145a3">
|
||||
<EmailAddressInput
|
||||
value={globalBcc}
|
||||
suggestions={suggestions}
|
||||
allowMultiple
|
||||
addLabel="Add BCC"
|
||||
emptyText="No global BCC recipients configured."
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "bcc"], addresses)}
|
||||
/>
|
||||
addLabel="i18n:govoplan-campaign.add_bcc.ae9feacc"
|
||||
emptyText="i18n:govoplan-campaign.no_global_bcc_recipients_configured.86ef64ab"
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "bcc"], addresses)} />
|
||||
|
||||
</FormField>
|
||||
<FormField label="Reply-To">
|
||||
<FormField label="i18n:govoplan-campaign.reply_to.c1733667">
|
||||
<EmailAddressInput
|
||||
value={globalReplyTo.slice(0, 1)}
|
||||
suggestions={suggestions}
|
||||
allowMultiple={false}
|
||||
showAddButton={false}
|
||||
addLabel={globalReplyTo.length ? "Replace" : "Add Reply-To"}
|
||||
emptyText="No Reply-To address configured."
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses.slice(0, 1))}
|
||||
/>
|
||||
addLabel={globalReplyTo.length ? "i18n:govoplan-campaign.replace.a7cf7b25" : "i18n:govoplan-campaign.add_reply_to.84b195f4"}
|
||||
emptyText="i18n:govoplan-campaign.no_reply_to_address_configured.0665c1d9"
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses.slice(0, 1))} />
|
||||
|
||||
</FormField>
|
||||
<FormField label="SMTP host"><input value={getText(smtp, "host")} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField>
|
||||
<FormField label="SMTP port"><input type="number" value={getNumber(smtp, "port", 587)} onChange={(event) => patch(["server", "smtp", "port"], Number(event.target.value || 0))} /></FormField>
|
||||
<ToggleSwitch label="Append successful messages to Sent via IMAP" checked={getBool(imapAppend, "enabled")} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
|
||||
</div>
|
||||
);
|
||||
<FormField label="i18n:govoplan-campaign.smtp_host.2d4a434b"><input value={getText(smtp, "host")} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField>
|
||||
<FormField label="i18n:govoplan-campaign.smtp_port.65b5a108"><input type="number" value={getNumber(smtp, "port", 587)} onChange={(event) => patch(["server", "smtp", "port"], Number(event.target.value || 0))} /></FormField>
|
||||
<ToggleSwitch label="i18n:govoplan-campaign.append_successful_messages_to_sent_via_imap.dbd1b1d8" checked={getBool(imapAppend, "enabled")} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function FieldsStep({ draft, patchRoot }: { draft: Record<string, unknown>; patchRoot: (key: string, value: unknown) => void }) {
|
||||
export function FieldsStep({ draft, patchRoot }: {draft: Record<string, unknown>;patchRoot: (key: string, value: unknown) => void;}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="step-intro">
|
||||
<h2>Campaign fields</h2>
|
||||
<p>Define reusable fields for templates, attachment rules, ZIP passwords and recipient data.</p>
|
||||
<h2>i18n:govoplan-campaign.campaign_fields.969e7d80</h2>
|
||||
<p>i18n:govoplan-campaign.define_reusable_fields_for_templates_attachment_.6424f892</p>
|
||||
</div>
|
||||
<JsonEditor value={draft.fields ?? []} onValid={(value) => patchRoot("fields", value)} />
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function RecipientsStep({ draft, patchRoot }: { draft: Record<string, unknown>; patchRoot: (key: string, value: unknown) => void }) {
|
||||
export function RecipientsStep({ draft, patchRoot }: {draft: Record<string, unknown>;patchRoot: (key: string, value: unknown) => void;}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="step-intro">
|
||||
<h2>Recipients</h2>
|
||||
<p>Store inline recipients or source/mapping configuration. A table editor will replace this JSON editor in the recipient section pass.</p>
|
||||
<h2>i18n:govoplan-campaign.recipients.78cbf8eb</h2>
|
||||
<p>i18n:govoplan-campaign.store_inline_recipients_or_source_mapping_config.77bbe98f</p>
|
||||
</div>
|
||||
<JsonEditor value={draft.entries ?? { inline: [] }} onValid={(value) => patchRoot("entries", value)} />
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function TemplateStep({ draft, patch }: WizardStepProps) {
|
||||
@@ -142,16 +142,16 @@ export function TemplateStep({ draft, patch }: WizardStepProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="step-intro">
|
||||
<h2>Template</h2>
|
||||
<p>Compose the subject and body. Merge fields can later be inserted from the field picker.</p>
|
||||
<h2>i18n:govoplan-campaign.template.3ec1ae06</h2>
|
||||
<p>i18n:govoplan-campaign.compose_the_subject_and_body_merge_fields_can_la.4fb6e97b</p>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<FormField label="Subject"><input value={getText(template, "subject")} onChange={(event) => patch(["template", "subject"], event.target.value)} /></FormField>
|
||||
<FormField label="Plain text body"><textarea rows={12} value={getText(template, "text")} onChange={(event) => patch(["template", "text"], event.target.value)} /></FormField>
|
||||
<FormField label="HTML body"><textarea rows={8} value={getText(template, "html")} onChange={(event) => patch(["template", "html"], event.target.value)} /></FormField>
|
||||
<FormField label="i18n:govoplan-campaign.subject.8d183dbd"><input value={getText(template, "subject")} onChange={(event) => patch(["template", "subject"], event.target.value)} /></FormField>
|
||||
<FormField label="i18n:govoplan-campaign.plain_text_body.030a0da0"><textarea rows={12} value={getText(template, "text")} onChange={(event) => patch(["template", "text"], event.target.value)} /></FormField>
|
||||
<FormField label="i18n:govoplan-campaign.html_body.77b5ba37"><textarea rows={8} value={getText(template, "html")} onChange={(event) => patch(["template", "html"], event.target.value)} /></FormField>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function AttachmentsStep({ draft, patch }: WizardStepProps) {
|
||||
@@ -159,36 +159,36 @@ export function AttachmentsStep({ draft, patch }: WizardStepProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="step-intro">
|
||||
<h2>Attachments</h2>
|
||||
<p>Configure campaign-wide attachment behavior and global matching rules.</p>
|
||||
<h2>i18n:govoplan-campaign.attachments.6771ade6</h2>
|
||||
<p>i18n:govoplan-campaign.configure_campaign_wide_attachment_behavior_and_.441a6cd2</p>
|
||||
</div>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<FormField label="Campaign attachment base path"><input value={getText(attachments, "base_path", ".")} onChange={(event) => patch(["attachments", "base_path"], event.target.value)} /></FormField>
|
||||
<FormField label="Missing behavior"><select value={getText(attachments, "missing_behavior", "ask")} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
|
||||
<FormField label="Ambiguous behavior"><select value={getText(attachments, "ambiguous_behavior", "ask")} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
|
||||
<ToggleSwitch label="Allow individual attachments" checked={getBool(attachments, "allow_individual")} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} />
|
||||
<FormField label="i18n:govoplan-campaign.campaign_attachment_base_path.84827619"><input value={getText(attachments, "base_path", ".")} onChange={(event) => patch(["attachments", "base_path"], event.target.value)} /></FormField>
|
||||
<FormField label="i18n:govoplan-campaign.missing_behavior.de3d1ac7"><select value={getText(attachments, "missing_behavior", "ask")} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
|
||||
<FormField label="i18n:govoplan-campaign.ambiguous_behavior.e86e724b"><select value={getText(attachments, "ambiguous_behavior", "ask")} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
|
||||
<ToggleSwitch label="i18n:govoplan-campaign.allow_individual_attachments.a6ff0e87" checked={getBool(attachments, "allow_individual")} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} />
|
||||
</div>
|
||||
<JsonEditor value={attachments.global ?? []} onValid={(value) => patch(["attachments", "global"], value)} />
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function ReviewStep({ version, onValidate }: { version: unknown; onValidate: () => void }) {
|
||||
export function ReviewStep({ version, onValidate }: {version: unknown;onValidate: () => void;}) {
|
||||
const record = asRecord(version);
|
||||
return (
|
||||
<div>
|
||||
<div className="step-intro">
|
||||
<h2>Review setup</h2>
|
||||
<p>Validate the campaign definition before building message drafts.</p>
|
||||
<h2>i18n:govoplan-campaign.review_setup.d0059acb</h2>
|
||||
<p>i18n:govoplan-campaign.validate_the_campaign_definition_before_building.d36ee1dc</p>
|
||||
</div>
|
||||
<div className="metric-grid inside">
|
||||
<MetricCard label="Errors" value={summaryValue(asRecord(record.validation_summary), ["error_count", "errors", "blocked"])} tone="danger" />
|
||||
<MetricCard label="Warnings" value={summaryValue(asRecord(record.validation_summary), ["warning_count", "warnings"])} tone="warning" />
|
||||
<MetricCard label="Built" value={summaryValue(asRecord(record.build_summary), ["built_count", "built", "messages_built"])} tone="info" />
|
||||
<MetricCard label="i18n:govoplan-campaign.errors.805e86a8" value={summaryValue(asRecord(record.validation_summary), ["error_count", "errors", "blocked"])} tone="danger" />
|
||||
<MetricCard label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(asRecord(record.validation_summary), ["warning_count", "warnings"])} tone="warning" />
|
||||
<MetricCard label="i18n:govoplan-campaign.built.a6ad3f82" value={summaryValue(asRecord(record.build_summary), ["built_count", "built", "messages_built"])} tone="info" />
|
||||
</div>
|
||||
<Button variant="primary" onClick={onValidate}>Validate campaign</Button>
|
||||
</div>
|
||||
);
|
||||
<Button variant="primary" onClick={onValidate}>i18n:govoplan-campaign.validate_campaign.6934c1c2</Button>
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function SendStep({ draft, patch }: WizardStepProps) {
|
||||
@@ -197,19 +197,19 @@ export function SendStep({ draft, patch }: WizardStepProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="step-intro">
|
||||
<h2>Send preparation</h2>
|
||||
<p>Configure rate limits and prepare the final send workflow.</p>
|
||||
<h2>i18n:govoplan-campaign.send_preparation.6e078f43</h2>
|
||||
<p>i18n:govoplan-campaign.configure_rate_limits_and_prepare_the_final_send.af3d4e48</p>
|
||||
</div>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<FormField label="Messages per minute"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
|
||||
<FormField label="Concurrency"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
|
||||
<FormField label="i18n:govoplan-campaign.messages_per_minute.bea49348"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
|
||||
<FormField label="i18n:govoplan-campaign.concurrency.2ec390bf"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
|
||||
</div>
|
||||
<p className="muted">Test send and queue actions remain in the Send Wizard for now.</p>
|
||||
</div>
|
||||
);
|
||||
<p className="muted">i18n:govoplan-campaign.test_send_and_queue_actions_remain_in_the_send_w.60dee716</p>
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function JsonEditor({ value, onValid }: { value: unknown; onValid: (value: unknown) => void }) {
|
||||
function JsonEditor({ value, onValid }: {value: unknown;onValid: (value: unknown) => void;}) {
|
||||
const [text, setText] = useState(stringifyJson(value));
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -228,8 +228,8 @@ function JsonEditor({ value, onValid }: { value: unknown; onValid: (value: unkno
|
||||
return (
|
||||
<div className="json-edit-block">
|
||||
<textarea rows={12} value={text} onChange={(event) => change(event.target.value)} />
|
||||
{error ? <p className="form-help danger-text">Invalid JSON: {error}</p> : <p className="form-help">Valid JSON is saved with the wizard draft.</p>}
|
||||
{Array.isArray(value) && value.length > 0 && <p className="form-help">Preview: {stringifyPreview(asArray(value)[0], 140)}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{error ? <p className="form-help danger-text">i18n:govoplan-campaign.invalid_json.03f61ae0 {error}</p> : <p className="form-help">i18n:govoplan-campaign.valid_json_is_saved_with_the_wizard_draft.16f5d341</p>}
|
||||
{Array.isArray(value) && value.length > 0 && <p className="form-help">i18n:govoplan-campaign.preview.4bf30626 {stringifyPreview(asArray(value)[0], 140)}</p>}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { ApiSettings, CampaignListItem } from "../../types";
|
||||
import { getCampaignSummary, listCampaigns, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns";
|
||||
import { getCampaignWorkspaceDelta, listCampaignsDelta, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
@@ -10,7 +9,7 @@ import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { MetricCard } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { StatusBadge, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useGuardedNavigate } from "@govoplan/core-webui";
|
||||
import { asRecord, formatDateTime, humanize } from "../campaigns/utils/campaignView";
|
||||
|
||||
type OperatorRow = {
|
||||
@@ -25,29 +24,40 @@ type OperatorRow = {
|
||||
needsAttention: number;
|
||||
};
|
||||
|
||||
export default function OperatorQueuePage({ settings }: { settings: ApiSettings }) {
|
||||
const navigate = useNavigate();
|
||||
export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [rows, setRows] = useState<OperatorRow[]>([]);
|
||||
const campaignsRef = useRef<CampaignListItem[]>([]);
|
||||
const summariesRef = useRef<Record<string, CampaignSummary | null>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [busy, setBusy] = useState("");
|
||||
|
||||
useEffect(() => { void load(); }, [settings.apiBaseUrl, settings.apiKey]);
|
||||
const settingsKey = useMemo(
|
||||
() => JSON.stringify({
|
||||
apiBaseUrl: settings.apiBaseUrl,
|
||||
apiKey: settings.apiKey,
|
||||
accessToken: settings.accessToken
|
||||
}),
|
||||
[settings.apiBaseUrl, settings.apiKey, settings.accessToken]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
campaignsRef.current = [];
|
||||
summariesRef.current = {};
|
||||
resetDeltaWatermark();
|
||||
void load();
|
||||
}, [settingsKey, resetDeltaWatermark]);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const campaigns = await listCampaigns(settings);
|
||||
const summaries = await Promise.all(campaigns.map(async (campaign) => {
|
||||
try {
|
||||
return await getCampaignSummary(settings, campaign.id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
setRows(campaigns.map((campaign, index) => toRow(campaign, summaries[index] ?? null)));
|
||||
const campaigns = await loadCampaignsDelta();
|
||||
const summaries = await loadCampaignSummariesDelta(campaigns);
|
||||
setRows(campaigns.map((campaign) => toRow(campaign, summaries[campaign.id] ?? null)));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -61,11 +71,12 @@ export default function OperatorQueuePage({ settings }: { settings: ApiSettings
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const response = action === "retry"
|
||||
? await retryCampaignJobs(settings, row.campaign.id, { enqueue_celery: true })
|
||||
: await sendUnattemptedCampaignJobs(settings, row.campaign.id, { enqueue_celery: true });
|
||||
const response = action === "retry" ?
|
||||
await retryCampaignJobs(settings, row.campaign.id, { enqueue_celery: true }) :
|
||||
await sendUnattemptedCampaignJobs(settings, row.campaign.id, { enqueue_celery: true });
|
||||
const result = asRecord(response.result ?? response);
|
||||
setMessage(`${row.campaign.name}: ${humanize(String(result.action ?? action))}, ${String(result.enqueued_count ?? 0)} enqueued.`);
|
||||
setMessage(i18nMessage("i18n:govoplan-campaign.value_value_value_enqueued.35b33f6d", { value0: row.campaign.name, value1: humanize(String(result.action ?? action)), value2: String(result.enqueued_count ?? 0) }));
|
||||
resetDeltaWatermark(operatorCampaignSummaryKey(row.campaign.id));
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -74,49 +85,109 @@ export default function OperatorQueuePage({ settings }: { settings: ApiSettings
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCampaignsDelta(): Promise<CampaignListItem[]> {
|
||||
const key = operatorCampaignListKey();
|
||||
let nextWatermark = getDeltaWatermark(key);
|
||||
let campaigns = campaignsRef.current;
|
||||
let hasMore = false;
|
||||
do {
|
||||
const response = await listCampaignsDelta(settings, { since: nextWatermark });
|
||||
campaigns = mergeDeltaRows(campaigns, response.campaigns, response.deleted, (campaign) => campaign.id, {
|
||||
deletedResourceType: "campaign",
|
||||
sort: sortCampaignsByUpdatedDesc
|
||||
});
|
||||
nextWatermark = response.watermark ?? null;
|
||||
hasMore = response.has_more;
|
||||
} while (hasMore);
|
||||
const campaignIds = new Set(campaigns.map((campaign) => campaign.id));
|
||||
for (const campaignId of Object.keys(summariesRef.current)) {
|
||||
if (!campaignIds.has(campaignId)) delete summariesRef.current[campaignId];
|
||||
}
|
||||
campaignsRef.current = campaigns;
|
||||
setDeltaWatermark(key, nextWatermark);
|
||||
return campaigns;
|
||||
}
|
||||
|
||||
async function loadCampaignSummariesDelta(campaigns: CampaignListItem[]): Promise<Record<string, CampaignSummary | null>> {
|
||||
const summaries = { ...summariesRef.current };
|
||||
await Promise.all(campaigns.map(async (campaign) => {
|
||||
const key = operatorCampaignSummaryKey(campaign.id);
|
||||
let nextWatermark = getDeltaWatermark(key);
|
||||
let summary = summaries[campaign.id] ?? null;
|
||||
let hasMore = false;
|
||||
try {
|
||||
do {
|
||||
const response = await getCampaignWorkspaceDelta(settings, campaign.id, {
|
||||
includeCurrentVersion: false,
|
||||
includeVersions: false,
|
||||
includeSummary: true,
|
||||
since: nextWatermark
|
||||
});
|
||||
if (response.full || response.summary) summary = response.summary;
|
||||
nextWatermark = response.watermark ?? null;
|
||||
hasMore = response.has_more;
|
||||
} while (hasMore);
|
||||
setDeltaWatermark(key, nextWatermark);
|
||||
summaries[campaign.id] = summary;
|
||||
} catch {
|
||||
summaries[campaign.id] = summary;
|
||||
}
|
||||
}));
|
||||
summariesRef.current = summaries;
|
||||
return summaries;
|
||||
}
|
||||
|
||||
function operatorCampaignListKey(): string {
|
||||
return JSON.stringify({ scope: "operator-campaigns", settingsKey });
|
||||
}
|
||||
|
||||
function operatorCampaignSummaryKey(campaignId: string): string {
|
||||
return JSON.stringify({ scope: "operator-campaign-summary", campaignId, settingsKey });
|
||||
}
|
||||
|
||||
const totals = rows.reduce((acc, row) => ({
|
||||
failed: acc.failed + row.failed,
|
||||
outcomeUnknown: acc.outcomeUnknown + row.outcomeUnknown,
|
||||
notAttempted: acc.notAttempted + row.notAttempted,
|
||||
queuedOrActive: acc.queuedOrActive + row.queuedOrActive,
|
||||
imapFailed: acc.imapFailed + row.imapFailed,
|
||||
imapFailed: acc.imapFailed + row.imapFailed
|
||||
}), { failed: 0, outcomeUnknown: 0, notAttempted: 0, queuedOrActive: 0, imapFailed: 0 });
|
||||
|
||||
const columns = useMemo<DataGridColumn<OperatorRow>[]>(() => [
|
||||
{ id: "campaign", header: "Campaign", width: "minmax(260px, 1.2fr)", sticky: "start", sortable: true, filterable: true, value: (row) => row.campaign.name },
|
||||
{ id: "status", header: "Status", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.campaign.status} />, value: (row) => row.campaign.status },
|
||||
{ id: "attention", header: "Attention", width: 120, align: "right", sortable: true, filterType: "integer", value: (row) => row.needsAttention },
|
||||
{ id: "failed", header: "Failed", width: 100, align: "right", sortable: true, filterType: "integer", value: (row) => row.failed },
|
||||
{ id: "unknown", header: "Unknown", width: 110, align: "right", sortable: true, filterType: "integer", value: (row) => row.outcomeUnknown },
|
||||
{ id: "unattempted", header: "Unattempted", width: 130, align: "right", sortable: true, filterType: "integer", value: (row) => row.notAttempted },
|
||||
{ id: "queued", header: "Queued/active", width: 135, align: "right", sortable: true, filterType: "integer", value: (row) => row.queuedOrActive },
|
||||
{ id: "updated", header: "Updated", width: 180, sortable: true, filterType: "date", value: (row) => formatDateTime(row.campaign.updated_at), sortValue: (row) => row.campaign.updated_at ?? "" },
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 270,
|
||||
sticky: "end",
|
||||
render: (row) => (
|
||||
<div className="button-row compact-actions">
|
||||
<Button className="admin-icon-button" onClick={() => navigate(`/campaigns/${row.campaign.id}/report`)} aria-label={`Open queue for ${row.campaign.name}`} title={`Open queue for ${row.campaign.name}`}>
|
||||
{ id: "campaign", header: "i18n:govoplan-campaign.campaign.69390e16", width: "minmax(260px, 1.2fr)", sticky: "start", sortable: true, filterable: true, value: (row) => row.campaign.name },
|
||||
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.campaign.status} />, value: (row) => row.campaign.status },
|
||||
{ id: "attention", header: "i18n:govoplan-campaign.attention.74e0b9c8", width: 120, align: "right", sortable: true, filterType: "integer", value: (row) => row.needsAttention },
|
||||
{ id: "failed", header: "i18n:govoplan-campaign.failed.09fef5d8", width: 100, align: "right", sortable: true, filterType: "integer", value: (row) => row.failed },
|
||||
{ id: "unknown", header: "i18n:govoplan-campaign.unknown.bc7819b3", width: 110, align: "right", sortable: true, filterType: "integer", value: (row) => row.outcomeUnknown },
|
||||
{ id: "unattempted", header: "i18n:govoplan-campaign.unattempted.e7411dd6", width: 130, align: "right", sortable: true, filterType: "integer", value: (row) => row.notAttempted },
|
||||
{ id: "queued", header: "i18n:govoplan-campaign.queued_active.b08bef73", width: 135, align: "right", sortable: true, filterType: "integer", value: (row) => row.queuedOrActive },
|
||||
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 180, sortable: true, filterType: "date", value: (row) => formatDateTime(row.campaign.updated_at), sortValue: (row) => row.campaign.updated_at ?? "" },
|
||||
{
|
||||
id: "actions",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 270,
|
||||
sticky: "end",
|
||||
render: (row) =>
|
||||
<div className="button-row compact-actions">
|
||||
<Button className="admin-icon-button" onClick={() => navigate(`/campaigns/${row.campaign.id}/report`)} aria-label={i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name })} title={i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name })}>
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
<Button onClick={() => void runAction(row, "retry")} disabled={row.failed <= 0 || Boolean(busy)}>Retry</Button>
|
||||
<Button onClick={() => void runAction(row, "unattempted")} disabled={row.notAttempted <= 0 || Boolean(busy)}>Queue unsent</Button>
|
||||
<Button onClick={() => void runAction(row, "retry")} disabled={row.failed <= 0 || Boolean(busy)}>i18n:govoplan-campaign.retry.9f5cd8a2</Button>
|
||||
<Button onClick={() => void runAction(row, "unattempted")} disabled={row.notAttempted <= 0 || Boolean(busy)}>i18n:govoplan-campaign.queue_unsent.b0e98610</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
], [busy, navigate]);
|
||||
|
||||
}],
|
||||
[busy, navigate]);
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Operator queue</PageTitle>
|
||||
<p>Queue state, retry candidates and reconciliation entry points across accessible campaigns.</p>
|
||||
<PageTitle loading={loading}>i18n:govoplan-campaign.operator_queue.72492fb5</PageTitle>
|
||||
<p>i18n:govoplan-campaign.queue_state_retry_candidates_and_reconciliation_.1b592bbe</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void load()} disabled={loading}>Refresh</Button>
|
||||
<Button onClick={() => void load()} disabled={loading}>i18n:govoplan-campaign.refresh.56e3badc</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,30 +195,30 @@ export default function OperatorQueuePage({ settings }: { settings: ApiSettings
|
||||
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
||||
|
||||
<section className="queue-pressure-section" aria-labelledby="operator-queue-pressure-title">
|
||||
<h2 id="operator-queue-pressure-title" className="queue-pressure-heading">Queue pressure</h2>
|
||||
<h2 id="operator-queue-pressure-title" className="queue-pressure-heading">i18n:govoplan-campaign.queue_pressure.28eee15e</h2>
|
||||
<QueuePressureGrid items={[
|
||||
{ label: "Failed", value: totals.failed, tone: "danger" },
|
||||
{ label: "Outcome unknown", value: totals.outcomeUnknown, tone: "warning" },
|
||||
{ label: "Unattempted", value: totals.notAttempted, tone: "info" },
|
||||
{ label: "Queued/active", value: totals.queuedOrActive, tone: "neutral" },
|
||||
{ label: "IMAP failed", value: totals.imapFailed, tone: "warning" },
|
||||
]} />
|
||||
{ label: "i18n:govoplan-campaign.failed.09fef5d8", value: totals.failed, tone: "danger" },
|
||||
{ label: "i18n:govoplan-campaign.outcome_unknown.6e929fca", value: totals.outcomeUnknown, tone: "warning" },
|
||||
{ label: "i18n:govoplan-campaign.unattempted.e7411dd6", value: totals.notAttempted, tone: "info" },
|
||||
{ label: "i18n:govoplan-campaign.queued_active.b08bef73", value: totals.queuedOrActive, tone: "neutral" },
|
||||
{ label: "i18n:govoplan-campaign.imap_failed.50dbca55", value: totals.imapFailed, tone: "warning" }]
|
||||
} />
|
||||
</section>
|
||||
|
||||
<LoadingFrame loading={loading} label="Loading operator queue…">
|
||||
<Card title="Campaign queues">
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_operator_queue.ae6576e0">
|
||||
<Card title="i18n:govoplan-campaign.campaign_queues.657785f4">
|
||||
<DataGrid
|
||||
id="operator-queue-campaigns"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
getRowKey={(row) => row.campaign.id}
|
||||
emptyText="No accessible campaigns."
|
||||
className="data-table compact-table"
|
||||
/>
|
||||
emptyText="i18n:govoplan-campaign.no_accessible_campaigns.9e080190"
|
||||
className="data-table compact-table" />
|
||||
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function toRow(campaign: CampaignListItem, summary: CampaignSummary | null): OperatorRow {
|
||||
@@ -161,7 +232,7 @@ function toRow(campaign: CampaignListItem, summary: CampaignSummary | null): Ope
|
||||
queuedOrActive: numberValue(cards.queued_or_active),
|
||||
imapFailed: numberValue(cards.imap_failed),
|
||||
queueable: numberValue(cards.queueable),
|
||||
needsAttention: numberValue(cards.needs_attention),
|
||||
needsAttention: numberValue(cards.needs_attention)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,12 +240,18 @@ function numberValue(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function QueuePressureGrid({ items }: { items: Array<{ label: string; value: number; tone: "neutral" | "good" | "warning" | "danger" | "info" }> }) {
|
||||
return (
|
||||
<div className="metric-grid queue-pressure-grid">
|
||||
{items.map((item) => (
|
||||
<MetricCard key={item.label} label={item.label} value={item.value} tone={item.tone} />
|
||||
))}
|
||||
</div>
|
||||
function sortCampaignsByUpdatedDesc(left: CampaignListItem, right: CampaignListItem): number {
|
||||
return String(right.updated_at ?? right.updatedAt ?? right.created_at ?? "").localeCompare(
|
||||
String(left.updated_at ?? left.updatedAt ?? left.created_at ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
function QueuePressureGrid({ items }: {items: Array<{label: string;value: number;tone: "neutral" | "good" | "warning" | "danger" | "info";}>;}) {
|
||||
return (
|
||||
<div className="metric-grid queue-pressure-grid">
|
||||
{items.map((item) =>
|
||||
<MetricCard key={item.label} label={item.label} value={item.value} tone={item.tone} />
|
||||
)}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { FieldLabel } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { StatusBadge, i18nMessage } from "@govoplan/core-webui";
|
||||
import { usePlatformLanguage } from "@govoplan/core-webui";
|
||||
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
|
||||
@@ -25,93 +26,98 @@ type TemplateRecord = {
|
||||
type TemplateDetailSection = "overview" | "content" | "fields" | "preview" | "usage" | "versions";
|
||||
|
||||
const templateRecords: TemplateRecord[] = [
|
||||
{
|
||||
id: "monthly-statement",
|
||||
name: "Monthly statement",
|
||||
description: "Reusable subject and body for monthly statement mailings.",
|
||||
type: "Plain text",
|
||||
status: "ready",
|
||||
fields: ["recipient_name", "period", "amount"],
|
||||
updatedAt: "2026-06-08 16:42",
|
||||
usedBy: "2 campaigns",
|
||||
subject: "Your statement for {{period}}",
|
||||
body: "Hello {{recipient_name}},\n\nplease find your statement for {{period}} attached.",
|
||||
versions: 4
|
||||
},
|
||||
{
|
||||
id: "deadline-reminder",
|
||||
name: "Deadline reminder",
|
||||
description: "Short reminder template with one deadline field.",
|
||||
type: "Plain text",
|
||||
status: "draft",
|
||||
fields: ["recipient_name", "deadline"],
|
||||
updatedAt: "2026-06-07 11:18",
|
||||
usedBy: "Not used yet",
|
||||
subject: "Reminder: {{deadline}}",
|
||||
body: "Hello {{recipient_name}},\n\nthis is a reminder that the deadline is {{deadline}}.",
|
||||
versions: 1
|
||||
},
|
||||
{
|
||||
id: "attachment-notice",
|
||||
name: "Attachment notice",
|
||||
description: "Generic note for campaigns where every recipient receives a file.",
|
||||
type: "HTML-ready",
|
||||
status: "ready",
|
||||
fields: ["recipient_name", "file_label", "contact_email"],
|
||||
updatedAt: "2026-06-05 09:05",
|
||||
usedBy: "1 campaign",
|
||||
subject: "Documents for {{recipient_name}}",
|
||||
body: "Hello {{recipient_name}},\n\nyour {{file_label}} is attached. Please contact {{contact_email}} if anything is missing.",
|
||||
versions: 3
|
||||
}
|
||||
];
|
||||
{
|
||||
id: "monthly-statement",
|
||||
name: "i18n:govoplan-campaign.monthly_statement.74029609",
|
||||
description: "i18n:govoplan-campaign.reusable_subject_and_body_for_monthly_statement_.9754240d",
|
||||
type: "plain_text",
|
||||
status: "ready",
|
||||
fields: ["recipient_name", "period", "amount"],
|
||||
updatedAt: "2026-06-08 16:42",
|
||||
usedBy: "i18n:govoplan-campaign.2_campaigns.35b84804",
|
||||
subject: "i18n:govoplan-campaign.subject_monthly_statement",
|
||||
body: "i18n:govoplan-campaign.hello_value_please_find_your_statement_for_value.48d94596",
|
||||
versions: 4
|
||||
},
|
||||
{
|
||||
id: "deadline-reminder",
|
||||
name: "i18n:govoplan-campaign.deadline_reminder.84977a7f",
|
||||
description: "i18n:govoplan-campaign.short_reminder_template_with_one_deadline_field.5f15d110",
|
||||
type: "plain_text",
|
||||
status: "draft",
|
||||
fields: ["recipient_name", "deadline"],
|
||||
updatedAt: "2026-06-07 11:18",
|
||||
usedBy: "i18n:govoplan-campaign.not_used_yet.962b7bbd",
|
||||
subject: "i18n:govoplan-campaign.subject_deadline_reminder",
|
||||
body: "i18n:govoplan-campaign.hello_value_this_is_a_reminder_that_the_deadline.c4fbdbd0",
|
||||
versions: 1
|
||||
},
|
||||
{
|
||||
id: "attachment-notice",
|
||||
name: "i18n:govoplan-campaign.attachment_notice.b73a59fe",
|
||||
description: "i18n:govoplan-campaign.generic_note_for_campaigns_where_every_recipient.f21254fa",
|
||||
type: "html_ready",
|
||||
status: "ready",
|
||||
fields: ["recipient_name", "file_label", "contact_email"],
|
||||
updatedAt: "2026-06-05 09:05",
|
||||
usedBy: "i18n:govoplan-campaign.1_campaign.ccd70074",
|
||||
subject: "i18n:govoplan-campaign.subject_documents_for_recipient",
|
||||
body: "i18n:govoplan-campaign.hello_value_your_value_is_attached_please_contac.da32415e",
|
||||
versions: 3
|
||||
}];
|
||||
|
||||
|
||||
const templateSubnav = (onBack: () => void): ModuleSubnavGroup<TemplateDetailSection>[] => [
|
||||
{
|
||||
items: [{ actionId: "template-library", label: "← Template library", primary: true, onClick: onBack }]
|
||||
},
|
||||
{
|
||||
title: "TEMPLATE",
|
||||
items: [
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "content", label: "Content" },
|
||||
{ id: "fields", label: "Fields" },
|
||||
{ id: "preview", label: "Preview" },
|
||||
{ id: "usage", label: "Usage" },
|
||||
{ id: "versions", label: "Versions" }
|
||||
]
|
||||
}
|
||||
];
|
||||
{
|
||||
items: [{ actionId: "template-library", label: "i18n:govoplan-campaign.template_library.6742c898", primary: true, onClick: onBack }]
|
||||
},
|
||||
{
|
||||
title: "i18n:govoplan-campaign.template.4c31d4ef",
|
||||
items: [
|
||||
{ id: "overview", label: "i18n:govoplan-campaign.overview.0efc2e6b" },
|
||||
{ id: "content", label: "i18n:govoplan-campaign.content.4f9be057" },
|
||||
{ id: "fields", label: "i18n:govoplan-campaign.fields.e8b68527" },
|
||||
{ id: "preview", label: "i18n:govoplan-campaign.preview.f1fbb2b4" },
|
||||
{ id: "usage", label: "i18n:govoplan-campaign.usage.0bb18642" },
|
||||
{ id: "versions", label: "i18n:govoplan-campaign.versions.a239107e" }]
|
||||
|
||||
}];
|
||||
|
||||
|
||||
function templateLibraryColumns(openTemplate: (templateId: string) => void): DataGridColumn<TemplateRecord>[] {
|
||||
return [
|
||||
{ id: "template", header: "Template", width: "minmax(240px, 1.4fr)", sortable: true, filterable: true, sticky: "start", render: (template) => <div className="module-title-cell"><strong>{template.name}</strong><span>{template.description}</span></div>, value: (template) => `${template.name} ${template.description}` },
|
||||
{ id: "type", header: "Type", width: 150, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Campaign", label: "Campaign" }, { value: "Notification", label: "Notification" }, { value: "Attachment", label: "Attachment" }] }, value: (template) => template.type },
|
||||
{ id: "fields", header: "Fields", width: 240, filterable: true, render: (template) => <div className="chip-row compact-chip-row">{template.fields.slice(0, 3).map((field) => <span key={field} className="field-chip">{field}</span>)}</div>, value: (template) => template.fields.join(", ") },
|
||||
{ id: "updated", header: "Updated", width: 170, sortable: true, filterable: true, filterType: "date", render: (template) => <span className="muted small-text">{template.updatedAt}</span>, value: (template) => template.updatedAt },
|
||||
{ id: "status", header: "Status", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "draft", label: "Draft" }, { value: "active", label: "Active" }, { value: "archived", label: "Archived" }], display: "pill" }, render: (template) => <StatusBadge status={template.status} />, value: (template) => template.status },
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 70,
|
||||
sticky: "end",
|
||||
align: "right",
|
||||
render: (template) => (
|
||||
<Button className="admin-icon-button" onClick={() => openTemplate(template.id)} aria-label={`Open ${template.name}`} title={`Open ${template.name}`}>
|
||||
{ id: "template", header: "i18n:govoplan-campaign.template.3ec1ae06", width: "minmax(240px, 1.4fr)", sortable: true, filterable: true, sticky: "start", render: (template) => <div className="module-title-cell"><strong>{template.name}</strong><span>{template.description}</span></div>, value: (template) => `${template.name} ${template.description}` },
|
||||
{ id: "type", header: "i18n:govoplan-campaign.type.3deb7456", width: 150, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "plain_text", label: "i18n:govoplan-campaign.plain_text_template_type" }, { value: "html_ready", label: "i18n:govoplan-campaign.html_ready_template_type" }] }, value: (template) => template.type },
|
||||
{ id: "fields", header: "i18n:govoplan-campaign.fields.e8b68527", width: 240, filterable: true, render: (template) => <div className="chip-row compact-chip-row">{template.fields.slice(0, 3).map((field) => <span key={field} className="field-chip">{field}</span>)}</div>, value: (template) => template.fields.join(", ") },
|
||||
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 170, sortable: true, filterable: true, filterType: "date", render: (template) => <span className="muted small-text">{template.updatedAt}</span>, value: (template) => template.updatedAt },
|
||||
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "draft", label: "i18n:govoplan-campaign.draft.23d33e22" }, { value: "active", label: "i18n:govoplan-campaign.active.a733b809" }, { value: "archived", label: "i18n:govoplan-campaign.archived.eddc813f" }], display: "pill" }, render: (template) => <StatusBadge status={template.status} />, value: (template) => template.status },
|
||||
{
|
||||
id: "actions",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 70,
|
||||
sticky: "end",
|
||||
align: "right",
|
||||
render: (template) =>
|
||||
<Button className="admin-icon-button" onClick={() => openTemplate(template.id)} aria-label={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: template.name })} title={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: template.name })}>
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
}];
|
||||
|
||||
}
|
||||
|
||||
function templateFieldColumns(): DataGridColumn<{ id: string; field: string; source: string; required: string }>[] {
|
||||
function templateTypeLabel(type: string): string {
|
||||
if (type === "html_ready") return "i18n:govoplan-campaign.html_ready_template_type";
|
||||
return "i18n:govoplan-campaign.plain_text_template_type";
|
||||
}
|
||||
|
||||
function templateFieldColumns(): DataGridColumn<{id: string;field: string;source: string;required: string;}>[] {
|
||||
return [
|
||||
{ id: "field", header: "Field", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (row) => <code>{row.field}</code>, value: (row) => row.field },
|
||||
{ id: "source", header: "Source", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Detected placeholder", label: "Detected placeholder" }] }, value: (row) => row.source },
|
||||
{ id: "required", header: "Required", width: 120, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }] }, value: (row) => row.required },
|
||||
{ id: "actions", header: "Actions", width: 130, sticky: "end", render: () => <Button disabled>Configure</Button> }
|
||||
];
|
||||
{ id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (row) => <code>{row.field}</code>, value: (row) => row.field },
|
||||
{ id: "source", header: "i18n:govoplan-campaign.source.6da13add", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "detected_placeholder", label: "i18n:govoplan-campaign.detected_placeholder.094b5214" }] }, value: (row) => row.source },
|
||||
{ id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 120, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.yes.5397e058" }, { value: "no", label: "i18n:govoplan-campaign.no.816c52fd" }] }, value: (row) => row.required },
|
||||
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 130, sticky: "end", render: () => <Button disabled>i18n:govoplan-campaign.configure.792c81a4</Button> }];
|
||||
|
||||
}
|
||||
|
||||
export default function TemplatesPage() {
|
||||
@@ -139,8 +145,8 @@ export default function TemplatesPage() {
|
||||
<p>{selectedTemplate.description}</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>Duplicate</Button>
|
||||
<Button variant="primary" disabled>Use in campaign</Button>
|
||||
<Button disabled>i18n:govoplan-campaign.duplicate.972d5737</Button>
|
||||
<Button variant="primary" disabled>i18n:govoplan-campaign.use_in_campaign.779163bc</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,133 +158,144 @@ export default function TemplatesPage() {
|
||||
{active === "versions" && <TemplateVersions template={selectedTemplate} />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page module-entry-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>Templates</PageTitle>
|
||||
<p>Reusable message templates. Open a template to edit content, fields, preview and usage.</p>
|
||||
<PageTitle>i18n:govoplan-campaign.templates.f25b700e</PageTitle>
|
||||
<p>i18n:govoplan-campaign.reusable_message_templates_open_a_template_to_ed.792e2afb</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>Import</Button>
|
||||
<Button variant="primary" disabled>Export</Button>
|
||||
<Button disabled>i18n:govoplan-campaign.import.d6fbc9d2</Button>
|
||||
<Button variant="primary" disabled>i18n:govoplan-campaign.export.f3e4fadb</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title={
|
||||
<div className="module-card-heading">
|
||||
<h2>All templates</h2>
|
||||
<span>Last loaded: local demo data</span>
|
||||
<div className="module-card-heading">
|
||||
<h2>i18n:govoplan-campaign.all_templates.0bba114c</h2>
|
||||
<span>i18n:govoplan-campaign.last_loaded_local_demo_data.62ee2f5d</span>
|
||||
</div>
|
||||
}
|
||||
actions={<Button disabled>Refresh</Button>}
|
||||
>
|
||||
actions={<Button disabled>i18n:govoplan-campaign.refresh.56e3badc</Button>}>
|
||||
|
||||
<DataGrid
|
||||
id="template-library"
|
||||
rows={templateRecords}
|
||||
columns={templateLibraryColumns(openTemplate)}
|
||||
getRowKey={(template) => template.id}
|
||||
emptyText="No templates found."
|
||||
className="compact-table-wrap module-table-wrap module-entry-table"
|
||||
/>
|
||||
emptyText="i18n:govoplan-campaign.no_templates_found.326abcdd"
|
||||
className="compact-table-wrap module-table-wrap module-entry-table" />
|
||||
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function TemplateOverview({ template }: { template: TemplateRecord }) {
|
||||
function TemplateOverview({ template }: {template: TemplateRecord;}) {
|
||||
return (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Template status">
|
||||
<Card title="i18n:govoplan-campaign.template_status.8bec97cf">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Status</dt><dd><StatusBadge status={template.status} /></dd></div>
|
||||
<div><dt>Type</dt><dd>{template.type}</dd></div>
|
||||
<div><dt>Updated</dt><dd>{template.updatedAt}</dd></div>
|
||||
<div><dt>Versions</dt><dd>{template.versions}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.status.bae7d5be</dt><dd><StatusBadge status={template.status} /></dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.type.3deb7456</dt><dd>{templateTypeLabel(template.type)}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.updated.f2f8570d</dt><dd>{template.updatedAt}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.versions.a239107e</dt><dd>{template.versions}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="Compatibility notes">
|
||||
<p className="muted">Campaigns can reuse this template, but each campaign still needs a field matching check because campaign fields and template placeholders can drift.</p>
|
||||
<Card title="i18n:govoplan-campaign.compatibility_notes.7d648a6d">
|
||||
<p className="muted">i18n:govoplan-campaign.campaigns_can_reuse_this_template_but_each_campa.bdb25009</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>Subject placeholders are detected</span>
|
||||
<span>Body placeholders are compared with campaign fields</span>
|
||||
<span>A mapping wizard can be added later</span>
|
||||
<span>i18n:govoplan-campaign.subject_placeholders_are_detected.96663276</span>
|
||||
<span>i18n:govoplan-campaign.body_placeholders_are_compared_with_campaign_fie.bbd1d67f</span>
|
||||
<span>i18n:govoplan-campaign.a_mapping_wizard_can_be_added_later.6b2fe0f3</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function TemplateContent({ template }: { template: TemplateRecord }) {
|
||||
function TemplateContent({ template }: {template: TemplateRecord;}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const subject = translateText(template.subject);
|
||||
const body = translateText(template.body);
|
||||
return (
|
||||
<Card title="Template content" actions={<Button disabled>Save changes</Button>}>
|
||||
<Card title="i18n:govoplan-campaign.template_content.48630575" actions={<Button disabled>i18n:govoplan-campaign.save_changes.179359b3</Button>}>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<label className="form-field"><FieldLabel className="form-label" help="Read-only subject stored in this reusable template record.">Subject</FieldLabel><input value={template.subject} readOnly /></label>
|
||||
<label className="form-field full-span"><FieldLabel className="form-label" help="Read-only body stored in this reusable template record.">Body</FieldLabel><textarea rows={10} value={template.body} readOnly /></label>
|
||||
<label className="form-field"><FieldLabel className="form-label" help="i18n:govoplan-campaign.read_only_subject_stored_in_this_reusable_templa.549ae246">i18n:govoplan-campaign.subject.8d183dbd</FieldLabel><input value={subject} readOnly /></label>
|
||||
<label className="form-field full-span"><FieldLabel className="form-label" help="i18n:govoplan-campaign.read_only_body_stored_in_this_reusable_template_.98fbf178">i18n:govoplan-campaign.body.718a7e8a</FieldLabel><textarea rows={10} value={body} readOnly /></label>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
</Card>);
|
||||
|
||||
}
|
||||
|
||||
function TemplateFields({ template }: { template: TemplateRecord }) {
|
||||
function TemplateFields({ template }: {template: TemplateRecord;}) {
|
||||
return (
|
||||
<Card title="Template fields" actions={<Button disabled>Add field hint</Button>}>
|
||||
<Card title="i18n:govoplan-campaign.template_fields.e6d7d8ac" actions={<Button disabled>i18n:govoplan-campaign.add_field_hint.b5029047</Button>}>
|
||||
<DataGrid
|
||||
id={`template-${template.id}-fields`}
|
||||
rows={template.fields.map((field) => ({ id: field, field, source: "Detected placeholder", required: "Yes" }))}
|
||||
rows={template.fields.map((field) => ({ id: field, field, source: "detected_placeholder", required: "yes" }))}
|
||||
columns={templateFieldColumns()}
|
||||
getRowKey={(field) => field.id}
|
||||
emptyText="No fields detected."
|
||||
className="compact-table-wrap module-table-wrap module-table"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
emptyText="i18n:govoplan-campaign.no_fields_detected.0cb30100"
|
||||
className="compact-table-wrap module-table-wrap module-table" />
|
||||
|
||||
</Card>);
|
||||
|
||||
}
|
||||
|
||||
function TemplatePreview({ template }: { template: TemplateRecord }) {
|
||||
const preview = template.body
|
||||
.replace(/\{\{recipient_name\}\}/g, "Jane Example")
|
||||
.replace(/\{\{period\}\}/g, "May 2026")
|
||||
.replace(/\{\{amount\}\}/g, "123.45 EUR")
|
||||
.replace(/\{\{deadline\}\}/g, "30 June 2026")
|
||||
.replace(/\{\{file_label\}\}/g, "statement")
|
||||
.replace(/\{\{contact_email\}\}/g, "support@example.org");
|
||||
function TemplatePreview({ template }: {template: TemplateRecord;}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const subject = translateText(template.subject);
|
||||
const body = translateText(template.body);
|
||||
const sampleRecipientName = translateText("i18n:govoplan-campaign.sample_recipient_name");
|
||||
const samplePeriod = translateText("i18n:govoplan-campaign.sample_period");
|
||||
const sampleAmount = translateText("i18n:govoplan-campaign.sample_amount");
|
||||
const sampleDeadline = translateText("i18n:govoplan-campaign.sample_deadline");
|
||||
const sampleFileLabel = translateText("i18n:govoplan-campaign.sample_file_label");
|
||||
const preview = body.
|
||||
replace(/\{\{recipient_name\}\}/g, sampleRecipientName).
|
||||
replace(/\{\{period\}\}/g, samplePeriod).
|
||||
replace(/\{\{amount\}\}/g, sampleAmount).
|
||||
replace(/\{\{deadline\}\}/g, sampleDeadline).
|
||||
replace(/\{\{file_label\}\}/g, sampleFileLabel).
|
||||
replace(/\{\{contact_email\}\}/g, "support@example.org");
|
||||
|
||||
return (
|
||||
<Card title="Preview" actions={<Button disabled>Change sample data</Button>}>
|
||||
<Card title="i18n:govoplan-campaign.preview.f1fbb2b4" actions={<Button disabled>i18n:govoplan-campaign.change_sample_data.e6903542</Button>}>
|
||||
<div className="message-preview">
|
||||
<strong>{template.subject.replace(/\{\{period\}\}/g, "May 2026").replace(/\{\{deadline\}\}/g, "30 June 2026").replace(/\{\{recipient_name\}\}/g, "Jane Example")}</strong>
|
||||
<strong>{subject.replace(/\{\{period\}\}/g, samplePeriod).replace(/\{\{deadline\}\}/g, sampleDeadline).replace(/\{\{recipient_name\}\}/g, sampleRecipientName)}</strong>
|
||||
<pre>{preview}</pre>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
</Card>);
|
||||
|
||||
}
|
||||
|
||||
function TemplateUsage({ template }: { template: TemplateRecord }) {
|
||||
function TemplateUsage({ template }: {template: TemplateRecord;}) {
|
||||
return (
|
||||
<Card title="Usage">
|
||||
<p className="muted">This view will list campaigns using the template once the backend template model is available.</p>
|
||||
<Card title="i18n:govoplan-campaign.usage.0bb18642">
|
||||
<p className="muted">i18n:govoplan-campaign.this_view_will_list_campaigns_using_the_template.116f7ee0</p>
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Currently used by</dt><dd>{template.usedBy}</dd></div>
|
||||
<div><dt>Safe to edit</dt><dd>Requires versioning once templates are shared</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.currently_used_by.a065cfbe</dt><dd>{template.usedBy}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.safe_to_edit.57b64df5</dt><dd>i18n:govoplan-campaign.requires_versioning_once_templates_are_shared.e020b221</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
);
|
||||
</Card>);
|
||||
|
||||
}
|
||||
|
||||
function TemplateVersions({ template }: { template: TemplateRecord }) {
|
||||
function TemplateVersions({ template }: {template: TemplateRecord;}) {
|
||||
return (
|
||||
<Card title="Versions and import/export" actions={<div className="button-row compact-actions"><Button disabled>Import</Button><Button disabled>Export</Button></div>}>
|
||||
<Card title="i18n:govoplan-campaign.versions_and_import_export.cc05cb43" actions={<div className="button-row compact-actions"><Button disabled>i18n:govoplan-campaign.import.d6fbc9d2</Button><Button disabled>i18n:govoplan-campaign.export.f3e4fadb</Button></div>}>
|
||||
<div className="placeholder-stack">
|
||||
<span>{template.versions} local versions in the planned model</span>
|
||||
<span>Sent campaigns should keep a fixed template snapshot</span>
|
||||
<span>Draft campaigns can update to a newer template version later</span>
|
||||
<span>{template.versions} i18n:govoplan-campaign.local_versions_in_the_planned_model.c1bf26cb</span>
|
||||
<span>i18n:govoplan-campaign.sent_campaigns_should_keep_a_fixed_template_snap.167d56c2</span>
|
||||
<span>i18n:govoplan-campaign.draft_campaigns_can_update_to_a_newer_template_v.0f854608</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
</Card>);
|
||||
|
||||
}
|
||||
|
||||
2232
webui/src/i18n/generatedTranslations.ts
Normal file
2232
webui/src/i18n/generatedTranslations.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
||||
import "./styles/campaign-workspace.css";
|
||||
export { default } from "./module";
|
||||
export * from "./module";
|
||||
export * from "./api/campaigns";
|
||||
|
||||
@@ -2,58 +2,58 @@ import type { CampaignWorkspaceSection } from "../types";
|
||||
import ModuleSubnav, { type ModuleSubnavGroup } from "./ModuleSubnav";
|
||||
|
||||
const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
|
||||
{
|
||||
items: [{ id: "overview", label: "Overview", primary: true }]
|
||||
},
|
||||
{
|
||||
title: "CAMPAIGN",
|
||||
items: [
|
||||
{ id: "fields", label: "Fields" },
|
||||
{ id: "files", label: "Attachments" },
|
||||
{ id: "recipients", label: "Sender & Recipients" },
|
||||
{ id: "recipient-data", label: "Recipient data" },
|
||||
{ id: "template", label: "Template" }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "SETTINGS",
|
||||
items: [
|
||||
{ id: "mail-settings", label: "Mail settings" },
|
||||
{ id: "global-settings", label: "Campaign settings" }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "POLICIES",
|
||||
items: [
|
||||
{ id: "mail-policy", label: "Mail policy" },
|
||||
{ id: "policies", label: "Campaign policies" }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "SEND",
|
||||
items: [
|
||||
{ id: "review", label: "Review & Send" }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "REPORT",
|
||||
items: [
|
||||
{ id: "report", label: "Report" },
|
||||
{ id: "audit", label: "Audit log" }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "ADVANCED",
|
||||
items: [{ id: "json", label: "JSON", subtle: true }]
|
||||
}
|
||||
];
|
||||
{
|
||||
items: [{ id: "overview", label: "i18n:govoplan-campaign.overview.0efc2e6b", primary: true }]
|
||||
},
|
||||
{
|
||||
title: "i18n:govoplan-campaign.campaign.9e28fcfc",
|
||||
items: [
|
||||
{ id: "fields", label: "i18n:govoplan-campaign.fields.e8b68527" },
|
||||
{ id: "files", label: "i18n:govoplan-campaign.attachments.6771ade6" },
|
||||
{ id: "recipients", label: "i18n:govoplan-campaign.sender_recipients.922c6d24" },
|
||||
{ id: "recipient-data", label: "i18n:govoplan-campaign.recipient_data.c2baaf10" },
|
||||
{ id: "template", label: "i18n:govoplan-campaign.template.3ec1ae06" }]
|
||||
|
||||
},
|
||||
{
|
||||
title: "i18n:govoplan-campaign.settings.c4b92432",
|
||||
items: [
|
||||
{ id: "mail-settings", label: "i18n:govoplan-campaign.mail_settings.19e07f55" },
|
||||
{ id: "global-settings", label: "i18n:govoplan-campaign.campaign_settings.efffec26" }]
|
||||
|
||||
},
|
||||
{
|
||||
title: "i18n:govoplan-campaign.policies.f03ff937",
|
||||
items: [
|
||||
{ id: "mail-policy", label: "i18n:govoplan-campaign.mail_policy.3eb5d32a" },
|
||||
{ id: "policies", label: "i18n:govoplan-campaign.campaign_policies.0b5de1f5" }]
|
||||
|
||||
},
|
||||
{
|
||||
title: "i18n:govoplan-campaign.send.a2469c47",
|
||||
items: [
|
||||
{ id: "review", label: "i18n:govoplan-campaign.review_send.1627617d" }]
|
||||
|
||||
},
|
||||
{
|
||||
title: "i18n:govoplan-campaign.report.7b8ddb90",
|
||||
items: [
|
||||
{ id: "report", label: "i18n:govoplan-campaign.report.ee45c303" },
|
||||
{ id: "audit", label: "i18n:govoplan-campaign.audit_log.3cfc5f1c" }]
|
||||
|
||||
},
|
||||
{
|
||||
title: "i18n:govoplan-campaign.advanced.6052c880",
|
||||
items: [{ id: "json", label: "i18n:govoplan-campaign.json.031a4e76", subtle: true }]
|
||||
}];
|
||||
|
||||
|
||||
export default function SectionSidebar({
|
||||
active,
|
||||
onSelect
|
||||
}: {
|
||||
active: CampaignWorkspaceSection;
|
||||
onSelect: (section: CampaignWorkspaceSection) => void;
|
||||
}) {
|
||||
|
||||
|
||||
|
||||
}: {active: CampaignWorkspaceSection;onSelect: (section: CampaignWorkspaceSection) => void;}) {
|
||||
return <ModuleSubnav active={active} groups={campaignSubnav} onSelect={onSelect} />;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { createElement, lazy, useCallback } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Card, ResourceAccessBoundary, type ApiSettings, type AuthInfo, type PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { getCampaign } from "./api/campaigns";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import "./styles/campaign-workspace.css";
|
||||
|
||||
const AddressBookPage = lazy(() => import("./features/addressbook/AddressBookPage"));
|
||||
const CampaignListPage = lazy(() => import("./features/campaigns/CampaignListPage"));
|
||||
@@ -11,37 +13,42 @@ const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage"));
|
||||
|
||||
const campaignRead = ["campaigns:campaign:read"];
|
||||
const operatorScopes = ["campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control", "campaigns:campaign:send"];
|
||||
const translations = {
|
||||
en: generatedTranslations.en,
|
||||
de: generatedTranslations.de
|
||||
};
|
||||
|
||||
export const campaignModule: PlatformWebModule = {
|
||||
id: "campaigns",
|
||||
label: "Campaigns",
|
||||
label: "i18n:govoplan-campaign.campaigns.01a23a28",
|
||||
version: "1.0.0",
|
||||
dependencies: ["access"],
|
||||
optionalDependencies: ["files", "mail"],
|
||||
translations,
|
||||
navItems: [
|
||||
{ to: "/campaigns", label: "Campaigns", iconName: "campaign", anyOf: campaignRead, order: 20 },
|
||||
{
|
||||
to: "/operator",
|
||||
label: "Operator Queue",
|
||||
iconName: "activity",
|
||||
anyOf: operatorScopes,
|
||||
order: 30
|
||||
},
|
||||
{ to: "/reports", label: "Reports", iconName: "reports", anyOf: ["campaigns:report:read"], order: 70 },
|
||||
{ to: "/address-book", label: "Address Book", iconName: "users", order: 80 },
|
||||
{ to: "/templates", label: "Templates", iconName: "form", order: 90 }
|
||||
],
|
||||
{ to: "/campaigns", label: "i18n:govoplan-campaign.campaigns.01a23a28", iconName: "campaign", anyOf: campaignRead, order: 20 },
|
||||
{
|
||||
to: "/operator",
|
||||
label: "i18n:govoplan-campaign.operator_queue.ddf23260",
|
||||
iconName: "activity",
|
||||
anyOf: operatorScopes,
|
||||
order: 30
|
||||
},
|
||||
{ to: "/reports", label: "i18n:govoplan-campaign.reports.88bc3fe3", iconName: "reports", anyOf: ["campaigns:report:read"], order: 70 },
|
||||
{ to: "/address-book", label: "i18n:govoplan-campaign.address_book.f6327f59", iconName: "users", order: 80 },
|
||||
{ to: "/templates", label: "i18n:govoplan-campaign.templates.f25b700e", iconName: "form", order: 90 }],
|
||||
|
||||
routes: [
|
||||
{ path: "/campaigns", anyOf: campaignRead, order: 20, render: ({ settings }) => createElement(CampaignListPage, { settings }) },
|
||||
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
|
||||
{ path: "/operator", anyOf: operatorScopes, order: 30, render: ({ settings }) => createElement(OperatorQueuePage, { settings }) },
|
||||
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: () => createElement(ReportsPage) },
|
||||
{ path: "/address-book", order: 80, render: () => createElement(AddressBookPage) },
|
||||
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }
|
||||
]
|
||||
{ path: "/campaigns", anyOf: campaignRead, order: 20, render: ({ settings }) => createElement(CampaignListPage, { settings }) },
|
||||
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
|
||||
{ path: "/operator", anyOf: operatorScopes, order: 30, render: ({ settings }) => createElement(OperatorQueuePage, { settings }) },
|
||||
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: () => createElement(ReportsPage) },
|
||||
{ path: "/address-book", order: 80, render: () => createElement(AddressBookPage) },
|
||||
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }]
|
||||
|
||||
};
|
||||
|
||||
function CampaignResourceRoute({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
||||
function CampaignResourceRoute({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
|
||||
const { campaignId = "" } = useParams();
|
||||
const tenantId = (auth.active_tenant ?? auth.tenant).id;
|
||||
const probe = useCallback(() => getCampaign(settings, campaignId), [settings.apiBaseUrl, settings.apiKey, settings.accessToken, campaignId, tenantId]);
|
||||
@@ -57,14 +64,14 @@ function CampaignResourceRoute({ settings, auth }: { settings: ApiSettings; auth
|
||||
function ReportsPage() {
|
||||
return createElement(
|
||||
"div",
|
||||
{ className: "content-pad" },
|
||||
{ className: "content-pad workspace-data-page" },
|
||||
createElement(
|
||||
"div",
|
||||
{ className: "page-heading" },
|
||||
createElement("h1", null, "Reports"),
|
||||
createElement("p", null, "This module is prepared but not implemented yet.")
|
||||
{ className: "page-heading workspace-heading" },
|
||||
createElement("h1", null, "i18n:govoplan-campaign.reports"),
|
||||
createElement("p", null, "i18n:govoplan-campaign.reports_module_prepared")
|
||||
),
|
||||
createElement(Card, null, createElement("p", { className: "muted" }, "Next passes will add functionality here."))
|
||||
createElement(Card, null, createElement("p", { className: "muted" }, "i18n:govoplan-campaign.next_passes_will_add_functionality_here"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
/* Campaign workspace data interfaces. Kept separate from layout.css so local sticky/table tweaks stay untouched. */
|
||||
.workspace-data-page .card { margin-bottom: 18px; }
|
||||
.workspace-data-page > .workspace-heading {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 75;
|
||||
background: var(--bg, #f8f7f4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
box-shadow: 0 8px 18px rgba(65, 60, 52, .08);
|
||||
margin: -28px -34px 22px;
|
||||
padding: 18px 34px 16px;
|
||||
}
|
||||
.workspace-heading .mono-small { margin-top: 8px; }
|
||||
.alert.success { background: var(--success-bg); color: var(--success-text); margin-bottom: 12px; }
|
||||
.alert.info { background: var(--info-bg); color: var(--info-text); margin-bottom: 12px; }
|
||||
.small-note { font-size: 12px; }
|
||||
@@ -177,16 +165,6 @@
|
||||
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||
gap: 8px 16px;
|
||||
}
|
||||
.mail-server-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(280px, 1fr));
|
||||
gap: 22px;
|
||||
}
|
||||
.mail-server-subsection {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
align-content: start;
|
||||
}
|
||||
.form-span-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -238,7 +216,6 @@
|
||||
.additional-global-values-table td:nth-child(1) { width: 260px; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.mail-server-settings-grid { grid-template-columns: 1fr; }
|
||||
.toggle-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -328,19 +305,9 @@
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.campaigns-page {
|
||||
padding: 28px 34px;
|
||||
}
|
||||
.campaigns-page > .alert {
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.campaigns-page .card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
background: var(--panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
.campaigns-page .card-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -352,10 +319,6 @@
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
box-shadow: 0 14px 18px -22px rgba(45, 43, 40, .75);
|
||||
}
|
||||
.campaigns-page .card-header::after {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
.campaigns-page .card-body {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -426,17 +389,7 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.campaigns-page {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Module entry/detail pages (Templates, Files). */
|
||||
.module-entry-page {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Module entry/detail pages. */
|
||||
.module-card-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -609,33 +562,7 @@
|
||||
|
||||
/* Template editor refinements. */
|
||||
.template-body-mode {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
width: fit-content;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--panel-soft);
|
||||
padding: 4px;
|
||||
}
|
||||
.template-body-mode button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.template-body-mode button.active {
|
||||
background: #fff;
|
||||
color: var(--text-strong);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.12);
|
||||
}
|
||||
.template-body-mode button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .58;
|
||||
}
|
||||
.template-editor-mode {
|
||||
margin-top: -2px;
|
||||
@@ -785,12 +712,6 @@
|
||||
.recipient-add-row .email-address-input {
|
||||
max-width: 720px;
|
||||
}
|
||||
.unsaved-changes-dialog {
|
||||
max-width: 520px;
|
||||
}
|
||||
.unsaved-changes-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.campaign-header-grid,
|
||||
@@ -1052,37 +973,6 @@
|
||||
.recipient-data-table td:nth-child(3) { min-width: 192px; }
|
||||
.recipient-index-cell { white-space: nowrap; }
|
||||
|
||||
/* Admin overview and address book mock module. */
|
||||
.admin-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.admin-overview-link {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-soft);
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
.admin-overview-link:hover {
|
||||
background: #fff;
|
||||
border-color: var(--line-dark);
|
||||
}
|
||||
.admin-overview-link strong {
|
||||
display: block;
|
||||
color: var(--text-strong);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.admin-overview-link span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.address-book-scope-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -1674,6 +1564,77 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.deliverability-preflight {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.deliverability-preflight-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.deliverability-preflight-header h3 {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.deliverability-preflight-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.deliverability-preflight-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--blue);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.deliverability-preflight-item[data-state="ready"] {
|
||||
border-left-color: var(--green);
|
||||
}
|
||||
|
||||
.deliverability-preflight-item[data-state="warning"] {
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
|
||||
.deliverability-preflight-item[data-state="blocked"] {
|
||||
border-left-color: var(--red);
|
||||
}
|
||||
|
||||
.deliverability-preflight-item > div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.deliverability-preflight-item span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.deliverability-preflight-item strong {
|
||||
min-width: 0;
|
||||
color: var(--text-strong);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.review-flow-stage-actions {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -1739,6 +1700,15 @@
|
||||
.review-flow-execution-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.deliverability-preflight-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.deliverability-preflight-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Collapsible workflow stage headers keep status and disclosure controls grouped. */
|
||||
@@ -1980,122 +1950,71 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Administration foundation ------------------------------------------------ */
|
||||
.admin-dialog {
|
||||
width: min(680px, calc(100vw - 40px));
|
||||
}
|
||||
|
||||
.admin-dialog-wide {
|
||||
width: min(1040px, calc(100vw - 40px));
|
||||
}
|
||||
|
||||
.admin-dialog > .dialog-header {
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
.admin-dialog > .dialog-footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
box-shadow: 0 -8px 24px rgba(15, 23, 42, .06);
|
||||
}
|
||||
|
||||
.admin-form-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-form-grid.two-columns {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-assignment-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.admin-selection-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-muted, #f6f7f9);
|
||||
}
|
||||
|
||||
.admin-selection-item {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 9px;
|
||||
padding: 8px 9px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-selection-item:hover {
|
||||
background: var(--surface, #fff);
|
||||
}
|
||||
|
||||
.admin-selection-item.disabled {
|
||||
opacity: .52;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.admin-selection-item input {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.admin-selection-item span {
|
||||
.recipient-outcome-cell {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-selection-item small {
|
||||
color: var(--muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.admin-selection-item small code {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.admin-inline-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.admin-permission-groups {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.admin-permission-group {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.admin-permission-group legend {
|
||||
padding: 0 6px;
|
||||
font-weight: 700;
|
||||
.recipient-outcome-cell strong,
|
||||
.recipient-outcome-cell span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.recipient-outcome-cell span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.recipient-outcome-error {
|
||||
color: var(--danger, #b91c1c);
|
||||
}
|
||||
|
||||
.attempt-history-section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attempt-history-section h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.attempt-history-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.attempt-history-table {
|
||||
width: 100%;
|
||||
min-width: 760px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.attempt-history-table th,
|
||||
.attempt-history-table td {
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.attempt-history-table th {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.attempt-history-table tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.attempt-history-table td:last-child {
|
||||
max-width: 420px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-secret {
|
||||
@@ -2127,45 +2046,6 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 850px) {
|
||||
.admin-form-grid.two-columns,
|
||||
.admin-assignment-grid,
|
||||
.admin-permission-groups {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Administration visual alignment ---------------------------------------- */
|
||||
.admin-section-page {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.admin-section-page > .loading-frame {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.admin-page-heading {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.admin-page-actions {
|
||||
align-self: start;
|
||||
}
|
||||
.admin-table-surface {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.admin-table-surface > .data-grid-shell {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
box-shadow: var(--shadow-card, 0 2px 10px rgba(0,0,0,.06));
|
||||
}
|
||||
.recipient-import-modal {
|
||||
width: min(1100px, calc(100vw - 32px));
|
||||
}
|
||||
@@ -2394,40 +2274,10 @@
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
.card-body > .admin-table-surface:only-child {
|
||||
margin: -22px -24px;
|
||||
width: calc(100% + 48px);
|
||||
max-width: inherit;
|
||||
}
|
||||
.card-body > .admin-table-surface:only-child > .data-grid-shell {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.campaigns-page .card-body > .admin-table-surface:only-child {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.admin-icon-actions {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 36px;
|
||||
justify-content: end;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
}
|
||||
.admin-icon-button.btn {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
.admin-icon-button.btn svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
.admin-audit-grid .data-grid-scroll-region {
|
||||
max-height: min(68vh, 720px);
|
||||
}
|
||||
@@ -2499,21 +2349,6 @@
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.admin-scope-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-scope-list code {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-muted, #f6f7f9);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
.campaign-access-dialog > .dialog-header,
|
||||
.campaign-access-dialog > .dialog-footer {
|
||||
background: var(--surface);
|
||||
@@ -2523,27 +2358,6 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Administration scope separation and ownership safeguards ---------------- */
|
||||
.admin-overview-section-label {
|
||||
margin: 2px 0 14px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-protection-note {
|
||||
margin: 4px 0 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #d8bd78;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff8e6;
|
||||
color: #6f5719;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* Campaign policy tables. */
|
||||
.campaign-policy-row {
|
||||
grid-template-columns: minmax(190px, .9fr) minmax(180px, .7fr) minmax(220px, 1fr);
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type { ApiSettings, AuthInfo, CampaignListItem, CampaignWorkspaceSection, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext, PlatformWebModule, WizardStep } from "@govoplan/core-webui";
|
||||
export type { ApiSettings, AuthInfo, CampaignListItem, CampaignWorkspaceSection, DeltaDeletedItem, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext, PlatformWebModule, WizardStep } from "@govoplan/core-webui";
|
||||
|
||||
Reference in New Issue
Block a user