initial commit after split

This commit is contained in:
2026-06-24 01:43:27 +02:00
parent 2406e70597
commit 7f25a3ccdf
125 changed files with 25551 additions and 0 deletions

519
webui/src/api/admin.ts Normal file
View File

@@ -0,0 +1,519 @@
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
export type PermissionItem = {
scope: string;
label: string;
description: string;
category: string;
level: "tenant" | "system";
system_template_id?: string | null;
system_required?: boolean;
};
export type AdminOverview = {
active_tenant_id: string;
active_tenant_name: string;
tenant_count?: number | null;
system_account_count?: number | null;
system_group_template_count?: number | null;
system_role_template_count?: number | null;
user_count: number;
active_user_count: number;
group_count: number;
role_count: number;
active_api_key_count: number;
capabilities: string[];
};
export type TenantAdminItem = {
id: string;
slug: string;
name: string;
description?: string | null;
default_locale: string;
settings: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
effective_governance: Record<string, boolean>;
is_active: boolean;
counts: Record<string, number>;
created_at: string;
updated_at: string;
};
export type TenantOwnerCandidate = {
account_id: string;
email: string;
display_name?: string | null;
};
export type RoleSummary = {
id: string;
slug: string;
name: string;
description?: string | null;
permissions: string[];
effective_permission_count: number;
is_builtin: boolean;
is_assignable: boolean;
user_assignments: number;
group_assignments: number;
level: "tenant" | "system";
system_template_id?: string | null;
system_required?: boolean;
};
export type GroupSummary = {
id: string;
slug: string;
name: string;
description?: string | null;
is_active: boolean;
member_count: number;
member_ids: string[];
roles: RoleSummary[];
created_at: string;
updated_at: string;
system_template_id?: string | null;
system_required?: boolean;
};
export type UserAdminItem = {
id: string;
account_id: string;
tenant_id: string;
email: string;
display_name?: string | null;
is_active: boolean;
account_is_active: boolean;
password_reset_required: boolean;
last_login_at?: string | null;
groups: GroupSummary[];
roles: RoleSummary[];
effective_scopes: string[];
is_owner: boolean;
is_last_active_owner: boolean;
created_at: string;
updated_at: string;
};
export type SystemAccountItem = {
account_id: string;
email: string;
display_name?: string | null;
is_active: boolean;
memberships: Array<{ tenant_id: string; tenant_name: string; user_id: string; is_active: boolean; role_ids: string[]; group_ids: string[]; is_owner: boolean; is_last_active_owner: boolean }>;
roles: RoleSummary[];
last_login_at?: string | null;
};
export type SystemMembershipDraft = {
tenant_id: string;
is_active: boolean;
role_ids: string[];
group_ids: string[];
is_owner?: boolean;
is_last_active_owner?: boolean;
};
export type PrivacyRetentionPolicyFieldKey =
| "store_raw_campaign_json"
| "raw_campaign_json_retention_days"
| "generated_eml_retention_days"
| "stored_report_detail_retention_days"
| "mock_mailbox_retention_days"
| "audit_detail_retention_days"
| "audit_detail_level";
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
export type PrivacyRetentionLimitPermissionPatch = Partial<PrivacyRetentionLimitPermissions>;
export type PrivacyRetentionPolicy = {
store_raw_campaign_json: boolean;
raw_campaign_json_retention_days?: number | null;
generated_eml_retention_days?: number | null;
stored_report_detail_retention_days?: number | null;
mock_mailbox_retention_days?: number | null;
audit_detail_retention_days?: number | null;
audit_detail_level: "full" | "redacted" | "minimal";
allow_lower_level_limits: PrivacyRetentionLimitPermissions;
};
export type PrivacyRetentionPolicyPatch = Partial<Omit<PrivacyRetentionPolicy, "allow_lower_level_limits">> & {
allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch;
};
export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign";
export type PrivacyRetentionPolicyScopeResponse = {
scope_type: PrivacyRetentionPolicyScope;
scope_id?: string | null;
policy: PrivacyRetentionPolicyPatch;
effective_policy: PrivacyRetentionPolicy;
parent_policy?: PrivacyRetentionPolicy | null;
};
export type SystemSettingsItem = {
default_locale: string;
allow_tenant_custom_groups: boolean;
allow_tenant_custom_roles: boolean;
allow_tenant_api_keys: boolean;
privacy_retention_policy: PrivacyRetentionPolicy;
settings: Record<string, unknown>;
};
export type RetentionRunResponse = {
result: {
dry_run: boolean;
policy: PrivacyRetentionPolicy;
cutoffs: Record<string, string | null>;
effective_policy_scope?: string;
counts: Record<string, Record<string, number>>;
};
};
export type GovernanceAssignment = {
tenant_id: string;
mode: "available" | "required";
};
export type GovernanceTemplateItem = {
id: string;
kind: "group" | "role";
slug: string;
name: string;
description?: string | null;
permissions: string[];
effective_permission_count: number;
is_active: boolean;
assignments: GovernanceAssignment[];
created_at: string;
updated_at: string;
};
export type ApiKeyAdminItem = {
id: string;
user_id: string;
user_email: string;
name: string;
prefix: string;
scopes: string[];
expires_at?: string | null;
last_used_at?: string | null;
revoked_at?: string | null;
created_at: string;
};
export type AuditAdminItem = {
id: string;
scope: "tenant" | "system";
tenant_id?: string | null;
actor_email?: string | null;
action: string;
object_type?: string | null;
object_id?: string | null;
details: Record<string, unknown>;
created_at: string;
};
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
return apiFetch(settings, "/api/v1/admin/overview");
}
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions");
return response.permissions;
}
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants");
return response.tenants;
}
export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> {
const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates");
return response.accounts;
}
export function createTenant(settings: ApiSettings, payload: {
slug: string;
name: string;
owner_account_id?: string | null;
description?: string | null;
default_locale?: string;
settings?: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
}): Promise<TenantAdminItem> {
return apiFetch(settings, "/api/v1/admin/tenants", { method: "POST", body: JSON.stringify(payload) });
}
export function updateTenant(settings: ApiSettings, tenantId: string, payload: Partial<{
name: string;
description: string | null;
default_locale: string;
settings: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
effective_governance: Record<string, boolean>;
is_active: boolean;
}>): Promise<TenantAdminItem> {
return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]> {
const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users");
return response.users;
}
export function createUser(settings: ApiSettings, payload: {
email: string;
display_name?: string | null;
password?: string | null;
password_reset_required?: boolean;
is_active?: boolean;
group_ids?: string[];
role_ids?: string[];
}): Promise<{ user: UserAdminItem; account_created: boolean; temporary_password?: string | null }> {
return apiFetch(settings, "/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) });
}
export function updateUser(settings: ApiSettings, userId: string, payload: Partial<{
display_name: string | null;
is_active: boolean;
group_ids: string[];
role_ids: string[];
}>): Promise<UserAdminItem> {
return apiFetch(settings, `/api/v1/admin/users/${userId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups");
return response.groups;
}
export function createGroup(settings: ApiSettings, payload: {
slug: string;
name: string;
description?: string | null;
is_active?: boolean;
member_ids?: string[];
role_ids?: string[];
}): Promise<GroupSummary> {
return apiFetch(settings, "/api/v1/admin/groups", { method: "POST", body: JSON.stringify(payload) });
}
export function updateGroup(settings: ApiSettings, groupId: string, payload: Partial<{
name: string;
description: string | null;
is_active: boolean;
member_ids: string[];
role_ids: string[];
}>): Promise<GroupSummary> {
return apiFetch(settings, `/api/v1/admin/groups/${groupId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]> {
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/roles");
return response.roles;
}
export function createRole(settings: ApiSettings, payload: {
slug: string;
name: string;
description?: string | null;
permissions: string[];
}): Promise<RoleSummary> {
return apiFetch(settings, "/api/v1/admin/roles", { method: "POST", body: JSON.stringify(payload) });
}
export function updateRole(settings: ApiSettings, roleId: string, payload: {
name: string;
description?: string | null;
permissions: string[];
is_assignable: boolean;
}): Promise<RoleSummary> {
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteRole(settings: ApiSettings, roleId: string): Promise<void> {
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "DELETE" });
}
export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> {
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles");
return response.roles;
}
export function createSystemRole(settings: ApiSettings, payload: {
slug: string;
name: string;
description?: string | null;
permissions: string[];
}): Promise<RoleSummary> {
return apiFetch(settings, "/api/v1/admin/system/roles", { method: "POST", body: JSON.stringify(payload) });
}
export function updateSystemRole(settings: ApiSettings, roleId: string, payload: {
name: string;
description?: string | null;
permissions: string[];
is_assignable: boolean;
}): Promise<RoleSummary> {
return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteSystemRole(settings: ApiSettings, roleId: string): Promise<void> {
return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "DELETE" });
}
export async function fetchSystemAccounts(settings: ApiSettings): Promise<{ accounts: SystemAccountItem[]; roles: RoleSummary[] }> {
return apiFetch(settings, "/api/v1/admin/system/accounts");
}
export function updateSystemAccount(settings: ApiSettings, accountId: string, payload: {
display_name?: string | null;
is_active?: boolean;
role_ids?: string[];
}): Promise<SystemAccountItem> {
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function updateSystemAccountRoles(settings: ApiSettings, accountId: string, roleIds: string[]): Promise<SystemAccountItem> {
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/roles`, {
method: "PUT",
body: JSON.stringify({ role_ids: roleIds })
});
}
export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise<ApiKeyAdminItem[]> {
const params = new URLSearchParams();
if (includeRevoked) params.set("include_revoked", "true");
const suffix = params.toString() ? `?${params.toString()}` : "";
const response = await apiFetch<{ api_keys: ApiKeyAdminItem[] }>(settings, `/api/v1/admin/api-keys${suffix}`);
return response.api_keys;
}
export function createApiKey(settings: ApiSettings, payload: {
name: string;
user_id?: string | null;
scopes: string[];
expires_at?: string | null;
}): Promise<ApiKeyAdminItem & { secret: string }> {
return apiFetch(settings, "/api/v1/admin/api-keys", { method: "POST", body: JSON.stringify(payload) });
}
export function revokeApiKey(settings: ApiSettings, keyId: string): Promise<ApiKeyAdminItem> {
return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" });
}
export type AuditQueryOptions = {
tenantId?: string | null;
allTenants?: boolean;
scope?: "tenant" | "system";
limit?: number;
offset?: number;
page?: number;
pageSize?: number;
sortBy?: "time" | "actor" | "action" | "object" | "tenant";
sortDirection?: "asc" | "desc";
filters?: Partial<Record<"time" | "actor" | "action" | "object" | "tenant", string>>;
};
export async function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise<{ items: AuditAdminItem[]; total: number; page: number; page_size: number; pages: number }> {
const params = new URLSearchParams();
if (options.tenantId) params.set("tenant_id", options.tenantId);
if (options.allTenants) params.set("all_tenants", "true");
if (options.scope) params.set("scope", options.scope);
if (options.limit) params.set("limit", String(options.limit));
if (options.offset) params.set("offset", String(options.offset));
if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.sortBy) params.set("sort_by", options.sortBy);
if (options.sortDirection) params.set("sort_direction", options.sortDirection);
for (const [column, value] of Object.entries(options.filters ?? {})) {
if (value?.trim()) params.set(`filter_${column}`, value);
}
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/audit${suffix}`);
}
export function createSystemAccount(settings: ApiSettings, payload: {
email: string;
display_name?: string | null;
password?: string | null;
password_reset_required?: boolean;
is_active?: boolean;
role_ids?: string[];
memberships?: SystemMembershipDraft[];
}): Promise<{ account: SystemAccountItem; temporary_password?: string | null }> {
return apiFetch(settings, "/api/v1/admin/system/accounts", { method: "POST", body: JSON.stringify(payload) });
}
export function updateSystemMemberships(settings: ApiSettings, accountId: string, memberships: SystemMembershipDraft[]): Promise<SystemAccountItem> {
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/memberships`, {
method: "PUT", body: JSON.stringify({ memberships })
});
}
export function fetchSystemSettings(settings: ApiSettings): Promise<SystemSettingsItem> {
return apiFetch(settings, "/api/v1/admin/system/settings");
}
export type SystemSettingsUpdatePayload = {
default_locale: string;
allow_tenant_custom_groups: boolean;
allow_tenant_custom_roles: boolean;
allow_tenant_api_keys: boolean;
privacy_retention_policy?: PrivacyRetentionPolicy | null;
};
export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise<SystemSettingsItem> {
return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) });
}
export function getPrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`);
}
export function updatePrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, policy: PrivacyRetentionPolicyPatch, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`, { method: "PUT", body: JSON.stringify({ policy }) });
}
export function runRetentionPolicy(settings: ApiSettings, dryRun = true): Promise<RetentionRunResponse> {
return apiFetch(settings, "/api/v1/admin/system/retention/run", { method: "POST", body: JSON.stringify({ dry_run: dryRun }) });
}
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`);
return response.templates;
}
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) });
}
export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit<GovernanceTemplateItem, "id" | "kind" | "slug" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string): Promise<void> {
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "DELETE" });
}

680
webui/src/api/campaigns.ts Normal file
View File

@@ -0,0 +1,680 @@
import type { ApiSettings, CampaignListItem } from "../types";
import { apiDownload, apiFetch } from "./client";
export type CampaignListResponse =
| CampaignListItem[]
| {
campaigns?: CampaignListItem[];
items?: CampaignListItem[];
results?: CampaignListItem[];
};
export type CampaignShare = {
id: string;
campaign_id: string;
target_type: "user" | "group";
target_id: string;
permission: "read" | "write";
revoked_at?: string | null;
};
export type CampaignShareTarget = { id: string; name: string; secondary?: string | null };
export type CampaignShareTargets = { users: CampaignShareTarget[]; groups: CampaignShareTarget[] };
export type CampaignUpdatePayload = {
external_id?: string | null;
name?: string | null;
status?: string | null;
description?: string | null;
};
export type CampaignCreateMinimalPayload = {
external_id?: string;
name?: string;
description?: string | null;
current_flow?: string;
current_step?: string;
};
export type CampaignCreateResponse = {
campaign: CampaignListItem & {
current_version_id?: string | null;
};
version: CampaignVersionListItem;
};
export type CampaignVersionListItem = {
id: string;
campaign_id: string;
version_number: number;
schema_version?: string;
source_filename?: string | null;
source_base_path?: string | null;
workflow_state?: string;
current_flow?: string;
current_step?: string | null;
is_complete?: boolean;
editor_state?: Record<string, unknown>;
autosaved_at?: string | null;
published_at?: string | null;
locked_at?: string | null;
locked_by_user_id?: string | null;
user_lock_state?: "temporary" | "permanent" | null;
user_locked_at?: string | null;
user_locked_by_user_id?: string | null;
created_at?: string;
updated_at?: string;
validation_summary?: Record<string, unknown> | null;
build_summary?: Record<string, unknown> | null;
execution_snapshot_hash?: string | null;
execution_snapshot_at?: string | null;
};
export type CampaignVersionDetail = CampaignVersionListItem & {
raw_json: Record<string, unknown>;
campaign_json?: Record<string, unknown>;
};
export type CampaignVersionUpdatePayload = {
campaign_json?: Record<string, unknown> | null;
current_flow?: string | null;
current_step?: string | null;
workflow_state?: string | null;
is_complete?: boolean | null;
editor_state?: Record<string, unknown> | null;
source_filename?: string | null;
source_base_path?: string | null;
};
export type CampaignPartialValidationPayload = {
campaign_json?: Record<string, unknown> | null;
section?: string | null;
};
export type CampaignPartialValidationResponse = {
ok: boolean;
section?: string | null;
error_count: number;
warning_count: number;
info_count: number;
issues: Record<string, unknown>[];
};
export type CampaignSummary = {
generated_at?: string;
selected_version_id?: string | null;
campaign?: CampaignListItem;
current_version?: {
id: string;
version_number?: number;
schema_version?: string;
source_filename?: string | null;
created_at?: string | null;
validation_summary?: Record<string, unknown> | null;
build_summary?: Record<string, unknown> | null;
} | null;
cards?: {
jobs_total?: number;
inactive?: number;
queueable?: number;
needs_attention?: number;
sent?: number;
smtp_accepted?: number;
failed?: number;
outcome_unknown?: number;
not_attempted?: number;
queued_or_active?: number;
cancelled?: number;
partially_completed?: boolean;
imap_appended?: number;
imap_failed?: number;
};
status_counts?: Record<string, Record<string, number>>;
issues?: Record<string, unknown>;
attachments?: Record<string, unknown>;
attempts?: Record<string, unknown>;
delivery?: Record<string, unknown>;
recent_failures?: Record<string, unknown>[];
};
export type CampaignQueuePayload = {
version_id?: string | null;
include_warnings?: boolean;
enqueue_celery?: boolean;
dry_run?: boolean;
};
export type CampaignSendNowPayload = {
version_id?: string | null;
include_warnings?: boolean;
check_files?: boolean;
validate_before_send?: boolean;
build_before_send?: boolean;
dry_run?: boolean;
use_rate_limit?: boolean;
enqueue_imap_task?: boolean;
};
export type CampaignAttachmentPreviewFile = {
id: string;
version_id?: string;
blob_id?: string;
display_path: string;
filename: string;
owner_type: string;
owner_id: string;
checksum_sha256?: string;
size_bytes?: number;
content_type?: string | null;
};
export type CampaignAttachmentPreviewRule = {
source: "global" | "entry";
entry_index: number;
entry_id?: string | null;
index: number;
attachment_id?: string | null;
label?: string | null;
required: boolean;
pattern: string;
base_path_name?: string | null;
base_path?: string | null;
status: "ok" | "missing" | "ambiguous";
behavior?: string | null;
zip_included?: boolean;
zip_mode?: "inherit" | "include" | "exclude";
zip_archive_id?: string | null;
zip_filename?: string | null;
matches: CampaignAttachmentPreviewFile[];
match_count: number;
issues: Record<string, unknown>[];
};
export type CampaignAttachmentPreviewResponse = {
campaign_id: string;
version_id: string;
shared_file_count: number;
rules: CampaignAttachmentPreviewRule[];
unused_shared_files: CampaignAttachmentPreviewFile[];
};
export type CampaignAttachmentPreviewPayload = {
include_unmatched?: boolean;
campaign_json?: Record<string, unknown>;
};
export type CampaignMockSendPayload = {
version_id?: string | null;
send?: boolean;
include_warnings?: boolean;
include_needs_review?: boolean;
append_sent?: boolean;
clear_mailbox?: boolean;
check_files?: boolean;
};
export type CampaignReviewStatePayload = {
inspection_complete: boolean;
reviewed_message_keys: string[];
};
export type CampaignJobsQuery = {
versionId?: string;
page?: number;
pageSize?: number;
sendStatus?: string[];
validationStatus?: string[];
imapStatus?: string[];
query?: string;
};
export type CampaignJobsResponse = {
jobs: Record<string, unknown>[];
page: number;
page_size: number;
total: number;
total_unfiltered: number;
pages: number;
counts: Record<string, Record<string, number>>;
filtered_counts: Record<string, Record<string, number>>;
review: {
inspection_complete?: boolean;
blocking_count?: number;
required_count?: number;
reviewed_required_count?: number;
bulk_acceptable_count?: number;
};
};
export type CampaignJobDetailResponse = {
job: Record<string, unknown>;
attempts: {
smtp?: Record<string, unknown>[];
imap?: Record<string, unknown>[];
};
};
export type CampaignReportEmailPayload = {
to: string[];
version_id?: string;
include_jobs?: boolean;
attach_jobs_csv?: boolean;
attach_report_json?: boolean;
dry_run?: boolean;
};
export async function listCampaigns(settings: ApiSettings): Promise<CampaignListItem[]> {
const response = await apiFetch<CampaignListResponse>(settings, "/api/v1/campaigns");
if (Array.isArray(response)) {
return response;
}
return response.campaigns ?? response.items ?? response.results ?? [];
}
export async function getCampaign(settings: ApiSettings, campaignId: string): Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
}
export async function updateCampaignMetadata(
settings: ApiSettings,
campaignId: string,
payload: CampaignUpdatePayload
): Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`, {
method: "PUT",
body: JSON.stringify(payload)
});
}
export async function createNewCampaign(
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",
description: overrides.description ?? "",
current_flow: overrides.current_flow ?? "create",
current_step: overrides.current_step ?? "basics"
};
return apiFetch<CampaignCreateResponse>(settings, "/api/v1/campaigns/new", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function getCampaignSchema(settings: ApiSettings): Promise<unknown> {
return apiFetch(settings, "/api/v1/schemas/campaign");
}
export async function listCampaignVersions(
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> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`);
}
export async function unlockCampaignVersionValidation(
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> {
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> {
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> {
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> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, {
method: "PUT",
body: JSON.stringify(payload)
});
}
export async function forkCampaignVersion(
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)
});
}
export async function autosaveCampaignVersion(
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)
});
}
export async function setCampaignVersionStep(
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 })
});
}
export async function validatePartial(
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)
});
}
export async function publishCampaignVersion(
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>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, {
method: "POST",
body: JSON.stringify({ check_files: checkFiles })
});
}
export async function buildVersion(
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 })
});
}
export function previewCampaignAttachments(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignAttachmentPreviewPayload = {}
): Promise<CampaignAttachmentPreviewResponse> {
return apiFetch<CampaignAttachmentPreviewResponse>(
settings,
`/api/v1/campaigns/${campaignId}/versions/${versionId}/attachments/preview`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export async function getCampaignSummary(
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> {
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));
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());
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return apiFetch<CampaignJobsResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`);
}
export async function getCampaignJobDetail(
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> {
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> {
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
await apiDownload(
settings,
`/api/v1/campaigns/${campaignId}/report/jobs.csv${suffix}`,
`campaign-${campaignId}${versionId ? `-${versionId}` : ""}-jobs.csv`
);
}
export async function emailCampaignReport(
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)
});
}
export async function retryCampaignJobs(
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)
});
}
export async function sendUnattemptedCampaignJobs(
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)
});
}
export async function resolveCampaignJobOutcome(
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 })
});
}
export async function updateCampaignReviewState(
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)
});
}
export async function queueCampaign(
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)
});
}
export async function sendCampaignNow(
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)
});
}
export async function mockSendCampaign(
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)
});
}
export async function pauseCampaign(settings: ApiSettings, campaignId: string): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/pause`, { method: "POST" });
}
export async function resumeCampaign(settings: ApiSettings, campaignId: string): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/resume`, { method: "POST" });
}
export async function cancelCampaign(settings: ApiSettings, campaignId: string): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/cancel`, { method: "POST" });
}
export async function appendSent(
settings: ApiSettings,
campaignId: string,
dryRun = false
): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/append-sent`, {
method: "POST",
body: JSON.stringify({ dry_run: dryRun })
});
}
export async function getCampaignShareTargets(settings: ApiSettings, campaignId: string): Promise<CampaignShareTargets> {
return apiFetch<CampaignShareTargets>(settings, `/api/v1/campaigns/${campaignId}/share-targets`);
}
export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> {
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> {
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> {
return apiFetch<CampaignShare>(settings, `/api/v1/campaigns/${campaignId}/shares`, { method: "POST", body: JSON.stringify(payload) });
}
export async function revokeCampaignShare(settings: ApiSettings, campaignId: string, shareId: string): Promise<void> {
await apiFetch<void>(settings, `/api/v1/campaigns/${campaignId}/shares/${shareId}`, { method: "DELETE" });
}

1
webui/src/api/client.ts Normal file
View File

@@ -0,0 +1 @@
export { apiFetch, apiUrl, authHeaders, csrfToken, apiDownload } from "@govoplan/core-webui";

1
webui/src/api/files.ts Normal file
View File

@@ -0,0 +1 @@
export * from "@govoplan/files-webui";

1
webui/src/api/mail.ts Normal file
View File

@@ -0,0 +1 @@
export * from "@govoplan/mail-webui";

View File

@@ -0,0 +1,2 @@
import { Button } from "@govoplan/core-webui";
export default Button;

View File

@@ -0,0 +1,2 @@
import { Card } from "@govoplan/core-webui";
export default Card;

View File

@@ -0,0 +1,2 @@
import { ConfirmDialog } from "@govoplan/core-webui";
export default ConfirmDialog;

View File

@@ -0,0 +1,2 @@
import { Dialog } from "@govoplan/core-webui";
export default Dialog;

View File

@@ -0,0 +1,2 @@
import { DismissibleAlert } from "@govoplan/core-webui";
export default DismissibleAlert;

View File

@@ -0,0 +1,2 @@
import { FormField } from "@govoplan/core-webui";
export default FormField;

View File

@@ -0,0 +1,2 @@
import { LoadingFrame } from "@govoplan/core-webui";
export default LoadingFrame;

View File

@@ -0,0 +1,2 @@
import { LoadingIndicator } from "@govoplan/core-webui";
export default LoadingIndicator;

View File

@@ -0,0 +1,2 @@
import { MetricCard } from "@govoplan/core-webui";
export default MetricCard;

View File

@@ -0,0 +1,2 @@
import { PageTitle } from "@govoplan/core-webui";
export default PageTitle;

View File

@@ -0,0 +1,2 @@
import { StatusBadge } from "@govoplan/core-webui";
export default StatusBadge;

View File

@@ -0,0 +1,2 @@
import { Stepper } from "@govoplan/core-webui";
export default Stepper;

View File

@@ -0,0 +1,2 @@
import { ToggleSwitch } from "@govoplan/core-webui";
export default ToggleSwitch;

View File

@@ -0,0 +1,2 @@
import { EmailAddressInput } from "@govoplan/core-webui";
export default EmailAddressInput;

View File

@@ -0,0 +1,2 @@
import { FieldLabel } from "@govoplan/core-webui";
export default FieldLabel;

View File

@@ -0,0 +1,2 @@
import { InlineHelp } from "@govoplan/core-webui";
export default InlineHelp;

View File

@@ -0,0 +1,4 @@
import { DataGrid, DataGridEmptyAction, DataGridRowActions } from "@govoplan/core-webui";
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridQueryState, DataGridSortDirection } from "@govoplan/core-webui";
export { DataGridEmptyAction, DataGridRowActions };
export default DataGrid;

View File

@@ -0,0 +1,97 @@
import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
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" }
];
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" }
];
const tenantContacts = [
{ name: "Helpdesk", email: "helpdesk@example.local", source: "Tenant", tags: "Directory" },
{ name: "Data Protection", email: "privacy@example.local", source: "Tenant", tags: "Directory" }
];
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" }
];
}
export default function AddressBookPage() {
const allContacts = [...personalContacts, ...groupContacts, ...tenantContacts];
return (
<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>
</div>
<div className="button-row compact-actions">
<Button disabled>Import</Button>
<Button variant="primary" disabled>Add contact</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>
</div>
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Address book scopes">
<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" />
</div>
</Card>
<Card title="Planned address actions">
<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>
</div>
</Card>
</div>
<Card title="Contacts" actions={<Button disabled>Manage sources</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"
/>
</Card>
</div>
);
}
function AddressBookScope({ title, description, status }: { title: string; description: string; status: string }) {
return (
<div className="address-book-scope-card">
<div>
<strong>{title}</strong>
<p>{description}</p>
</div>
<StatusBadge status={status} />
</div>
);
}

View File

@@ -0,0 +1,623 @@
import { useEffect, useMemo, useState } from "react";
import { Pencil } from "lucide-react";
import type { ApiSettings } from "../../types";
import { listFileSpaces, type FileSpace } from "../../api/files";
import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import ToggleSwitch from "../../components/ToggleSwitch";
import DismissibleAlert from "../../components/DismissibleAlert";
import ConfirmDialog from "../../components/ConfirmDialog";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { updateNested } from "./utils/draftEditor";
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
import ManagedFileChooser from "./components/ManagedFileChooser";
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 "../../utils/arrayOrder";
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
import { recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
type PathChooserState = { index: number };
type IndividualDisableState = { index: number; usageCount: number };
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
const [fileSpaces, setFileSpaces] = useState<FileSpace[]>([]);
const [individualDisable, setIndividualDisable] = useState<IndividualDisableState | null>(null);
const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "files",
unsavedTitle: "Unsaved attachment settings",
unsavedMessage: "Attachment settings have unsaved changes. Save them before leaving, or discard them and continue."
});
const attachments = asRecord(displayDraft.attachments);
const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]);
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachments.zip), [attachments.zip]);
const filenameFieldOptions = useMemo(() => buildZipFilenameFieldOptions(displayDraft), [displayDraft]);
const passwordFields = useMemo(() => getDraftFields(displayDraft).filter((field) => field.type === "password"), [displayDraft]);
const zipArchiveNameValidation = useMemo(
() => zipConfig.enabled ? validateZipArchiveNames(zipConfig.archives) : EMPTY_ZIP_ARCHIVE_NAME_VALIDATION,
[zipConfig.archives, zipConfig.enabled]
);
const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message;
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
useEffect(() => {
let cancelled = false;
void listFileSpaces(settings)
.then((response) => { if (!cancelled) setFileSpaces(response.spaces); })
.catch(() => { if (!cancelled) setFileSpaces([]); });
return () => { cancelled = true; };
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
function patchBasePaths(paths: AttachmentBasePath[]) {
if (locked) return;
const normalized = ensureAttachmentBasePaths(paths);
setDraft((current) => {
const withPaths = updateNested(current ?? {}, ["attachments", "base_paths"], normalized);
return updateNested(withPaths, ["attachments", "base_path"], normalized[0]?.path || ".");
});
markDirty();
}
function patchBasePath(index: number, patch: Partial<AttachmentBasePath>) {
patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath));
}
function addBasePath(afterIndex = basePaths.length - 1) {
patchBasePaths(insertAfter(basePaths, afterIndex, createAttachmentBasePath("New attachment source", ".")));
}
function removeBasePath(index: number) {
patchBasePaths(basePaths.filter((_, currentIndex) => currentIndex !== index));
}
function moveBasePath(index: number, targetIndex: number) {
if (locked || index === targetIndex) return;
patchBasePaths(moveArrayItem(basePaths, index, targetIndex));
}
function setIndividualEligibility(index: number, checked: boolean) {
if (locked) return;
if (checked) {
patchBasePath(index, { allow_individual: true });
return;
}
const basePath = basePaths[index];
if (!basePath) return;
const usageCount = countIndividualAttachmentRulesForBasePath(displayDraft.entries, basePath);
if (usageCount > 0) {
setIndividualDisable({ index, usageCount });
return;
}
patchBasePath(index, { allow_individual: false });
}
function confirmIndividualDisable() {
if (!individualDisable) return;
const basePath = basePaths[individualDisable.index];
if (!basePath) {
setIndividualDisable(null);
return;
}
const nextPaths = basePaths.map((item, index) => index === individualDisable.index ? { ...item, allow_individual: false } : item);
setDraft((current) => {
const source = current ?? {};
const withPaths = updateNested(source, ["attachments", "base_paths"], nextPaths);
const withPrimaryPath = updateNested(withPaths, ["attachments", "base_path"], nextPaths[0]?.path || ".");
return updateNested(withPrimaryPath, ["entries"], removeIndividualAttachmentRulesForBasePath(asRecord(source).entries, basePath));
});
markDirty();
setIndividualDisable(null);
}
function patchZipCollection(next: AttachmentZipCollection) {
if (locked) return;
patch(["attachments", "zip"], next);
}
function setZipEnabled(enabled: boolean) {
const archives = enabled && zipConfig.archives.length === 0
? [createAttachmentZipArchive("{{local:id}}-attachments.zip", true)]
: zipConfig.archives;
patchZipCollection({ enabled, archives });
}
function patchZipArchive(index: number, archivePatch: Partial<AttachmentZipArchive>) {
const archives = zipConfig.archives.map((archive, currentIndex) => {
if (currentIndex !== index) return archive;
const next = { ...archive, ...archivePatch };
if (archivePatch.password_field !== undefined || archivePatch.password_scope !== undefined) {
next.password_mode = "field";
delete next.password;
delete next.password_template;
}
return next;
});
patchZipCollection({ ...zipConfig, archives });
}
function setStandardZipArchive(index: number) {
patchZipCollection({
...zipConfig,
archives: zipConfig.archives.map((archive, currentIndex) => ({ ...archive, standard: currentIndex === index }))
});
}
function addZipArchive(afterIndex = zipConfig.archives.length - 1) {
const archive = createAttachmentZipArchive(`attachments-${zipConfig.archives.length + 1}.zip`, zipConfig.archives.length === 0);
patchZipCollection({ ...zipConfig, archives: insertAfter(zipConfig.archives, afterIndex, archive) });
}
function removeZipArchive(index: number) {
const removed = zipConfig.archives[index];
if (!removed) return;
const removedWasStandard = removed.standard;
const archives = zipConfig.archives.filter((_, currentIndex) => currentIndex !== index);
if (removedWasStandard && archives.length > 0) archives[0] = { ...archives[0], standard: true };
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;
};
setDraft((current) => {
const source = asRecord(current ?? {});
const currentAttachments = asRecord(source.attachments);
const currentEntries = asRecord(source.entries);
return {
...source,
attachments: {
...currentAttachments,
zip: { ...zipConfig, archives },
global: asArray(currentAttachments.global).map(resetRule)
},
entries: {
...currentEntries,
inline: asArray(currentEntries.inline).map((value) => {
const entry = asRecord(value);
return { ...entry, attachments: asArray(entry.attachments).map(resetRule) };
})
}
};
});
markDirty();
}
function moveZipArchive(index: number, targetIndex: number) {
if (locked || index === targetIndex) return;
patchZipCollection({ ...zipConfig, archives: moveArrayItem(zipConfig.archives, index, targetIndex) });
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Attachments</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<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>
</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." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<Card title="Attachment sources">
<DataGrid
id={`campaign-${campaignId}-attachment-sources`}
rows={basePaths}
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, 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"
/>
</Card>
<Card title="ZIP attachments" collapsible>
<div className="attachment-zip-master-toggle">
<ToggleSwitch
label="Enable ZIP attachments"
checked={zipConfig.enabled}
disabled={locked}
onChange={setZipEnabled}
/>
</div>
<DataGrid
id={`campaign-${campaignId}-zip-archives`}
rows={zipConfig.archives}
columns={zipArchiveColumns({
disabled: locked || !zipConfig.enabled,
archives: zipConfig.archives,
invalidNameIndexes: zipArchiveNameValidation.invalidIndexes,
onEditName: setZipNameEditorIndex,
passwordFields,
patchArchive: patchZipArchive,
setStandard: setStandardZipArchive,
addArchive: addZipArchive,
moveArchive: moveZipArchive,
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"
/>
{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>
</Card>
<Card title="Global Attachments">
<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."
basePaths={basePaths}
settings={settings}
campaignId={campaignId}
zipConfig={zipConfig}
onChange={(rules) => patch(["attachments", "global"], rules)}
/>
</Card>
<Card title="Statistics">
<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>Connected through Files</dd></div>
</dl>
<p className="muted small-note">Files are now managed in the top-level Files module. Upload there, share files with campaigns, then use these rules to resolve concrete attachments during review/build.</p>
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
</div>
</>
</LoadingFrame>
<TemplateExpressionEditorDialog
open={zipNameEditorIndex !== null && Boolean(zipConfig.archives[zipNameEditorIndex])}
title="Edit ZIP archive filename"
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"
validate={(value) => zipNameEditorIndex === null ? "" : describeZipArchiveNameValueProblem(zipConfig.archives, zipNameEditorIndex, value)}
onCancel={() => setZipNameEditorIndex(null)}
onSave={(name) => {
if (zipNameEditorIndex === null) return;
patchZipArchive(zipNameEditorIndex, { name });
setZipNameEditorIndex(null);
}}
onAddField={(name) => {
if (!draft || locked || !name) return;
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 }
]);
}}
canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)}
/>
{pathChooser && (
<ManagedFileChooser
open
settings={settings}
campaignId={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);
}}
/>
)}
<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"
tone="danger"
onConfirm={confirmIndividualDisable}
onCancel={() => setIndividualDisable(null)}
/>
</div>
);
}
type ZipArchiveColumnContext = {
disabled: boolean;
archives: AttachmentZipArchive[];
invalidNameIndexes: ReadonlySet<number>;
onEditName: (index: number) => void;
passwordFields: ReturnType<typeof getDraftFields>;
patchArchive: (index: number, patch: Partial<AttachmentZipArchive>) => void;
setStandard: (index: number) => void;
addArchive: (afterIndex?: number) => void;
moveArchive: (index: number, targetIndex: number) => void;
removeArchive: (index: number) => void;
};
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>
<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>
{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"
/>
}
];
}
type ZipFilenameNamespace = "local" | "global";
type ZipFilenameFieldOption = {
namespace: ZipFilenameNamespace;
name: string;
label: string;
};
type ZipArchiveNameValidation = {
invalidIndexes: ReadonlySet<number>;
message: string;
};
const EMPTY_ZIP_ARCHIVE_NAME_VALIDATION: ZipArchiveNameValidation = {
invalidIndexes: new Set<number>(),
message: ""
};
function buildZipFilenameFieldOptions(draft: Record<string, unknown>): ZipFilenameFieldOption[] {
const fieldDefinitions = new Map(getDraftFields(draft).map((field) => [field.name, field]));
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");
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) }))
];
}
function describeZipArchiveNameValueProblem(archives: AttachmentZipArchive[], index: number, value: string): string {
const normalized = normalizeZipArchiveName(value);
if (!normalized) return "Archive filename must not be empty.";
const duplicate = archives.some((archive, currentIndex) => currentIndex !== index && normalizeZipArchiveName(archive.name) === normalized);
return duplicate ? "Another ZIP attachment already uses this effective filename." : "";
}
function validateZipArchiveNames(archives: AttachmentZipArchive[]): ZipArchiveNameValidation {
const invalidIndexes = new Set<number>();
const byName = new Map<string, number[]>();
archives.forEach((archive, index) => {
const normalized = normalizeZipArchiveName(archive.name);
if (!normalized) {
invalidIndexes.add(index);
return;
}
const indexes = byName.get(normalized) ?? [];
indexes.push(index);
byName.set(normalized, indexes);
});
for (const indexes of byName.values()) {
if (indexes.length < 2) continue;
indexes.forEach((index) => invalidIndexes.add(index));
}
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 parts: string[] = [];
if (hasEmpty) parts.push("Archive filenames must not be empty");
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.` };
}
function normalizeZipArchiveName(value: string): string {
const trimmed = value.trim().toLocaleLowerCase();
if (!trimmed) return "";
return trimmed.endsWith(".zip") ? trimmed : `${trimmed}.zip`;
}
function uniqueStrings(values: string[]): string[] {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
}
type AttachmentSourceColumnContext = {
locked: boolean;
basePaths: AttachmentBasePath[];
fileSpaces: FileSpace[];
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
setIndividualEligibility: (index: number, checked: boolean) => void;
addBasePath: (afterIndex?: number) => void;
moveBasePath: (index: number, targetIndex: number) => void;
removeBasePath: (index: number) => void;
setPathChooser: (state: PathChooserState | null) => void;
};
function attachmentSourceColumns({ locked, basePaths, fileSpaces, 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">
<input
className="chooser-display-input"
value={formatAttachmentSourcePath(basePath, fileSpaces)}
disabled={locked}
readOnly
tabIndex={-1}
placeholder="attachments"
onClick={() => !locked && setPathChooser({ index })}
onKeyDown={(event) => {
if (!locked && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
setPathChooser({ index });
}
}}
/>
<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"
/>
)
}
];
}
function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FileSpace[]): 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 relativePath = basePath.path.trim().replace(/^\.\/?$/, "").replace(/^\/+|\/+$/g, "");
if (rootLabel) return `${rootLabel}/${relativePath ? `${relativePath}/` : ""}`;
return `${relativePath || "."}/`;
}

View File

@@ -0,0 +1,35 @@
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import DismissibleAlert from "../../components/DismissibleAlert";
import PageTitle from "../../components/PageTitle";
import VersionLine from "./components/VersionLine";
import LoadingFrame from "../../components/LoadingFrame";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
export default function CampaignAuditPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion;
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Audit log</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>
</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>
</Card>
</LoadingFrame>
</div>
);
}

View File

@@ -0,0 +1,308 @@
import { useMemo, useRef } from "react";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import ToggleSwitch from "../../components/ToggleSwitch";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion, isRecord } from "./utils/campaignView";
import { getBool, getText, updateNested } from "./utils/draftEditor";
import FieldValueInput from "./components/FieldValueInput";
import DismissibleAlert from "../../components/DismissibleAlert";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const fieldValueKeys = useRef<string[]>([]);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, setDraft, displayDraft, dirty, saveState, setSaveState, localError, setLocalError, markDirty, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "campaign-fields",
unsavedTitle: "Unsaved fields",
unsavedMessage: "Campaign fields have unsaved changes. Save them before leaving, or discard them and continue.",
transformLoadedDraft: (loadedVersion, loadedDraft) => migrateFieldOverridePolicy(loadedDraft, asRecord(loadedVersion.editor_state)),
onLoaded: (_loadedVersion, loadedDraft) => {
fieldValueKeys.current = normalizeFields(loadedDraft.fields).map((field) => field.name);
}
});
const fields = useMemo(() => normalizeFields(displayDraft.fields), [displayDraft.fields]);
const globalValues = asRecord(displayDraft.global_values);
const fieldNameWarning = useMemo(() => describeFieldNameProblem(fields), [fields]);
const canSave = dirty && !locked && Boolean(draft) && !fieldNameWarning;
function patchDraft(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
markDirty();
}
function patchFields(nextFields: CampaignFieldDefinition[]) {
patchDraft(["fields"], nextFields.map((field) => ({
name: field.name,
type: field.type || "string",
label: field.label,
required: field.required,
can_override: field.can_override
})));
}
function patchGlobalValues(nextValues: Record<string, unknown>) {
patchDraft(["global_values"], nextValues);
}
function setField(index: number, patchValue: Partial<CampaignFieldDefinition>) {
const nextFields = fields.map((field, currentIndex) => currentIndex === index ? { ...field, ...patchValue } : field);
patchFields(nextFields);
}
function renameField(index: number, nextName: string) {
const oldName = fields[index]?.name ?? "";
const cleanedName = nextName.trim();
const duplicate = Boolean(cleanedName) && fields.some((field, currentIndex) => currentIndex !== index && field.name === cleanedName);
const valueKey = fieldValueKeys.current[index] ?? oldName;
const nextFields = fields.map((field, currentIndex) => currentIndex === index ? { ...field, name: cleanedName } : field);
const nextGlobalValues = { ...globalValues };
if (!duplicate && cleanedName) {
if (valueKey && valueKey !== cleanedName && Object.prototype.hasOwnProperty.call(nextGlobalValues, valueKey)) {
nextGlobalValues[cleanedName] = nextGlobalValues[valueKey];
delete nextGlobalValues[valueKey];
}
fieldValueKeys.current[index] = cleanedName;
}
setDraft((current) => {
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
const nextWithGlobalValues = updateNested(nextDraft, ["global_values"], nextGlobalValues);
if (duplicate || !cleanedName || !valueKey || valueKey === cleanedName) {
return nextWithGlobalValues;
}
return migrateEntryFieldValues(nextWithGlobalValues, valueKey, cleanedName);
});
markDirty();
}
function addField(afterIndex = fields.length - 1) {
const name = uniqueFieldName(fields);
const nextField = { name, label: humanizeFieldName(name), type: "string", required: false, can_override: true };
const nextFields = insertAfter(fields, afterIndex, nextField);
fieldValueKeys.current = insertAfter(fieldValueKeys.current, afterIndex, name);
const nextGlobalValues = { ...globalValues, [name]: "" };
setDraft((current) => {
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
return updateNested(nextDraft, ["global_values"], nextGlobalValues);
});
markDirty();
}
function moveField(index: number, targetIndex: number) {
if (locked || index === targetIndex) return;
fieldValueKeys.current = moveArrayItem(fieldValueKeys.current, index, targetIndex);
patchFields(moveArrayItem(fields, index, targetIndex));
}
function deleteField(index: number) {
const field = fields[index];
const nextFields = fields.filter((_, currentIndex) => currentIndex !== index);
fieldValueKeys.current = fieldValueKeys.current.filter((_, currentIndex) => currentIndex !== index);
const nextGlobalValues = { ...globalValues };
if (field?.name) {
delete nextGlobalValues[field.name];
}
setDraft((current) => {
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
return updateNested(nextDraft, ["global_values"], nextGlobalValues);
});
markDirty();
}
function setGlobalValue(key: string, value: unknown) {
patchGlobalValues({ ...globalValues, [key]: value });
}
function setOverrideAllowed(index: number, allowed: boolean) {
setField(index, { can_override: allowed });
}
async function saveFields(): Promise<boolean> {
const fieldProblem = describeFieldNameProblem(fields);
if (fieldProblem) {
setLocalError(fieldProblem);
setSaveState("Save blocked");
return false;
}
return saveDraft("manual");
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Fields</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>
</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." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<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"
/>
</div>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={saveFields} disabled={!canSave}>Save</Button>
</div>
</>
</LoadingFrame>
</div>
);
}
type FieldColumnContext = {
locked: boolean;
fields: CampaignFieldDefinition[];
globalValues: Record<string, unknown>;
renameField: (index: number, nextName: string) => void;
setField: (index: number, patchValue: Partial<CampaignFieldDefinition>) => void;
setGlobalValue: (key: string, value: unknown) => void;
setOverrideAllowed: (index: number, allowed: boolean) => void;
addField: (afterIndex?: number) => void;
moveField: (index: number, targetIndex: number) => void;
deleteField: (index: number) => void;
};
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"
/>
)
}
];
}
function normalizeFields(value: unknown): CampaignFieldDefinition[] {
if (!Array.isArray(value)) return [];
return value.filter(isRecord).map((field) => ({
name: getText(field, "name"),
label: getText(field, "label"),
type: normalizeFieldType(getText(field, "type")),
required: getBool(field, "required"),
can_override: getBool(field, "can_override", true)
}));
}
function migrateEntryFieldValues(draft: Record<string, unknown>, oldName: string, newName: string): Record<string, unknown> {
const entries = asRecord(draft.entries);
const inlineEntries = Array.isArray(entries.inline) ? entries.inline : [];
if (inlineEntries.length === 0) return draft;
const nextInlineEntries = inlineEntries.map((entry) => {
if (!isRecord(entry)) return entry;
const fields = asRecord(entry.fields);
if (!Object.prototype.hasOwnProperty.call(fields, oldName)) return entry;
const nextFields = { ...fields };
if (!Object.prototype.hasOwnProperty.call(nextFields, newName)) {
nextFields[newName] = nextFields[oldName];
}
delete nextFields[oldName];
return { ...entry, fields: nextFields };
});
return updateNested(draft, ["entries", "inline"], nextInlineEntries);
}
function migrateFieldOverridePolicy(draft: Record<string, unknown>, editorState: Record<string, unknown>): Record<string, unknown> {
const overridePolicy = asRecord(editorState.field_overrides);
if (Object.keys(overridePolicy).length === 0) return draft;
const fields = normalizeFields(draft.fields).map((field) => {
if (!field.name || !Object.prototype.hasOwnProperty.call(overridePolicy, field.name)) return field;
return { ...field, can_override: getBool(overridePolicy, field.name, true) };
});
return updateNested(draft, ["fields"], fields);
}
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.";
}
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const name of names) {
if (seen.has(name)) duplicates.add(name);
seen.add(name);
}
if (duplicates.size === 0) return "";
return `Duplicate field ID${duplicates.size === 1 ? "" : "s"}: ${[...duplicates].sort().join(", ")}. Field IDs must be unique before saving.`;
}
function uniqueFieldName(fields: CampaignFieldDefinition[]): string {
const existing = new Set(fields.map((field) => field.name));
let counter = fields.length + 1;
let name = `field_${counter}`;
while (existing.has(name)) {
counter += 1;
name = `field_${counter}`;
}
return name;
}

View File

@@ -0,0 +1,39 @@
import type { ApiSettings } from "../../types";
import Card from "../../components/Card";
import Button from "../../components/Button";
import DismissibleAlert from "../../components/DismissibleAlert";
import PageTitle from "../../components/PageTitle";
import VersionLine from "./components/VersionLine";
import LoadingFrame from "../../components/LoadingFrame";
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 }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion;
const campaignJson = getCampaignJson(version);
const campaign = asRecord(campaignJson.campaign);
const filename = `${safeFileStem(String(campaign.id || data.campaign?.external_id || campaignId))}.json`;
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>JSON</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>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading JSON…">
<Card>
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
</Card>
</LoadingFrame>
</div>
);
}

View File

@@ -0,0 +1,191 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Card from "../../components/Card";
import Button from "../../components/Button";
import StatusBadge from "../../components/StatusBadge";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import DismissibleAlert from "../../components/DismissibleAlert";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import { createNewCampaign, listCampaigns } from "../../api/campaigns";
import type { CampaignListItem } from "../../types";
export default function CampaignListPage({ settings }: { settings: ApiSettings }) {
const navigate = useNavigate();
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>("");
async function load() {
setLoading(true);
setError("");
try {
const data = await listCampaigns(settings);
setCampaigns(data);
setLastLoadedAt(formatLoadedAt(new Date()));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
async function create() {
setCreating(true);
setError("");
try {
const created = await createNewCampaign(settings);
navigate(`/campaigns/${created.campaign.id}/wizard/create`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setCreating(false);
}
}
useEffect(() => {
load();
}, [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>
<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}`
},
{
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: 110,
sticky: "end",
render: (campaign) => <Link to={`/campaigns/${campaign.id}`}><Button variant="primary">Open</Button></Link>
}
];
return (
<div className="content-pad 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>
</div>
<div className="button-row compact-actions">
<Button onClick={load} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={create} disabled={creating}>
{creating ? "Creating…" : "New campaign"}
</Button>
</div>
</div>
<Card>
<LoadingFrame loading={loading} label="Loading campaigns…">
{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>
<Button variant="primary" onClick={create} disabled={creating}>
Create first campaign
</Button>
</div>
) : (
<DataGrid
id="campaigns"
rows={campaigns}
columns={columns}
getRowKey={(campaign) => campaign.id}
initialSort={{ columnId: "updated", direction: "desc" }}
className="campaign-table-wrap"
emptyText="No campaigns found."
/>
)}
</LoadingFrame>
</Card>
</div>
);
}
function shortId(value: string): string {
if (value.length <= 20) return value;
return `${value.slice(0, 12)}${value.slice(-6)}`;
}
function formatDateTime(value?: string): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
function formatLoadedAt(value: Date): string {
return value.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
}

View File

@@ -0,0 +1,281 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import ConfirmDialog from "../../components/ConfirmDialog";
import FormField from "../../components/FormField";
import LoadingFrame from "../../components/LoadingFrame";
import MetricCard from "../../components/MetricCard";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
import DismissibleAlert from "../../components/DismissibleAlert";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import {
lockCampaignVersionPermanently,
lockCampaignVersionTemporarily,
unlockCampaignVersionUserLock,
updateCampaignMetadata,
type CampaignVersionListItem,
} from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import {
canUnlockValidationVersion,
formatDateTime,
isFinalLockedVersion,
isPermanentUserLockedVersion,
isTemporaryUserLockedVersion,
isVersionReadyForDelivery,
summaryValue,
} from "./utils/campaignView";
const campaignModeOptions = ["draft", "test", "send"];
type LockAction = "temporary" | "unlock" | "permanent";
type PendingLockAction = { version: CampaignVersionListItem; action: LockAction } | null;
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]);
const [identity, setIdentity] = useState({ external_id: "", name: "", status: "", description: "" });
const [identityDirty, setIdentityDirty] = useState(false);
const [savingIdentity, setSavingIdentity] = useState(false);
const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
const [lockBusy, setLockBusy] = useState(false);
const [message, setMessage] = useState("");
useEffect(() => {
if (!campaign || identityDirty) return;
setIdentity({
external_id: campaign.external_id ?? "",
name: campaign.name ?? "",
status: campaign.status ?? "",
description: campaign.description ?? "",
});
}, [campaign, identityDirty]);
function patchIdentity(key: keyof typeof identity, value: string) {
setIdentity((current) => ({ ...current, [key]: value }));
setIdentityDirty(true);
setMessage("");
}
async function saveIdentity() {
if (!campaign || savingIdentity || !identityDirty) return;
setSavingIdentity(true);
setError("");
setMessage("");
try {
await updateCampaignMetadata(settings, campaign.id, {
external_id: identity.external_id,
name: identity.name,
status: identity.status,
description: identity.description,
});
setIdentityDirty(false);
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSavingIdentity(false);
}
}
async function applyLockAction() {
const pending = pendingLockAction;
if (!pending || lockBusy) return;
setLockBusy(true);
setError("");
setMessage("");
try {
if (pending.action === "temporary") {
await lockCampaignVersionTemporarily(settings, campaignId, pending.version.id);
setMessage(`Version #${pending.version.version_number} temporarily locked.`);
} else if (pending.action === "unlock") {
await unlockCampaignVersionUserLock(settings, campaignId, pending.version.id);
setMessage(`Temporary lock removed from version #${pending.version.version_number}.`);
} else {
await lockCampaignVersionPermanently(settings, campaignId, pending.version.id);
setMessage(`Version #${pending.version.version_number} permanently locked.`);
}
setPendingLockAction(null);
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLockBusy(false);
}
}
return (
<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>
</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>
</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…">
<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" />
</div>
<Card title="Campaign identity">
<div className="form-grid campaign-identity-grid">
<FormField label="Campaign ID">
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
</FormField>
<FormField label="Mode">
<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">
<input value={identity.name} onChange={(event) => patchIdentity("name", event.target.value)} />
</FormField>
<FormField label="Description">
<textarea rows={4} value={identity.description} onChange={(event) => patchIdentity("description", event.target.value)} />
</FormField>
</div>
</Card>
<Card title="Version history">
<DataGrid
id={`campaign-${campaignId}-versions`}
rows={versions}
columns={versionColumns(setPendingLockAction, campaign?.current_version_id)}
getRowKey={(version) => version.id}
initialSort={{ columnId: "version", direction: "desc" }}
emptyText="No versions found."
className="version-history-table"
rowClassName={(version) => version.id === data.currentVersion?.id ? "current-version-row" : undefined}
/>
</Card>
<Card title="Current version state">
<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 ?? "—"} />
</div>
</Card>
</LoadingFrame>
<ConfirmDialog
open={Boolean(pendingLockAction)}
title={lockDialogTitle(pendingLockAction)}
message={lockDialogMessage(pendingLockAction)}
confirmLabel={lockDialogLabel(pendingLockAction)}
tone={pendingLockAction?.action === "unlock" ? "default" : "danger"}
busy={lockBusy}
onCancel={() => setPendingLockAction(null)}
onConfirm={() => void applyLockAction()}
/>
</div>
);
}
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: 310,
sticky: "end",
render: (version) => {
const isCurrent = version.id === currentVersionId;
return (
<div className="button-row compact-actions">
<Link to={`send?version=${version.id}`}><Button variant={isCurrent ? "primary" : "secondary"}>Open</Button></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 onClick={() => setPendingLockAction({ version, action: "temporary" })}>Lock</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";
}
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";
}
function buildLabel(version: CampaignVersionListItem): string {
const build = version.build_summary ?? {};
return String(build.built_count ?? build.ready_count ?? "Not built");
}
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";
}
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.";
}
if (pending?.action === "unlock") {
return "This removes the temporary user lock and makes the version editable again. Existing validation/build state is otherwise retained.";
}
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 "Continue?";
}
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";
}
function SummaryTile({ label, value }: { label: string; value: string | number }) {
return (
<div className="summary-tile">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}

View File

@@ -0,0 +1,357 @@
import { useEffect, useMemo, useState } from "react";
import type { ApiSettings } from "../../types";
import {
downloadCampaignJobsCsv,
emailCampaignReport,
getCampaignJobDetail,
getCampaignJobs,
resolveCampaignJobOutcome,
retryCampaignJobs,
sendUnattemptedCampaignJobs,
type CampaignJobDetailResponse,
type CampaignJobsResponse,
} from "../../api/campaigns";
import Card from "../../components/Card";
import Button from "../../components/Button";
import ConfirmDialog from "../../components/ConfirmDialog";
import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid";
import Dialog from "../../components/Dialog";
import DismissibleAlert from "../../components/DismissibleAlert";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
import VersionLine from "./components/VersionLine";
import LoadingFrame from "../../components/LoadingFrame";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, humanize, stringifyPreview } from "./utils/campaignView";
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) }));
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 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 [jobsLoading, setJobsLoading] = useState(false);
const [page, setPage] = useState(1);
const [sendStatus, setSendStatus] = useState("");
const [query, setQuery] = useState("");
const [appliedQuery, setAppliedQuery] = useState("");
const [actionMessage, setActionMessage] = useState("");
const [actionError, setActionError] = useState("");
const [busyAction, setBusyAction] = useState("");
const [detail, setDetail] = useState<CampaignJobDetailResponse | null>(null);
const [emailOpen, setEmailOpen] = useState(false);
const [emailRecipients, setEmailRecipients] = useState("");
const [attachCsv, setAttachCsv] = useState(true);
const [attachJson, setAttachJson] = useState(false);
const [reconcile, setReconcile] = useState<ReconcileRequest>(null);
useEffect(() => {
const handle = window.setTimeout(() => {
setAppliedQuery(query.trim());
setPage(1);
}, 350);
return () => window.clearTimeout(handle);
}, [query]);
useEffect(() => {
void loadJobs();
}, [campaignId, version?.id, page, sendStatus, appliedQuery]);
async function loadJobs() {
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);
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setJobsLoading(false);
}
}
async function reloadAll() {
await Promise.all([reload(), loadJobs()]);
}
async function runExplicitAction(action: "retry" | "unattempted") {
if (!version || busyAction) return;
setBusyAction(action);
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 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();
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusyAction("");
}
}
async function reconcileOutcome() {
if (!reconcile || busyAction) return;
setBusyAction("reconcile");
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.");
setReconcile(null);
await reloadAll();
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusyAction("");
}
}
async function openJob(jobId: string) {
setBusyAction("detail");
setActionError("");
try {
setDetail(await getCampaignJobDetail(settings, campaignId, jobId));
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusyAction("");
}
}
async function exportCsv() {
setBusyAction("csv");
setActionError("");
try {
await downloadCampaignJobsCsv(settings, campaignId, version?.id);
setActionMessage("Campaign job CSV downloaded.");
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusyAction("");
}
}
async function sendReportEmail() {
const recipients = emailRecipients.split(/[;,\n]+/).map((value) => value.trim()).filter(Boolean);
if (recipients.length === 0 || busyAction) return;
setBusyAction("email");
setActionError("");
try {
await emailCampaignReport(settings, campaignId, {
to: recipients,
version_id: version?.id,
include_jobs: false,
attach_jobs_csv: attachCsv,
attach_report_json: attachJson,
});
setActionMessage(`Report sent to ${recipients.join(", ")}.`);
setEmailOpen(false);
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusyAction("");
}
}
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]);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Report</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>
</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…">
<div className="dashboard-grid">
<Card title="Delivery outcome">
<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>
</dl>
</Card>
<Card title="IMAP and execution plan">
<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>
</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>
<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>
</div>
</Card>
</div>
<Card title="Recipient delivery jobs">
<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>
{SEND_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>
</div>
<LoadingFrame loading={jobsLoading} label="Loading delivery jobs…">
<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."
/>
</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>
</div>
</Card>
</LoadingFrame>
<Dialog
open={emailOpen}
title="Email campaign report"
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>
</div>
)}
>
<label className="field-stack">
<span>Recipients</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>
</Dialog>
<Dialog
open={Boolean(detail)}
title="Campaign job detail"
onClose={() => setDetail(null)}
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>
</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>
</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"}
tone={reconcile?.decision === "smtp_accepted" ? "default" : "danger"}
busy={busyAction === "reconcile"}
onConfirm={() => void reconcileOutcome()}
onCancel={() => setReconcile(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import { useEffect, useState } from "react";
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
import type { ApiSettings, AuthInfo, CampaignWorkspaceSection } from "../../types";
import SectionSidebar from "../../layout/SectionSidebar";
import CampaignOverviewPage from "./CampaignOverviewPage";
import CampaignFieldsPage from "./CampaignFieldsPage";
import GlobalSettingsPage from "./GlobalSettingsPage";
import RecipientDataPage from "./RecipientDataPage";
import RecipientDetailsPage from "./RecipientDetailsPage";
import TemplateDataPage from "./TemplateDataPage";
import AttachmentsDataPage from "./AttachmentsDataPage";
import MailSettingsPage from "./MailSettingsPage";
import ReviewSendPage from "./ReviewSendPage";
import CreateWizard from "./wizard/CreateWizard";
import ReviewWizard from "./wizard/ReviewWizard";
import SendWizard from "./wizard/SendWizard";
import CampaignJsonView from "./CampaignJsonView";
import CampaignReportPage from "./CampaignReportPage";
import CampaignAuditPage from "./CampaignAuditPage";
import { CampaignUnsavedChangesProvider, useCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
const sectionPaths: Record<CampaignWorkspaceSection, string> = {
overview: "",
campaign: "recipients",
"global-settings": "global-settings",
fields: "fields",
recipients: "recipients",
"recipient-data": "recipient-data",
template: "template",
files: "files",
"mail-settings": "mail-settings",
review: "review",
report: "report",
audit: "audit",
json: "json"
};
export default function CampaignWorkspace({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
return (
<CampaignUnsavedChangesProvider>
<CampaignWorkspaceInner settings={settings} auth={auth} />
</CampaignUnsavedChangesProvider>
);
}
function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const { campaignId } = useParams();
const navigate = useNavigate();
const { requestNavigation } = useCampaignUnsavedChanges();
const location = useLocation();
const active = sectionFromPath(location.pathname);
const urlVersionId = new URLSearchParams(location.search).get("version");
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(urlVersionId);
useEffect(() => {
if (urlVersionId) setSelectedVersionId(urlVersionId);
}, [urlVersionId]);
// Relative links inside the workspace historically dropped ?version=. Keep
// the selected version stable for the lifetime of this campaign workspace,
// while still opening the latest version on a fresh campaign entry.
useEffect(() => {
if (urlVersionId || !selectedVersionId) return;
const params = new URLSearchParams(location.search);
params.set("version", selectedVersionId);
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
}, [location.pathname, location.search, navigate, selectedVersionId, urlVersionId]);
function select(section: CampaignWorkspaceSection) {
const path = sectionPaths[section];
const pathname = path ? `/campaigns/${campaignId}/${path}` : `/campaigns/${campaignId}`;
const params = new URLSearchParams(location.search);
const versionId = urlVersionId ?? selectedVersionId;
if (versionId) params.set("version", versionId);
const query = params.toString();
const target = query ? `${pathname}?${query}` : pathname;
if (`${location.pathname}${location.search}` === target) return;
requestNavigation(() => navigate(target));
}
return (
<div className="workspace">
<SectionSidebar active={active} onSelect={select} />
<section className="workspace-content">
<Routes>
<Route index element={<CampaignOverviewPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="data" element={<Navigate to="../recipients" replace />} />
<Route path="fields" element={<CampaignFieldsPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="recipient-data" element={<RecipientDetailsPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="template" element={<TemplateDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="files" element={<AttachmentsDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="attachments" element={<Navigate to="../files" replace />} />
<Route path="mail-settings" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="server-settings" element={<Navigate to="../mail-settings" replace />} />
<Route path="global-settings" element={<GlobalSettingsPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
<Route path="review" element={<ReviewSendPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="send" element={<Navigate to="../review" replace />} />
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="reports" element={<Navigate to="../report" replace />} />
<Route path="audit" element={<CampaignAuditPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="json" element={<CampaignJsonView settings={settings} campaignId={campaignId || ""} />} />
<Route path="wizard/create" element={<CreateWizard settings={settings} campaignId={campaignId || ""} />} />
<Route path="wizard/review" element={<ReviewWizard />} />
<Route path="wizard/send" element={<SendWizard />} />
<Route path="create" element={<Navigate to="../wizard/create" replace />} />
<Route path="campaign" element={<Navigate to="../data" replace />} />
<Route path="mail" element={<Navigate to="../mail-settings" replace />} />
<Route path="settings" element={<Navigate to="../global-settings" replace />} />
<Route path="Review" element={<Navigate to="../" replace />} />
<Route path="*" element={<Navigate to="../" replace />} />
</Routes>
</section>
</div>
);
}
function sectionFromPath(pathname: string): CampaignWorkspaceSection {
const segments = pathname.split("/").filter(Boolean);
const section = segments[2];
if (!section || section === "wizard" || section === "create") return "overview";
if (section === "data" || section === "campaign") return "recipients";
if (section === "global-settings" || section === "settings") return "global-settings";
if (section === "fields") return "fields";
if (section === "recipients") return "recipients";
if (section === "recipient-data" || section === "recipient-details") return "recipient-data";
if (section === "template") return "template";
if (section === "files" || section === "attachments") return "files";
if (section === "mail-settings" || section === "server-settings" || section === "mail") return "mail-settings";
if (section === "review") return "review";
if (section === "send") return "review";
if (section === "report" || section === "reports") return "report";
if (section === "audit") return "audit";
if (section === "json") return "json";
return "overview";
}

View File

@@ -0,0 +1,158 @@
import { useState } from "react";
import type { ApiSettings, AuthInfo } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import DismissibleAlert from "../../components/DismissibleAlert";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import CampaignAccessCard from "./components/CampaignAccessCard";
import VersionLine from "./components/VersionLine";
import ToggleSwitch from "../../components/ToggleSwitch";
import { hasScope } from "../../utils/permissions";
import { RetentionPolicyEditor } from "../privacy/RetentionPolicyManagement";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { cloneJson, getBool, getNumber, getText, updateNested } from "./utils/draftEditor";
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
type EditorState = Record<string, unknown>;
export default function GlobalSettingsPage({ settings, auth, campaignId }: { settings: ApiSettings; auth: AuthInfo; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [editorState, setEditorState] = useState<EditorState>({});
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "global-settings",
unsavedTitle: "Unsaved global settings",
unsavedMessage: "Policies have unsaved changes. Save them before leaving, or discard them and continue.",
extraPayload: () => ({ editor_state: editorState }),
onLoaded: (loadedVersion) => setEditorState(cloneJson(loadedVersion.editor_state ?? {})),
onSaved: (savedVersion) => setEditorState(cloneJson(savedVersion.editor_state ?? editorState))
});
const validationPolicy = asRecord(displayDraft.validation_policy);
const attachments = asRecord(displayDraft.attachments);
const delivery = asRecord(displayDraft.delivery);
const rateLimit = asRecord(delivery.rate_limit);
const retry = asRecord(delivery.retry);
const statusTracking = asRecord(displayDraft.status_tracking);
const optIns = asRecord(editorState.opt_ins);
const canReadRetentionPolicy = hasScope(auth, "admin:policies:read");
const canWriteRetentionPolicy = hasScope(auth, "admin:policies:write");
function patchEditor(path: string[], value: unknown) {
if (locked) return;
setEditorState((current) => updateNested(current, path, value));
markDirty();
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Campaign settings</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}>{dirty ? "Save now" : "Saved"}</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." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
{data.campaign && <CampaignAccessCard settings={settings} campaign={data.campaign} onChanged={reload} onError={setError} />}
{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}
/>
)}
<div className="dashboard-grid">
<Card title="Validation policy">
<PolicySelect label="Missing required attachment" value={getText(validationPolicy, "missing_required_attachment", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_required_attachment"], value)} />
<PolicySelect label="Missing optional attachment" value={getText(validationPolicy, "missing_optional_attachment", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_optional_attachment"], value)} />
<PolicySelect label="Ambiguous attachment match" value={getText(validationPolicy, "ambiguous_attachment_match", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "ambiguous_attachment_match"], value)} />
<PolicySelect label="Unsent attachment file" value={getText(validationPolicy, "unsent_attachment_files", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "unsent_attachment_files"], value)} />
<PolicySelect label="Missing email address" value={getText(validationPolicy, "missing_email", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_email"], value)} options={["block", "drop"]} />
<PolicySelect label="Template error" value={getText(validationPolicy, "template_error", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "template_error"], value)} options={["block", "drop"]} />
<ToggleSwitch label="Ignore empty fields" checked={getBool(validationPolicy, "ignore_empty_fields")} disabled={locked} onChange={(checked) => patch(["validation_policy", "ignore_empty_fields"], checked)} />
</Card>
<Card title="Attachment defaults">
<div className="form-grid compact responsive-form-grid">
<FormField label="Missing behavior">
<select value={getText(attachments, "missing_behavior", "ask")} disabled={locked} 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")} disabled={locked} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select>
</FormField>
<ToggleSwitch label="Send without attachments" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />
</div>
<p className="muted small-note">The actual global and per-recipient attachment rules live in Attachments. These settings define campaign-wide behavior used by validation and review. Individual-attachment permission is configured per attachment base path.</p>
</Card>
</div>
<div className="dashboard-grid below-grid">
<Card title="Delivery defaults">
<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)} />
</div>
</Card>
<Card title="Opt-ins and local assistance">
<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)} />
</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>
</Card>
</div>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
</LoadingFrame>
</div>
);
}
function PolicySelect({ label, value, disabled, onChange, options = behaviorOptions }: { label: string; value: string; disabled?: boolean; onChange: (value: string) => void; options?: string[] }) {
return (
<FormField label={label}>
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</FormField>
);
}

View File

@@ -0,0 +1,627 @@
import { useEffect, useRef, useState } from "react";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
import ToggleSwitch from "../../components/ToggleSwitch";
import DismissibleAlert from "../../components/DismissibleAlert";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import { MailProfilePolicyEditor } from "../mail/MailProfileManagement";
import {
clearMockMailboxMessages,
createMailServerProfile,
getMailProfilePolicy,
getMockMailboxMessage,
listImapFolders,
listMailProfileImapFolders,
listMailServerProfiles,
listMockMailboxMessages,
testImapSettings,
testMailProfileImap,
testMailProfileSmtp,
testSmtpSettings,
updateMockMailboxFailures,
type MailConnectionTestResponse,
type MailImapFolderListResponse,
type MailProfilePolicy,
type MailSecurity,
type MailServerProfile,
type MockMailboxMessage
} from "../../api/mail";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { getBool, getNumber, getText } from "./utils/draftEditor";
const securityOptions = ["plain", "tls", "starttls"];
export default function MailSettingsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [smtpTestResult, setSmtpTestResult] = useState<MailConnectionTestResponse | null>(null);
const [imapTestResult, setImapTestResult] = useState<MailConnectionTestResponse | null>(null);
const [folderResult, setFolderResult] = useState<MailImapFolderListResponse | null>(null);
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | "mock" | null>(null);
const [mockMessages, setMockMessages] = useState<MockMailboxMessage[]>([]);
const [selectedMockMessage, setSelectedMockMessage] = useState<MockMailboxMessage | null>(null);
const [mockError, setMockError] = useState("");
const [mockSandboxEnabled, setMockSandboxEnabled] = useState(false);
const [mailProfiles, setMailProfiles] = useState<MailServerProfile[]>([]);
const [policyProfiles, setPolicyProfiles] = useState<MailServerProfile[]>([]);
const [effectiveMailPolicy, setEffectiveMailPolicy] = useState<MailProfilePolicy | null>(null);
const [profilesLoading, setProfilesLoading] = useState(false);
const [profileName, setProfileName] = useState("");
const [profileMessage, setProfileMessage] = useState("");
const [profileError, setProfileError] = useState("");
const mockSandboxSnapshot = useRef<Record<string, unknown> | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, displayDraft, dirty, saveState, localError, setLocalError, patch, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "mail-settings",
unsavedTitle: "Unsaved server settings",
unsavedMessage: "Server settings have unsaved changes. Save them before leaving, or discard them and continue."
});
const server = asRecord(displayDraft.server);
const smtp = asRecord(server.smtp);
const imap = asRecord(server.imap);
const delivery = asRecord(displayDraft.delivery);
const imapAppend = asRecord(delivery.imap_append_sent);
const imapEnabled = getBool(imap, "enabled");
const selectedProfileId = getText(server, "mail_profile_id");
const selectedProfile = mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null;
const usingMailProfile = Boolean(selectedProfileId);
const selectedProfileHasImap = Boolean(selectedProfile?.imap);
const effectiveImapAvailable = usingMailProfile ? selectedProfileHasImap : imapEnabled;
const smtpCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.smtp_credentials?.inherit !== false : false;
const imapCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.imap_credentials?.inherit !== false : false;
const smtpDisabled = locked || usingMailProfile;
const smtpCredentialDisabled = locked || (usingMailProfile && smtpCredentialsInherited);
const imapServerDisabled = locked || usingMailProfile || !imapEnabled;
const imapCredentialDisabled = locked || !effectiveImapAvailable || (usingMailProfile && imapCredentialsInherited);
const imapDisabled = locked || !effectiveImapAvailable;
useEffect(() => { void refreshMailProfiles(); }, [settings.apiBaseUrl, settings.apiKey, campaignId]);
async function refreshMailProfiles() {
setProfilesLoading(true);
setProfileError("");
try {
const [allowedProfiles, visibleProfiles, policyResponse] = await Promise.all([
listMailServerProfiles(settings, false, campaignId),
listMailServerProfiles(settings, true),
getMailProfilePolicy(settings, "campaign", campaignId, campaignId)
]);
setMailProfiles(allowedProfiles);
setPolicyProfiles(visibleProfiles);
setEffectiveMailPolicy(policyResponse.effective_policy ?? null);
} catch (err) {
setMailProfiles([]);
setPolicyProfiles([]);
setEffectiveMailPolicy(null);
setProfileError(err instanceof Error ? err.message : String(err));
} finally {
setProfilesLoading(false);
}
}
function selectMailProfile(profileId: string) {
if (locked) return;
patch(["server", "mail_profile_id"], profileId);
setProfileMessage("");
setProfileError("");
if (profileId) {
patch(["server", "smtp"], {});
patch(["server", "imap"], {});
setMockSandboxEnabled(false);
setSmtpTestResult(null);
setImapTestResult(null);
setFolderResult(null);
}
}
async function saveCurrentSettingsAsProfile() {
if (locked || usingMailProfile) return;
setProfilesLoading(true);
setProfileMessage("");
setProfileError("");
try {
const created = await createMailServerProfile(settings, {
name: profileName.trim() || `${data.campaign?.name || "Campaign"} mail profile`,
scope_type: "campaign",
scope_id: campaignId,
smtp: smtpPayload(),
imap: imapEnabled ? imapPayload() : null,
is_active: true
});
setMailProfiles((current) => [...current.filter((profile) => profile.id !== created.id), created].sort((a, b) => a.name.localeCompare(b.name)));
patch(["server", "mail_profile_id"], created.id);
patch(["server", "smtp"], {});
patch(["server", "imap"], {});
setProfileName("");
setProfileMessage(`Saved profile ${created.name}.`);
} catch (err) {
setProfileError(err instanceof Error ? err.message : String(err));
} finally {
setProfilesLoading(false);
}
}
function toggleImap(enabled: boolean) {
patch(["server", "imap", "enabled"], enabled);
if (!enabled) {
patch(["delivery", "imap_append_sent", "enabled"], false);
}
}
function profileScopeLabel(profile: MailServerProfile): string {
if (profile.scope_type === "system") return "system";
if (profile.scope_type === "tenant") return "tenant";
if (profile.scope_type === "user") return "user";
if (profile.scope_type === "group") return "group";
return "campaign";
}
function emptyToNull(value: string, trim = true): string | null {
const normalized = trim ? value.trim() : value;
return normalized ? normalized : null;
}
function readSecurity(value: string, fallback: MailSecurity): MailSecurity {
return securityOptions.includes(value as MailSecurity) ? (value as MailSecurity) : fallback;
}
function smtpPayload() {
return {
host: emptyToNull(getText(smtp, "host")),
port: getNumber(smtp, "port", 587),
username: emptyToNull(getText(smtp, "username")),
password: emptyToNull(getText(smtp, "password"), false),
security: readSecurity(getText(smtp, "security", "starttls"), "starttls"),
timeout_seconds: getNumber(smtp, "timeout_seconds", 30)
};
}
function imapPayload() {
return {
enabled: true,
host: emptyToNull(getText(imap, "host")),
port: getNumber(imap, "port", 993),
username: emptyToNull(getText(imap, "username")),
password: emptyToNull(getText(imap, "password"), false),
security: readSecurity(getText(imap, "security", "tls"), "tls"),
sent_folder: emptyToNull(getText(imap, "sent_folder", "auto")),
timeout_seconds: getNumber(imap, "timeout_seconds", 30)
};
}
function effectiveSmtpPayload() {
if (selectedProfile && !smtpCredentialsInherited) {
return {
...selectedProfile.smtp,
username: emptyToNull(getText(smtp, "username")),
password: emptyToNull(getText(smtp, "password"), false)
};
}
return smtpPayload();
}
function effectiveImapPayload() {
if (selectedProfile?.imap && !imapCredentialsInherited) {
return {
...selectedProfile.imap,
enabled: true,
username: emptyToNull(getText(imap, "username")),
password: emptyToNull(getText(imap, "password"), false)
};
}
return imapPayload();
}
async function runSmtpTest() {
if (locked) return;
setMailActionState("smtp");
setLocalError("");
try {
setSmtpTestResult(selectedProfileId && smtpCredentialsInherited
? await testMailProfileSmtp(settings, selectedProfileId)
: await testSmtpSettings(settings, effectiveSmtpPayload()));
} catch (err) {
setSmtpTestResult({ ok: false, protocol: "smtp", message: err instanceof Error ? err.message : String(err), details: {} });
} finally {
setMailActionState(null);
}
}
async function runImapTest() {
if (imapDisabled) return;
setMailActionState("imap");
setLocalError("");
try {
setImapTestResult(selectedProfileId && imapCredentialsInherited
? await testMailProfileImap(settings, selectedProfileId)
: await testImapSettings(settings, effectiveImapPayload()));
} catch (err) {
setImapTestResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), details: {} });
} finally {
setMailActionState(null);
}
}
async function runFolderLookup() {
if (imapDisabled) return;
setMailActionState("folders");
setLocalError("");
try {
setFolderResult(selectedProfileId && imapCredentialsInherited
? await listMailProfileImapFolders(settings, selectedProfileId)
: await listImapFolders(settings, effectiveImapPayload()));
} catch (err) {
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
} finally {
setMailActionState(null);
}
}
function useDetectedSentFolder() {
const folder = folderResult?.detected_sent_folder;
if (!folder || imapDisabled) return;
if (!usingMailProfile) {
patch(["server", "imap", "sent_folder"], folder);
}
if (!getText(imapAppend, "folder") || getText(imapAppend, "folder") === "auto") {
patch(["delivery", "imap_append_sent", "folder"], folder);
}
}
function toggleMockMailSandbox(enabled: boolean) {
if (locked || usingMailProfile) return;
setMockSandboxEnabled(enabled);
if (enabled) {
mockSandboxSnapshot.current = {
smtp: { ...smtp },
imap: { ...imap },
imap_append_sent: { ...imapAppend }
};
patch(["server", "smtp", "host"], "mock.smtp.local");
patch(["server", "smtp", "port"], 2525);
patch(["server", "smtp", "security"], "plain");
patch(["server", "smtp", "username"], "mock");
patch(["server", "smtp", "password"], "mock");
patch(["server", "imap", "enabled"], true);
patch(["server", "imap", "host"], "mock.imap.local");
patch(["server", "imap", "port"], 1143);
patch(["server", "imap", "security"], "plain");
patch(["server", "imap", "username"], "mock");
patch(["server", "imap", "password"], "mock");
patch(["server", "imap", "sent_folder"], "Sent");
patch(["delivery", "imap_append_sent", "enabled"], true);
patch(["delivery", "imap_append_sent", "folder"], "Sent");
setSmtpTestResult({ ok: true, protocol: "smtp", host: "mock.smtp.local", port: 2525, security: "plain", message: "Temporary mock profile enabled. Turn it off to restore the previous values.", details: { mock: true } });
return;
}
const snapshot = mockSandboxSnapshot.current;
if (snapshot) {
patch(["server", "smtp"], asRecord(snapshot.smtp));
patch(["server", "imap"], asRecord(snapshot.imap));
patch(["delivery", "imap_append_sent"], asRecord(snapshot.imap_append_sent));
}
mockSandboxSnapshot.current = null;
setSmtpTestResult(null);
setImapTestResult(null);
}
async function loadMockMailbox() {
setMailActionState("mock");
setMockError("");
try {
const response = await listMockMailboxMessages(settings);
setMockMessages(response.messages);
} catch (err) {
setMockError(err instanceof Error ? err.message : String(err));
} finally {
setMailActionState(null);
}
}
async function openMockMessage(id: string) {
setMailActionState("mock");
setMockError("");
try {
const response = await getMockMailboxMessage(settings, id);
setSelectedMockMessage(response.message);
} catch (err) {
setMockError(err instanceof Error ? err.message : String(err));
} finally {
setMailActionState(null);
}
}
async function clearMockMailbox() {
setMailActionState("mock");
setMockError("");
try {
await clearMockMailboxMessages(settings);
setMockMessages([]);
setSelectedMockMessage(null);
} catch (err) {
setMockError(err instanceof Error ? err.message : String(err));
} finally {
setMailActionState(null);
}
}
async function failNextMockSmtp() {
setMailActionState("mock");
setMockError("");
try {
await updateMockMailboxFailures(settings, { fail_next_smtp: true });
} catch (err) {
setMockError(err instanceof Error ? err.message : String(err));
} finally {
setMailActionState(null);
}
}
async function failNextMockImap() {
setMailActionState("mock");
setMockError("");
try {
await updateMockMailboxFailures(settings, { fail_next_imap: true });
} catch (err) {
setMockError(err instanceof Error ? err.message : String(err));
} finally {
setMailActionState(null);
}
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Server settings</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}>{dirty ? "Save now" : "Saved"}</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." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<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 mail profile policy"
description="Campaign-level allow-list and wildcard limits applied after system, tenant and owner policies."
onSaved={refreshMailProfiles}
/>
<Card
title="Reusable mail profile"
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}>{profilesLoading ? "Saving…" : "Save current settings as profile"}</Button>
</div>
}
>
<div className="form-grid compact responsive-form-grid">
<FormField label="Profile">
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
<option value="">Inline SMTP/IMAP settings</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} onChange={(event) => setProfileName(event.target.value)} placeholder={data.campaign?.name ? `${data.campaign.name} mail profile` : "Campaign mail profile"} />
</FormField>
</div>
{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>
)}
{profileMessage && <DismissibleAlert tone="success" resetKey={profileMessage} floating>{profileMessage}</DismissibleAlert>}
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} floating>{profileError}</DismissibleAlert>}
</Card>
<Card title="Mail server settings">
<div className="mail-server-settings-grid">
<section className="form-subsection mail-server-subsection">
<div className="subsection-heading split">
<h3>SMTP login</h3>
<ToggleSwitch label="Mock server settings" checked={mockSandboxEnabled} disabled={locked || usingMailProfile} onChange={toggleMockMailSandbox} />
</div>
<div className="form-grid compact responsive-form-grid">
<FormField label="Host"><input value={getText(smtp, "host")} disabled={smtpDisabled} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField>
<FormField label="Port"><input type="number" value={getNumber(smtp, "port", 587)} disabled={smtpDisabled} onChange={(event) => patch(["server", "smtp", "port"], Number(event.target.value || 0))} /></FormField>
<FormField label="Username"><input value={getText(smtp, "username")} disabled={smtpCredentialDisabled} onChange={(event) => patch(["server", "smtp", "username"], event.target.value)} /></FormField>
<FormField label="Password"><input type="password" value={getText(smtp, "password")} disabled={smtpCredentialDisabled} onChange={(event) => patch(["server", "smtp", "password"], event.target.value)} /></FormField>
<FormField label="Security"><select value={getText(smtp, "security", "starttls")} disabled={smtpDisabled} onChange={(event) => patch(["server", "smtp", "security"], event.target.value)}>{securityOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="Timeout seconds"><input type="number" value={getNumber(smtp, "timeout_seconds", 30)} disabled={smtpDisabled} onChange={(event) => patch(["server", "smtp", "timeout_seconds"], Number(event.target.value || 0))} /></FormField>
</div>
<div className="button-row compact-actions subsection-bottom-actions">
<Button variant="primary" onClick={runSmtpTest} disabled={locked || mailActionState === "smtp"}>{mailActionState === "smtp" ? "Testing…" : (usingMailProfile ? "Test profile SMTP" : "Test SMTP login")}</Button>
</div>
<MailActionResult result={smtpTestResult} />
</section>
<section className="form-subsection mail-server-subsection">
<div className="subsection-heading split">
<h3>IMAP sent-folder append</h3>
</div>
<div className="form-grid compact responsive-form-grid">
<div className="form-span-full toggle-span-full">
<ToggleSwitch label="Enable IMAP" checked={imapEnabled} disabled={locked || usingMailProfile} onChange={toggleImap} />
</div>
<FormField label="Host"><input value={getText(imap, "host")} disabled={imapServerDisabled} onChange={(event) => patch(["server", "imap", "host"], event.target.value)} /></FormField>
<FormField label="Port"><input type="number" value={getNumber(imap, "port", 993)} disabled={imapServerDisabled} onChange={(event) => patch(["server", "imap", "port"], Number(event.target.value || 0))} /></FormField>
<FormField label="Username"><input value={getText(imap, "username")} disabled={imapCredentialDisabled} onChange={(event) => patch(["server", "imap", "username"], event.target.value)} /></FormField>
<FormField label="Password"><input type="password" value={getText(imap, "password")} disabled={imapCredentialDisabled} onChange={(event) => patch(["server", "imap", "password"], event.target.value)} /></FormField>
<FormField label="Security"><select value={getText(imap, "security", "tls")} disabled={imapServerDisabled} onChange={(event) => patch(["server", "imap", "security"], event.target.value)}>{securityOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="Detected/saved sent folder"><input value={getText(imap, "sent_folder", "auto")} disabled={imapServerDisabled} onChange={(event) => patch(["server", "imap", "sent_folder"], event.target.value)} /></FormField>
<div className="form-span-full toggle-span-full">
<ToggleSwitch label="Append successfully sent messages to Sent" checked={getBool(imapAppend, "enabled")} disabled={imapDisabled} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
</div>
<FormField label="Append folder"><input value={getText(imapAppend, "folder", getText(imap, "sent_folder", "auto"))} disabled={imapDisabled || !getBool(imapAppend, "enabled")} onChange={(event) => patch(["delivery", "imap_append_sent", "folder"], event.target.value)} /></FormField>
</div>
<div className="button-row compact-actions subsection-bottom-actions">
<Button variant="primary" onClick={runImapTest} disabled={imapDisabled || mailActionState === "imap"}>{mailActionState === "imap" ? "Testing…" : (usingMailProfile ? "Test profile IMAP" : "Test IMAP login")}</Button>
<Button variant="primary" onClick={runFolderLookup} disabled={imapDisabled || mailActionState === "folders"}>{mailActionState === "folders" ? "Looking up…" : "Folders…"}</Button>
</div>
<p className="muted small-note">Folder lookup lists visible mailboxes and guesses folders such as Sent, Gesendet or Sent Mail.</p>
<MailActionResult result={imapTestResult} />
<FolderLookupResult result={folderResult} disabled={imapDisabled} onUseDetected={useDetectedSentFolder} />
</section>
</div>
</Card>
<Card title="Mock mail sandbox">
<div className="subsection-heading split">
<div>
<h3>Captured messages</h3>
<p className="muted small-note">Use the mock sandbox profile, save this page, then send normally. SMTP deliveries and IMAP Sent appends appear here.</p>
</div>
<div className="button-row compact-actions">
<Button onClick={loadMockMailbox} disabled={mailActionState === "mock"}>{mailActionState === "mock" ? "Loading…" : "Reload mailbox"}</Button>
<Button onClick={failNextMockSmtp} disabled={mailActionState === "mock"}>Fail next SMTP</Button>
<Button onClick={failNextMockImap} disabled={mailActionState === "mock"}>Fail next IMAP</Button>
<Button variant="danger" onClick={clearMockMailbox} disabled={mailActionState === "mock" || mockMessages.length === 0}>Clear</Button>
</div>
</div>
{mockError && <DismissibleAlert tone="danger" resetKey={mockError} floating>{mockError}</DismissibleAlert>}
<DataGrid
id={`campaign-${campaignId}-server-mock-mailbox`}
rows={mockMessages}
columns={mockMailboxColumns(openMockMessage)}
getRowKey={(message) => message.id}
emptyText="No mock messages captured yet."
className="data-table compact-table"
/>
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
</LoadingFrame>
{selectedMockMessage && (
<MessagePreviewOverlay
title="Captured mock mail"
subject={selectedMockMessage.subject || "Mock message"}
bodyMode="text"
text={selectedMockMessage.body_preview || ""}
recipientLabel={selectedMockMessage.kind === "imap_append" ? "Mock IMAP append" : "Mock SMTP delivery"}
recipientNote={selectedMockMessage.created_at ? new Date(selectedMockMessage.created_at).toLocaleString() : undefined}
metaItems={mockMessageMetaItems(selectedMockMessage)}
attachments={mockMessageAttachments(selectedMockMessage)}
raw={selectedMockMessage.raw_eml}
rawLabel="Raw MIME"
onClose={() => setSelectedMockMessage(null)}
/>
)}
</div>
);
}
function mockMessageMetaItems(message: MockMailboxMessage) {
return [
{ label: "From", value: message.from_header || message.envelope_from || "—" },
{ label: "To", value: message.to_header || message.envelope_recipients?.join(", ") || "—" },
{ label: "Kind", value: message.kind || "—" },
{ label: "Folder", value: message.folder || "—" },
{ label: "Message-ID", value: message.message_id || "—" },
{ label: "Size", value: `${message.size_bytes || 0} bytes` }
];
}
function mockMessageAttachments(message: MockMailboxMessage): MessagePreviewAttachment[] {
return (message.attachments ?? []).map((attachment, index) => ({
filename: attachment.filename || `Attachment ${index + 1}`,
contentType: attachment.content_type || undefined,
sizeBytes: attachment.size_bytes ?? undefined
}));
}
function formatMockDate(value: string): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
function mockMailboxColumns(openMockMessage: (id: string) => Promise<void>): DataGridColumn<MockMailboxMessage>[] {
return [
{ id: "kind", header: "Kind", width: 110, sortable: true, filterable: true, sticky: "start", columnType: "from-list", list: { options: [{ value: "SMTP", label: "SMTP" }, { value: "IMAP", label: "IMAP" }] }, render: (message) => <span className="status-badge neutral">{message.kind === "imap_append" ? "IMAP" : "SMTP"}</span>, value: (message) => message.kind === "imap_append" ? "IMAP" : "SMTP" },
{ id: "received", header: "Received", width: 180, sortable: true, filterable: true, filterType: "date", value: (message) => formatMockDate(message.created_at), sortValue: (message) => message.created_at ?? "" },
{ id: "subject", header: "Subject", width: "minmax(240px, 1fr)", sortable: true, filterable: true, value: (message) => message.subject || "—" },
{ id: "envelope", header: "Envelope", width: 300, resizable: true, filterable: true, value: (message) => `${message.envelope_from || message.folder || "—"}${(message.envelope_recipients || []).join(", ") || message.folder || "—"}` },
{ id: "attachments", header: "Attachments", width: 130, sortable: true, filterable: true, filterType: "integer", value: (message) => message.attachment_count ?? 0 },
{ id: "actions", header: "Actions", width: 110, sticky: "end", render: (message) => <Button onClick={() => openMockMessage(message.id)}>Open</Button> }
];
}
function MailActionResult({ result }: { result: MailConnectionTestResponse | null }) {
if (!result) return null;
const authenticated = result.details?.authenticated;
return (
<DismissibleAlert tone={result.ok ? "success" : "danger"} resetKey={`${result.ok}:${result.message}`} floating>
{result.message}
{result.ok && typeof authenticated === "boolean" && (
<span> Authentication: {authenticated ? "credentials accepted" : "not used"}.</span>
)}
</DismissibleAlert>
);
}
function FolderLookupResult({ result, disabled, onUseDetected }: { result: MailImapFolderListResponse | null; disabled?: boolean; onUseDetected: () => void }) {
if (!result) return null;
if (!result.ok) {
return <DismissibleAlert tone="danger" resetKey={result.message}>{result.message}</DismissibleAlert>;
}
return (
<DismissibleAlert tone="success" resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
<p>{result.message}</p>
<p>Detected Sent folder: <strong>{result.detected_sent_folder || "—"}</strong></p>
{result.detected_sent_folder && <Button onClick={onUseDetected} disabled={disabled}>Use detected folder</Button>}
{result.folders.length > 0 && (
<div className="field-chip-list">
{result.folders.slice(0, 12).map((folder) => (
<span className="field-chip" key={folder.name} title={(folder.flags || []).join(" ")}>{folder.name}</span>
))}
{result.folders.length > 12 && <span className="field-chip">+{result.folders.length - 12} more</span>}
</div>
)}
</DismissibleAlert>
);
}

View File

@@ -0,0 +1,387 @@
import { useMemo } from "react";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import ToggleSwitch from "../../components/ToggleSwitch";
import EmailAddressInput from "../../components/email/EmailAddressInput";
import DismissibleAlert from "../../components/DismissibleAlert";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { getBool } from "./utils/draftEditor";
import {
addressesFromValue,
collectCampaignAddressSuggestions,
type MailboxAddress
} from "../../utils/emailAddresses";
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
const recipientHeaderRows = [
{ key: "to", label: "To", toggleKey: "allow_individual_to", toggleLabel: "Allow individual To", addLabel: "Add recipient", emptyText: "No global recipients configured." },
{ key: "cc", label: "CC", toggleKey: "allow_individual_cc", toggleLabel: "Allow individual CC", addLabel: "Add CC", emptyText: "No global CC recipients configured." },
{ key: "bcc", label: "BCC", toggleKey: "allow_individual_bcc", toggleLabel: "Allow individual BCC", addLabel: "Add BCC", emptyText: "No global BCC recipients configured." }
] as const;
type RecipientAddressKey = "to" | "cc" | "bcc";
type EntryAddressColumn = {
key: RecipientAddressKey | "from" | "reply_to";
label: string;
allowMultiple: boolean;
addLabel: string;
emptyText: string;
mergeKey?: "merge_to" | "merge_cc" | "merge_bcc" | "merge_reply_to";
};
export default function RecipientDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "recipients",
unsavedTitle: "Unsaved recipient changes",
unsavedMessage: "Recipient address data has unsaved changes. Save it before leaving, or discard it and continue."
});
const recipientsSection = asRecord(displayDraft.recipients);
const entries = asRecord(displayDraft.entries);
const entryDefaults = asRecord(entries.defaults);
const inlineEntries = asArray(entries.inline).map(asRecord);
const source = asRecord(entries.source);
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
const defaultFrom = addressesFromValue(recipientsSection.from).slice(0, 1);
const globalReplyTo = addressesFromValue(recipientsSection.reply_to);
const globalRecipientValues: Record<string, MailboxAddress[]> = {
to: addressesFromValue(recipientsSection.to),
cc: addressesFromValue(recipientsSection.cc),
bcc: addressesFromValue(recipientsSection.bcc)
};
const entryAddressColumns = useMemo<EntryAddressColumn[]>(() => {
const columns: EntryAddressColumn[] = [];
if (getBool(recipientsSection, "allow_individual_from")) {
columns.push({ key: "from", label: "From", allowMultiple: false, addLabel: "Set From", emptyText: "Uses global From." });
}
if (getBool(recipientsSection, "allow_individual_reply_to")) {
columns.push({ key: "reply_to", label: "Reply-To", allowMultiple: true, addLabel: "Add Reply-To", emptyText: "Uses global Reply-To.", mergeKey: "merge_reply_to" });
}
if (getBool(recipientsSection, "allow_individual_to")) {
columns.push({ key: "to", label: "To", allowMultiple: true, addLabel: "Add To", emptyText: "No recipient address.", mergeKey: "merge_to" });
}
if (getBool(recipientsSection, "allow_individual_cc")) columns.push({ key: "cc", label: "CC", allowMultiple: true, addLabel: "Add CC", emptyText: "No CC.", mergeKey: "merge_cc" });
if (getBool(recipientsSection, "allow_individual_bcc")) columns.push({ key: "bcc", label: "BCC", allowMultiple: true, addLabel: "Add BCC", emptyText: "No BCC.", mergeKey: "merge_bcc" });
return columns;
}, [recipientsSection]);
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
patch(["entries", "inline"], nextEntries);
}
function addRecipient(afterIndex = inlineEntries.length - 1) {
const nextIndex = inlineEntries.length + 1;
const newEntry = {
id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`),
active: true,
to: [],
cc: [],
bcc: [],
name: "",
email: "",
from: [],
reply_to: [],
merge_to: false,
merge_cc: true,
merge_bcc: true,
merge_reply_to: true
};
replaceInlineEntries(insertAfter(inlineEntries, afterIndex, newEntry));
}
function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) {
const nextEntries = inlineEntries.map((entry, currentIndex) => currentIndex === index ? updater(entry) : entry);
replaceInlineEntries(nextEntries);
}
function updateEntryAddressList(index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) {
updateEntry(index, (entry) => {
if (key === "from") {
return { ...entry, from: addresses.slice(0, 1) };
}
const nextEntry = { ...entry, [key]: addresses };
if (key === "to") {
const address = addresses[0] ?? { name: "", email: "" };
return {
...nextEntry,
name: address.name ?? "",
email: address.email
};
}
return nextEntry;
});
}
function updateEntryMerge(index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) {
updateEntry(index, (entry) => {
const next = { ...entry, [mergeKey]: checked };
delete next[mergeKey.replace("merge_", "combine_")];
return next;
});
}
function removeEntry(index: number) {
replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index));
}
function moveEntry(index: number, targetIndex: number) {
if (locked || index === targetIndex) return;
replaceInlineEntries(moveArrayItem(inlineEntries, index, targetIndex));
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Sender & Recipients</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>
</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." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<Card title="Campaign sender">
<div className="campaign-header-stack">
<div className="campaign-header-grid">
<FormField label="Default From address">
<EmailAddressInput
value={defaultFrom}
suggestions={addressSuggestions}
allowMultiple={false}
disabled={locked}
addLabel="Set From"
emptyText="No global From address configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses.slice(0, 1))}
/>
</FormField>
<div className="campaign-header-toggle">
<ToggleSwitch
label="Allow individual senders"
checked={getBool(recipientsSection, "allow_individual_from")}
disabled={locked}
onChange={(checked) => patch(["recipients", "allow_individual_from"], checked)}
/>
</div>
</div>
<div className="campaign-header-grid">
<FormField label="Global Reply-To address">
<EmailAddressInput
value={globalReplyTo}
suggestions={addressSuggestions}
allowMultiple
disabled={locked}
addLabel="Add Reply-To"
emptyText="No global Reply-To address configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses)}
/>
</FormField>
<div className="campaign-header-toggle">
<ToggleSwitch
label="Allow individual Reply-To"
checked={getBool(recipientsSection, "allow_individual_reply_to")}
disabled={locked}
onChange={(checked) => patch(["recipients", "allow_individual_reply_to"], checked)}
/>
</div>
</div>
</div>
</Card>
<Card title="Global recipient headers">
<div className="campaign-header-stack">
{recipientHeaderRows.map((row) => (
<div className="campaign-header-grid" key={row.key}>
<FormField label={row.label}>
<EmailAddressInput
value={globalRecipientValues[row.key] ?? []}
suggestions={addressSuggestions}
allowMultiple
disabled={locked}
addLabel={row.addLabel}
emptyText={row.emptyText}
onChange={(addresses: MailboxAddress[]) => patch(["recipients", row.key], addresses)}
/>
</FormField>
<div className="campaign-header-toggle">
<ToggleSwitch
label={row.toggleLabel}
checked={getBool(recipientsSection, row.toggleKey)}
disabled={locked}
onChange={(checked) => patch(["recipients", row.toggleKey], checked)}
/>
</div>
</div>
))}
</div>
</Card>
<section className="recipient-profiles-section">
<div className="subsection-heading split">
<h3>Recipient profiles</h3>
<Button disabled>Import</Button>
</div>
{inlineEntries.length === 0 && Boolean(source.type) && (
<DismissibleAlert tone="info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</DismissibleAlert>
)}
{!source.type && (
<div className="admin-table-surface recipient-profiles-table-surface">
<DataGrid
id={`campaign-${campaignId}-recipient-profiles`}
rows={inlineEntries.slice(0, 100)}
columns={recipientProfileColumns({ locked, entries: inlineEntries, entryDefaults, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry })}
getRowKey={(entry, index) => String(entry.id || index)}
emptyText="No recipient profiles are stored in the current version yet."
emptyAction={<DataGridEmptyAction onAdd={() => addRecipient(-1)} disabled={locked} label="Add first recipient" />}
className="recipient-table-wrap recipient-address-table"
/>
</div>
)}
</section>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
</LoadingFrame>
</div>
);
}
type RecipientProfileColumnContext = {
locked: boolean;
entries: Record<string, unknown>[];
entryDefaults: Record<string, unknown>;
entryAddressColumns: EntryAddressColumn[];
addressSuggestions: MailboxAddress[];
updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void;
updateEntryMerge: (index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) => void;
updateEntry: (index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) => void;
addRecipient: (afterIndex?: number) => void;
moveEntry: (index: number, targetIndex: number) => void;
removeEntry: (index: number) => void;
};
function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small">{index + 1}</span>, value: (_entry, index) => index + 1 },
...entryAddressColumns.map((column): DataGridColumn<Record<string, unknown>> => ({
id: column.key,
header: column.label,
width: column.key === "to" ? "minmax(260px, 1.2fr)" : 250,
resizable: true,
filterable: true,
render: (entry, index) => (
<div className="recipient-address-cell">
<EmailAddressInput
value={getEntryAddresses(entry, column.key)}
suggestions={addressSuggestions}
allowMultiple={column.allowMultiple}
compact
disabled={locked}
addLabel={column.addLabel}
emptyText={column.emptyText}
onChange={(addresses) => updateEntryAddressList(index, column.key, addresses)}
/>
{column.mergeKey && (
<div className="recipient-address-merge">
<ToggleSwitch
label="Merge"
checked={getEntryMerge(entry, entryDefaults, column.mergeKey)}
disabled={locked}
onChange={(checked) => updateEntryMerge(index, column.mergeKey!, checked)}
/>
<span className="muted">Merge with global {column.label}</span>
</div>
)}
</div>
),
value: (entry) => getEntryAddresses(entry, column.key).map((address) => `${address.name ?? ""} ${address.email ?? ""}`).join(", ")
})),
{ id: "active", header: "Active", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "active", label: "Active" }, { value: "inactive", label: "Inactive" }] }, render: (entry, index) => <ToggleSwitch label="Active" checked={entry.active !== false} disabled={locked} onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))} />, value: (entry) => entry.active !== false ? "active" : "inactive" },
{
id: "actions",
header: "Actions",
width: 180,
sticky: "end",
render: (_entry, index) => (
<DataGridRowActions
disabled={locked}
onAddBelow={() => addRecipient(index)}
onRemove={() => removeEntry(index)}
onMoveUp={index > 0 ? () => moveEntry(index, index - 1) : undefined}
onMoveDown={index < entries.length - 1 ? () => moveEntry(index, index + 1) : undefined}
addLabel="Add recipient below"
removeLabel="Remove recipient"
moveUpLabel="Move recipient up"
moveDownLabel="Move recipient down"
/>
)
}
];
}
function getEntryMerge(entry: Record<string, unknown>, defaults: Record<string, unknown>, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>): boolean {
const legacyKey = mergeKey.replace("merge_", "combine_");
if (typeof entry[mergeKey] === "boolean") return entry[mergeKey] as boolean;
if (typeof entry[legacyKey] === "boolean") return entry[legacyKey] as boolean;
if (typeof defaults[mergeKey] === "boolean") return defaults[mergeKey] as boolean;
if (typeof defaults[legacyKey] === "boolean") return defaults[legacyKey] as boolean;
return true;
}
function getEntryAddresses(entry: Record<string, unknown>, key: EntryAddressColumn["key"]): MailboxAddress[] {
if (key === "to") {
const explicit = addressesFromValue(entry.to);
return explicit.length ? explicit : fallbackRecipientAddress(entry);
}
if (key === "from") return addressesFromValue(entry.from).slice(0, 1);
if (key === "reply_to") return addressesFromValue(entry.reply_to);
return addressesFromValue(entry[key]);
}
function fallbackRecipientAddress(entry: Record<string, unknown>): MailboxAddress[] {
const direct = addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0];
return direct?.email ? [direct] : [];
}
function uniqueEntryId(entries: Record<string, unknown>[], preferred: string): string {
const existing = new Set(entries.map((entry) => String(entry.id || "")).filter(Boolean));
if (!existing.has(preferred)) return preferred;
let counter = entries.length + 1;
let candidate = `recipient-${counter}`;
while (existing.has(candidate)) {
counter += 1;
candidate = `recipient-${counter}`;
}
return candidate;
}

View File

@@ -0,0 +1,203 @@
import { useMemo } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import FieldValueInput from "./components/FieldValueInput";
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
import { getDraftFields } from "./utils/fieldDefinitions";
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
import { addressesFromValue } from "../../utils/emailAddresses";
import DismissibleAlert from "../../components/DismissibleAlert";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
export default function RecipientDetailsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
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."
});
const entries = asRecord(displayDraft.entries);
const inlineEntries = asArray(entries.inline).map(asRecord);
const source = asRecord(entries.source);
const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]);
const attachmentSection = asRecord(displayDraft.attachments);
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]);
const individualAttachmentBasePaths = useMemo(() => getIndividualAttachmentBasePaths(attachmentBasePaths), [attachmentBasePaths]);
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachmentSection.zip), [attachmentSection.zip]);
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
patch(["entries", "inline"], nextEntries);
}
function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) {
const nextEntries = inlineEntries.map((entry, currentIndex) => currentIndex === index ? updater(entry) : entry);
replaceInlineEntries(nextEntries);
}
function updateEntryField(index: number, field: string, value: unknown) {
updateEntry(index, (entry) => ({
...entry,
fields: {
...asRecord(entry.fields),
[field]: value
}
}));
}
function updateEntryAttachments(index: number, attachments: AttachmentRule[]) {
updateEntry(index, (entry) => ({ ...entry, attachments }));
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Recipient data</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>
</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." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<Card title="Recipient field values and attachments">
{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 && (
<DataGrid
id={`campaign-${campaignId}-recipient-data`}
rows={inlineEntries.slice(0, 100)}
columns={recipientDataColumns({ settings, campaignId, locked, 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"
/>
)}
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
</LoadingFrame>
</div>
);
}
type RecipientDataColumnContext = {
settings: ApiSettings;
campaignId: string;
locked: boolean;
fieldDefinitions: ReturnType<typeof getDraftFields>;
individualAttachmentBasePaths: ReturnType<typeof getIndividualAttachmentBasePaths>;
zipConfig: AttachmentZipCollection;
updateEntryAttachments: (index: number, attachments: AttachmentRule[]) => void;
updateEntryField: (index: number, field: string, value: unknown) => void;
};
function recipientDataColumns({ settings, campaignId, locked, 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>
{extraRecipientCount(entry) > 0 && <span className="recipient-extra-bubble">+{extraRecipientCount(entry)}</span>}
</Link>
),
value: firstRecipientEmail
},
{
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}
onChange={(rules) => updateEntryAttachments(index, rules)}
/>
);
},
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 ? "Uses global value" : undefined}
onChange={(value) => updateEntryField(index, field.name, value)}
/>
);
},
value: (entry) => String(asRecord(entry.fields)[field.name] ?? "")
}))
];
}
function firstRecipientEmail(entry: Record<string, unknown>): string {
return (addressesFromValue(entry.to)[0] ?? addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0])?.email ?? "";
}
function extraRecipientCount(entry: Record<string, unknown>): number {
const count = addressesFromValue(entry.to).length;
return Math.max(0, count - 1);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,381 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { ApiSettings } from "../../types";
import { previewCampaignAttachments, type CampaignAttachmentPreviewRule } from "../../api/campaigns";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import DismissibleAlert from "../../components/DismissibleAlert";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
import { TemplateFieldChipList, UndefinedPlaceholderDecisionDialog, UndefinedPlaceholderList } from "./components/TemplatePlaceholderControls";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { cloneJson, getBool, getText } from "./utils/draftEditor";
import { humanizeFieldName } from "./utils/fieldDefinitions";
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
type BodyMode = "text" | "html";
type EditorTarget = "subject" | "text" | "html";
export default function TemplateDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [bodyMode, setBodyMode] = useState<BodyMode>("text");
const [activeEditor, setActiveEditor] = useState<EditorTarget>("text");
const [previewOpen, setPreviewOpen] = useState(false);
const [previewIndex, setPreviewIndex] = useState(0);
const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null);
const [attachmentPreviewRules, setAttachmentPreviewRules] = useState<CampaignAttachmentPreviewRule[]>([]);
const [attachmentPreviewLoading, setAttachmentPreviewLoading] = useState(false);
const [attachmentPreviewError, setAttachmentPreviewError] = useState("");
const subjectRef = useRef<HTMLInputElement | null>(null);
const textRef = useRef<HTMLTextAreaElement | null>(null);
const htmlRef = useRef<HTMLTextAreaElement | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
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 ${new Date(loadedVersion.autosaved_at).toLocaleString()}` : "Loaded",
onLoaded: () => setPreviewIndex(0)
});
const template = asRecord(displayDraft.template);
const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]);
const localFieldNames = useMemo(() => fields.map((field) => String(field.name || field.id || "")).filter(Boolean), [fields]);
const globalFieldNames = useMemo(() => uniqueSorted([...localFieldNames, ...Object.keys(asRecord(displayDraft.global_values))]), [displayDraft.global_values, localFieldNames]);
const builtInAddressNames = useMemo(() => recipientAddressTemplateFieldOptions().map((field) => field.name), []);
const localAvailableNames = useMemo(() => new Set([...localFieldNames, ...builtInAddressNames]), [builtInAddressNames, localFieldNames]);
const globalAvailableNames = useMemo(() => new Set(globalFieldNames), [globalFieldNames]);
const allAvailableNames = useMemo(() => new Set([...localAvailableNames, ...globalAvailableNames]), [globalAvailableNames, localAvailableNames]);
const localFieldOptions = useMemo(() => {
const options = [...recipientAddressTemplateFieldOptions(), ...localFieldNames.map((name) => ({ name, label: name }))];
return options.filter((option, index) => options.findIndex((candidate) => candidate.name === option.name) === index);
}, [localFieldNames]);
const globalFieldOptions = useMemo(() => globalFieldNames.map((name) => ({ name, label: name })), [globalFieldNames]);
const entries = asRecord(displayDraft.entries);
const inlineEntries = useMemo(() => asArray(entries.inline).map(asRecord), [entries.inline]);
const activePreviewEntries = useMemo(
() => inlineEntries.map((entry, sourceIndex) => ({ entry, sourceIndex: sourceIndex + 1 })).filter(({ entry }) => entry.active !== false),
[inlineEntries]
);
const previewEntries = activePreviewEntries.length > 0 ? activePreviewEntries : [{ entry: {}, sourceIndex: 0 }];
const previewSelection = previewEntries[Math.min(previewIndex, previewEntries.length - 1)] ?? previewEntries[0];
const previewEntry = previewSelection.entry;
const ignoreEmptyFields = getBool(asRecord(displayDraft.validation_policy), "ignore_empty_fields", false);
const templateText = `${getText(template, "subject")}\n${getText(template, "text")}\n${getText(template, "html")}`;
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]);
const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]);
const undefinedPlaceholders = useMemo(
() => buildUndefinedPlaceholders(usedPlaceholders, allAvailableNames, { local: localAvailableNames, global: globalAvailableNames }),
[usedPlaceholders, allAvailableNames, globalAvailableNames, localAvailableNames]
);
const previewContext = useMemo(() => buildTemplatePreviewContext(displayDraft, previewEntry), [displayDraft, previewEntry]);
const previewSubject = renderTemplatePreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields);
const previewText = renderTemplatePreviewText(getText(template, "text"), previewContext, ignoreEmptyFields);
const previewHtml = renderTemplatePreviewText(getText(template, "html"), previewContext, ignoreEmptyFields);
const selectedPreviewEntryIndex = previewSelection.sourceIndex;
const previewAttachmentRules = useMemo(
() => attachmentPreviewRules.filter((rule) => rule.entry_index === selectedPreviewEntryIndex),
[attachmentPreviewRules, selectedPreviewEntryIndex]
);
const previewAttachments = useMemo(
() => mapResolvedAttachmentsToPreviewBoxes(previewAttachmentRules, attachmentPreviewLoading, attachmentPreviewError),
[attachmentPreviewError, attachmentPreviewLoading, previewAttachmentRules]
);
useEffect(() => {
if (previewIndex >= previewEntries.length) setPreviewIndex(Math.max(0, previewEntries.length - 1));
}, [previewIndex, previewEntries.length]);
useEffect(() => {
if (!previewOpen || !version?.id || !draft) return;
let cancelled = false;
setAttachmentPreviewLoading(true);
setAttachmentPreviewError("");
const handle = window.setTimeout(() => {
void previewCampaignAttachments(settings, campaignId, version.id, {
include_unmatched: false,
campaign_json: 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);
});
}, 120);
return () => {
cancelled = true;
window.clearTimeout(handle);
};
}, [campaignId, displayDraft, draft, previewOpen, settings.apiBaseUrl, settings.apiKey, settings.accessToken, version?.id]);
function patchTemplateText(target: EditorTarget, value: string) {
patch(["template", target], value);
}
function insertPlaceholder(namespace: TemplateNamespace, name: string) {
if (locked) return;
const target = bodyMode === "html" && activeEditor !== "subject" ? "html" : activeEditor;
const element = target === "subject" ? subjectRef.current : target === "html" ? htmlRef.current : textRef.current;
const token = `{{${namespace}:${name}}}`;
const currentText = getText(template, target);
const start = element?.selectionStart ?? currentText.length;
const end = element?.selectionEnd ?? currentText.length;
const nextText = `${currentText.slice(0, start)}${token}${currentText.slice(end)}`;
patchTemplateText(target, nextText);
window.requestAnimationFrame(() => {
element?.focus();
const cursor = start + token.length;
element?.setSelectionRange(cursor, cursor);
});
}
function addUndefinedField(field: UndefinedPlaceholder) {
if (!draft || locked || !field.name) return;
const existingFields = asArray(draft.fields).map(asRecord);
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
}
]);
}
setUndefinedDialog(null);
}
function removePlaceholder(field: UndefinedPlaceholder) {
if (locked) return;
setDraft((current) => {
const next = cloneJson(current ?? {});
const nextTemplate = { ...asRecord(next.template) };
nextTemplate.subject = removePlaceholderFromText(getText(nextTemplate, "subject"), field.raw);
nextTemplate.text = removePlaceholderFromText(getText(nextTemplate, "text"), field.raw);
nextTemplate.html = removePlaceholderFromText(getText(nextTemplate, "html"), field.raw);
next.template = nextTemplate;
return next;
});
markDirty();
setUndefinedDialog(null);
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Template</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>
</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." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<div className="dashboard-grid template-editor-grid">
<Card title="Editable template" actions={<Button onClick={() => setPreviewOpen(true)}>Preview</Button>}>
<div className="form-grid">
<FormField label="Subject">
<input
ref={subjectRef}
value={getText(template, "subject")}
disabled={locked}
onFocus={() => setActiveEditor("subject")}
onChange={(event) => patchTemplateText("subject", event.target.value)}
/>
</FormField>
<div className="template-body-mode" role="tablist" aria-label="Template body mode">
<button type="button" className={bodyMode === "text" ? "active" : ""} onClick={() => { setBodyMode("text"); setActiveEditor("text"); }}>Plain text</button>
<button type="button" className={bodyMode === "html" ? "active" : ""} onClick={() => { setBodyMode("html"); setActiveEditor("html"); }}>HTML</button>
</div>
{bodyMode === "text" && (
<FormField label="Plain text body">
<textarea
ref={textRef}
rows={16}
value={getText(template, "text")}
disabled={locked}
onFocus={() => setActiveEditor("text")}
onChange={(event) => patchTemplateText("text", event.target.value)}
/>
</FormField>
)}
{bodyMode === "html" && (
<FormField label="HTML body">
<textarea
ref={htmlRef}
rows={16}
value={getText(template, "html")}
disabled={locked}
onFocus={() => 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>
</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>
<h3 className="section-mini-heading">Global fields</h3>
<TemplateFieldChipList
namespace="global"
fields={globalFieldOptions}
usedPlaceholders={usedPlaceholders}
empty="No campaign fields or global values defined."
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>
<TemplateFieldChipList
namespace="local"
fields={localFieldOptions}
usedPlaceholders={usedPlaceholders}
empty="No campaign fields defined."
onInsert={insertPlaceholder}
/>
<h3 className="section-mini-heading">Used in template, but undefined</h3>
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
</Card>
</div>
</div>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</>
</LoadingFrame>
{previewOpen && (
<MessagePreviewOverlay
title="Template preview"
bodyMode={bodyMode}
subject={previewSubject}
text={previewText}
html={previewHtml}
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)}
/>
)}
<UndefinedPlaceholderDecisionDialog
field={undefinedDialog}
contextLabel="template"
removeLabel="Remove from template"
onCancel={() => setUndefinedDialog(null)}
onRemove={removePlaceholder}
onAddField={addUndefinedField}
/>
</div>
);
}
function mapResolvedAttachmentsToPreviewBoxes(
rules: CampaignAttachmentPreviewRule[],
loading: boolean,
error: string
): MessagePreviewAttachment[] {
if (loading) {
return [{ filename: "Resolving attachment patterns…", detail: "Managed files are being checked for this recipient." }];
}
if (error) {
return [{ filename: "Attachment preview unavailable", detail: error }];
}
return rules.flatMap((rule) => {
const detailParts = [
rule.source === "global" ? "Global" : "Recipient",
rule.label,
rule.required ? "required" : "optional",
rule.pattern
].filter(Boolean);
const detail = detailParts.join(" · ");
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}` : ""}`,
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
}));
}
return [{
filename: `No file matched ${rule.pattern || "attachment 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
}];
});
}
function recipientLabel(entry: Record<string, unknown>, index: number): string {
const name = valueToPreview(entry.name).trim();
const email = valueToPreview(entry.email).trim();
if (name && email) return `${name} <${email}>`;
if (name) return name;
if (email) return email;
return `Recipient ${index + 1}`;
}
function uniqueSorted(values: string[]): string[] {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
}

View File

@@ -0,0 +1,379 @@
import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import type { ApiSettings } from "../../../types";
import Button from "../../../components/Button";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid";
import ToggleSwitch from "../../../components/ToggleSwitch";
import { getBool, getText } from "../utils/draftEditor";
import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments";
import ManagedFileChooser, { type ManagedAttachmentSelection } from "./ManagedFileChooser";
import { insertAfter, moveArrayItem } from "../../../utils/arrayOrder";
import { asRecord } from "../utils/campaignView";
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
type AttachmentRulesOverlayProps = {
title: string;
rules: AttachmentRule[];
settings: ApiSettings;
campaignId: string;
disabled?: boolean;
buttonLabel?: string;
emptyText?: string;
basePaths?: AttachmentBasePath[];
zipConfig?: AttachmentZipCollection;
onChange: (rules: AttachmentRule[]) => void;
};
type AttachmentRulesTableProps = {
rules: AttachmentRule[];
settings: ApiSettings;
campaignId: string;
disabled?: boolean;
emptyText?: string;
basePaths?: AttachmentBasePath[];
id?: string;
showAddButton?: boolean;
activeChooserRuleIndex?: number | null;
zipConfig?: AttachmentZipCollection;
onOpenFileChooser?: (ruleIndex: number) => void;
onChange: (rules: AttachmentRule[]) => void;
};
type FileChooserState = {
ruleIndex: number;
basePath: AttachmentBasePath | null;
};
export default function AttachmentRulesOverlay({
title,
rules,
settings,
campaignId,
disabled = false,
buttonLabel,
emptyText = "No attachment files or matching rules configured yet.",
basePaths = [],
zipConfig = { enabled: false, archives: [] },
onChange
}: AttachmentRulesOverlayProps) {
const [open, setOpen] = useState(false);
const [draftRules, setDraftRules] = useState<AttachmentRule[]>([]);
const summary = useMemo(() => summarizeAttachmentRules(rules), [rules]);
const label = buttonLabel ?? `direct: ${summary.direct} / rules: ${summary.rules}`;
function openOverlay() {
setDraftRules(cloneAttachmentRules(rules));
setOpen(true);
}
function cancelOverlay() {
setOpen(false);
}
function saveOverlay() {
if (disabled) return;
onChange(draftRules);
setOpen(false);
}
const dialog = open ? createPortal(
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="attachment-rules-title">
<div className="modal-panel attachment-rules-modal">
<header className="modal-header">
<h2 id="attachment-rules-title">{title}</h2>
<button className="modal-close" aria-label="Cancel attachment changes" onClick={cancelOverlay}>×</button>
</header>
<div className="modal-body attachment-rules-body">
<AttachmentRulesDataGrid
rules={draftRules}
settings={settings}
campaignId={campaignId}
disabled={disabled}
emptyText={emptyText}
basePaths={basePaths}
zipConfig={zipConfig}
activeChooserRuleIndex={null}
onChange={setDraftRules}
/>
</div>
<footer className="modal-footer">
<Button onClick={cancelOverlay}>Cancel</Button>
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>Save</Button>
</footer>
</div>
</div>,
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)`}>
{label}
</Button>
{dialog}
</>
);
}
function cloneAttachmentRules(rules: AttachmentRule[]): AttachmentRule[] {
return rules.map((rule) => ({
...rule,
...(rule.zip && typeof rule.zip === "object" ? { zip: { ...asRecord(rule.zip) } } : {})
}));
}
export function AttachmentRulesTable({
showAddButton = true,
onChange,
...tableProps
}: AttachmentRulesTableProps) {
function addRule() {
onChange([
...tableProps.rules,
createAttachmentRule(tableProps.basePaths?.[0]?.path ?? "", nextAttachmentLabel(tableProps.rules), tableProps.basePaths?.[0]?.id ?? "")
]);
}
return (
<div className="attachment-rules-editor">
<div className="attachment-rules-main">
<AttachmentRulesDataGrid {...tableProps} onChange={onChange} />
{showAddButton && (
<div className="button-row compact-actions attachment-rules-footer-actions">
<Button variant="primary" onClick={addRule} disabled={tableProps.disabled || (tableProps.basePaths?.length ?? 0) === 0}>Add file</Button>
</div>
)}
</div>
</div>
);
}
export function AttachmentRulesDataGrid({
rules,
settings,
campaignId,
disabled = false,
emptyText = "No attachment files or matching rules configured yet.",
basePaths = [],
id = "attachment-rules",
activeChooserRuleIndex = null,
zipConfig = { enabled: false, archives: [] },
onOpenFileChooser,
onChange
}: AttachmentRulesTableProps) {
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
function patchRule(index: number, patch: Partial<AttachmentRule>) {
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
}
function addRule(afterIndex = rules.length - 1) {
if (disabled || basePaths.length === 0) return;
const nextRule = createAttachmentRule(basePaths[0].path, nextAttachmentLabel(rules), basePaths[0].id);
onChange(insertAfter(rules, afterIndex, nextRule));
}
function removeRule(index: number) {
setFileChooser(null);
onChange(rules.filter((_, currentIndex) => currentIndex !== index));
}
function moveRule(index: number, targetIndex: number) {
if (disabled || index === targetIndex) return;
setFileChooser(null);
onChange(moveArrayItem(rules, index, targetIndex));
}
function openFileChooser(ruleIndex: number) {
if (onOpenFileChooser) {
onOpenFileChooser(ruleIndex);
return;
}
const rule = rules[ruleIndex] ?? {};
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;
if (!basePath) return;
setFileChooser({ ruleIndex, basePath });
}
function selectAttachment(selection: ManagedAttachmentSelection) {
if (!fileChooser) return;
const currentRule = rules[fileChooser.ruleIndex] ?? {};
patchRule(fileChooser.ruleIndex, {
base_path_id: fileChooser.basePath?.id ?? "",
base_dir: fileChooser.basePath?.path ?? (selection.folderPath || "."),
file_filter: selection.fileFilter,
type: selection.selectionType === "file" ? "direct" : "pattern",
include_subdirs: false,
label: getText(currentRule, "label") || `Attachment ${fileChooser.ruleIndex + 1}`
});
setFileChooser(null);
}
return (
<>
<DataGrid
id={id}
rows={rules}
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, 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" />}
className="attachment-rules-table-wrap attachment-rules-table"
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
/>
{fileChooser && (
<ManagedFileChooser
open
settings={settings}
campaignId={campaignId}
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)}`}
onClose={() => setFileChooser(null)}
onSelectAttachment={selectAttachment}
/>
)}
</>
);
}
type AttachmentRuleColumnContext = {
disabled: boolean;
rules: AttachmentRule[];
basePaths: AttachmentBasePath[];
zipConfig: AttachmentZipCollection;
activeChooserRuleIndex: number | null;
patchRule: (index: number, patch: Partial<AttachmentRule>) => void;
addRule: (afterIndex?: number) => void;
moveRule: (index: number, targetIndex: number) => void;
openFileChooser: (ruleIndex: number) => void;
removeRule: (index: number) => void;
};
function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, 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>
)}
{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 ?? "")
},
{
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">
<input
className="chooser-display-input"
value={getText(rule, "file_filter")}
disabled={disabled || basePaths.length === 0}
readOnly
tabIndex={-1}
placeholder="Choose a managed file or pattern"
onClick={() => !disabled && openFileChooser(index)}
onKeyDown={(event) => {
if (!disabled && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
openFileChooser(index);
}
}}
/>
<Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>Choose</Button>
</div>
),
value: (rule) => getText(rule, "file_filter")
},
...(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>
{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"
/>
)
}
];
}

View File

@@ -0,0 +1,160 @@
import { useEffect, useMemo, useState } from "react";
import type { ApiSettings, CampaignListItem } from "../../../types";
import {
getCampaignShares,
getCampaignShareTargets,
revokeCampaignShare,
updateCampaignOwner,
upsertCampaignShare,
type CampaignShare,
type CampaignShareTargets
} from "../../../api/campaigns";
import Button from "../../../components/Button";
import Card from "../../../components/Card";
import ConfirmDialog from "../../../components/ConfirmDialog";
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
import Dialog from "../../../components/Dialog";
import FormField from "../../../components/FormField";
import StatusBadge from "../../../components/StatusBadge";
type TargetType = "user" | "group";
export default function CampaignAccessCard({
settings,
campaign,
onChanged,
onError
}: {
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[]>([]);
const [ownerOpen, setOwnerOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [revokeTarget, setRevokeTarget] = useState<CampaignShare | null>(null);
const [ownerType, setOwnerType] = useState<TargetType>(campaign.owner_group_id ? "group" : "user");
const [ownerId, setOwnerId] = useState(campaign.owner_group_id || campaign.owner_user_id || "");
const [shareType, setShareType] = useState<TargetType>("user");
const [shareTargetId, setShareTargetId] = useState("");
const [sharePermission, setSharePermission] = useState<"read" | "write">("read");
const [busy, setBusy] = useState(false);
async function load() {
try {
const [nextTargets, nextShares] = await Promise.all([
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);
}
}
useEffect(() => {
setOwnerType(campaign.owner_group_id ? "group" : "user");
setOwnerId(campaign.owner_group_id || campaign.owner_user_id || "");
void load();
}, [campaign.id, campaign.owner_group_id, campaign.owner_user_id, settings.accessToken, settings.apiBaseUrl]);
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]);
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";
async function saveOwner() {
if (!ownerId) return;
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 });
setOwnerOpen(false);
await onChanged();
await load();
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
}
async function saveShare() {
if (!shareTargetId) return;
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); }
}
async function revoke() {
if (!revokeTarget) return;
setBusy(true);
try {
await revokeCampaignShare(settings, campaign.id, revokeTarget.id);
setRevokeTarget(null);
await load();
} 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]);
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>
<Dialog open={ownerOpen} 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>
<Dialog open={shareOpen} 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>
<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()} />
</>
);
}

View File

@@ -0,0 +1,36 @@
import { inputValueToFieldValue, normalizeFieldType, valueToInputText } from "../utils/fieldDefinitions";
export type FieldValueInputProps = {
fieldType?: string;
value: unknown;
disabled?: boolean;
placeholder?: string;
className?: string;
onChange: (value: unknown) => void;
};
export default function FieldValueInput({ fieldType = "string", value, disabled = false, placeholder, className, onChange }: FieldValueInputProps) {
const normalizedType = normalizeFieldType(fieldType);
const inputType = inputTypeForField(normalizedType);
const step = normalizedType === "integer" ? "1" : normalizedType === "double" ? "any" : undefined;
return (
<input
className={className}
type={inputType}
step={step}
value={valueToInputText(value, normalizedType)}
disabled={disabled}
placeholder={placeholder}
autoComplete={normalizedType === "password" ? "new-password" : undefined}
onChange={(event) => onChange(inputValueToFieldValue(normalizedType, event.target.value))}
/>
);
}
function inputTypeForField(fieldType: string): string {
if (fieldType === "integer" || fieldType === "double") return "number";
if (fieldType === "date") return "date";
if (fieldType === "password") return "password";
return "text";
}

View File

@@ -0,0 +1,235 @@
import { useState } from "react";
import type { ApiSettings } from "../../../types";
import Button from "../../../components/Button";
import ConfirmDialog from "../../../components/ConfirmDialog";
import DismissibleAlert from "../../../components/DismissibleAlert";
import {
forkCampaignVersion,
lockCampaignVersionPermanently,
unlockCampaignVersionUserLock,
unlockCampaignVersionValidation,
type CampaignVersionDetail,
type CampaignVersionListItem,
} from "../../../api/campaigns";
import {
canUnlockValidationVersion,
formatDateTime,
isFinalLockedVersion,
isHistoricalCampaignVersion,
isPermanentUserLockedVersion,
isTemporaryUserLockedVersion,
} from "../utils/campaignView";
type LockedVersionNoticeProps = {
settings: ApiSettings;
campaignId: string;
version: CampaignVersionDetail | CampaignVersionListItem | null;
currentVersionId?: string | null;
reload: () => Promise<void>;
message?: string;
};
type ConfirmAction = "unlock-validation" | "unlock-user" | "permanent" | null;
export default function LockedVersionNotice({ settings, campaignId, version, currentVersionId, reload, message }: LockedVersionNoticeProps) {
const [busy, setBusy] = useState(false);
const [localError, setLocalError] = useState("");
const [localMessage, setLocalMessage] = useState("");
const [confirmAction, setConfirmAction] = useState<ConfirmAction>(null);
const historicalVersion = isHistoricalCampaignVersion(version, currentVersionId);
const validationLock = !historicalVersion && canUnlockValidationVersion(version);
const temporaryUserLock = !historicalVersion && isTemporaryUserLockedVersion(version);
const permanentUserLock = isPermanentUserLockedVersion(version);
const finalLock = isFinalLockedVersion(version);
const canCreateEditableCopy = !historicalVersion && (permanentUserLock || finalLock);
const presentation = lockPresentation(version, {
historicalVersion,
validationLock,
temporaryUserLock,
permanentUserLock,
finalLock,
});
async function runAction(action: Exclude<ConfirmAction, null>) {
if (!version || busy) return;
setBusy(true);
setLocalError("");
setLocalMessage("");
try {
if (action === "unlock-validation") {
await unlockCampaignVersionValidation(settings, campaignId, version.id);
setLocalMessage("Validation lock removed. Validation, build and generated queue state were invalidated.");
} else if (action === "unlock-user") {
await unlockCampaignVersionUserLock(settings, campaignId, version.id);
setLocalMessage("Temporary user lock removed. The version is editable again.");
} else {
await lockCampaignVersionPermanently(settings, campaignId, version.id);
setLocalMessage("Version permanently locked. Future changes require an editable copy.");
}
await reload();
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function createEditableCopy() {
if (!version || busy) return;
setBusy(true);
setLocalError("");
setLocalMessage("");
try {
const result = await forkCampaignVersion(settings, campaignId, version.id, {
current_flow: "manual",
current_step: version.current_step ?? null,
});
setLocalMessage(`Created editable version #${result.version.version_number}.`);
await reload();
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<DismissibleAlert tone="info" dismissible={false} className={`locked-version-notice lock-kind-${presentation.kind}`}>
<div className="locked-version-copy">
<strong>{presentation.title}</strong>{" "}
<span>{presentation.description}</span>
{message && <span className="locked-version-context"> {message}</span>}
{presentation.info && <span className="locked-version-reason"> {presentation.info}</span>}
{localMessage && <span className="locked-version-feedback"> {localMessage}</span>}
{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"}
</Button>
)}
{temporaryUserLock && (
<>
<Button onClick={() => setConfirmAction("unlock-user")} disabled={!version || busy}>
{busy ? "Working…" : "Unlock"}
</Button>
<Button variant="danger" onClick={() => setConfirmAction("permanent")} disabled={!version || busy}>
Lock permanently
</Button>
</>
)}
{canCreateEditableCopy && (
<Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}>
{busy ? "Creating copy…" : "Create editable copy"}
</Button>
)}
</div>
<ConfirmDialog
open={confirmAction !== null}
title={confirmDialogTitle(confirmAction)}
message={confirmDialogMessage(confirmAction)}
confirmLabel={confirmDialogLabel(confirmAction)}
tone={confirmAction === "unlock-user" ? "default" : "danger"}
busy={busy}
onCancel={() => setConfirmAction(null)}
onConfirm={() => {
const action = confirmAction;
setConfirmAction(null);
if (action) void runAction(action);
}}
/>
</DismissibleAlert>
);
}
type LockFlags = {
historicalVersion: boolean;
validationLock: boolean;
temporaryUserLock: boolean;
permanentUserLock: boolean;
finalLock: boolean;
};
function lockPresentation(
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 ?? "—"}.`,
};
}
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)}.` : "",
};
}
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)}.`
: "",
};
}
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)}.` : "",
};
}
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)}.` : "",
};
}
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)}.` : "",
};
}
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";
}
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.";
}
if (action === "unlock-user") {
return "This removes the reversible user lock. Campaign data and existing workflow results are not otherwise changed.";
}
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 "Continue?";
}
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";
}

View File

@@ -0,0 +1,635 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { ArrowDown, ArrowUp, ArrowUpDown, File, Folder, FolderOpen, Home, Link2, Search } from "lucide-react";
import type { ApiSettings } from "../../../types";
import {
listFileSpaces,
listFiles,
listFolders,
resolveFilePatterns,
shareFileWithCampaign,
type FileFolder,
type FileSpace,
type ManagedFile
} from "../../../api/files";
import Button from "../../../components/Button";
import ConfirmDialog from "../../../components/ConfirmDialog";
import Dialog from "../../../components/Dialog";
import DismissibleAlert from "../../../components/DismissibleAlert";
import { FolderTree } from "../../files/components/FileManagerComponents";
import { useFileTreeState } from "../../files/hooks/useFileTreeState";
import type { FolderNode, SortColumn, SortDirection } from "../../files/types";
import {
buildExplorerEntries,
buildFolderTree,
formatBytes,
formatDate,
normalizeFilePath,
normalizeFolder,
parentFolderPath,
sortExplorerEntries
} from "../../files/utils/fileManagerUtils";
import {
encodeManagedAttachmentSource,
parseManagedAttachmentSource,
type ManagedAttachmentSource
} from "../utils/attachments";
type ManagedFileChooserMode = "folder" | "attachment";
type AttachmentChoiceMode = "file" | "pattern";
type RememberedChooserState = {
spaceId?: string;
folder?: string;
pattern?: string;
};
export type ManagedFolderSelection = {
space: FileSpace;
folderPath: string;
source: string;
};
export type ManagedAttachmentSelection = ManagedFolderSelection & {
fileFilter: string;
matchCount: number;
fileIds: string[];
selectionType: AttachmentChoiceMode;
};
type ManagedFileChooserProps = {
open: boolean;
settings: ApiSettings;
campaignId: string;
mode: ManagedFileChooserMode;
source?: string;
basePath?: string;
initialPattern?: string;
rememberKey?: string;
onClose: () => void;
onSelectFolder?: (selection: ManagedFolderSelection) => void;
onSelectAttachment?: (selection: ManagedAttachmentSelection) => void;
};
export default function ManagedFileChooser({
open,
settings,
campaignId,
mode,
source,
basePath = ".",
initialPattern = "",
rememberKey = mode,
onClose,
onSelectFolder,
onSelectAttachment
}: ManagedFileChooserProps) {
const parsedSource = useMemo(() => parseManagedAttachmentSource(source), [source]);
const normalizedBasePath = normalizeManagedBasePath(basePath);
const storageKey = useMemo(
() => `multimailer.managedFileChooser.${campaignId}.${rememberKey}`,
[campaignId, rememberKey]
);
const remembered = useMemo(() => readRememberedState(storageKey), [storageKey, open]);
const [spaces, setSpaces] = useState<FileSpace[]>([]);
const [selectedSpaceId, setSelectedSpaceId] = useState("");
const [files, setFiles] = useState<ManagedFile[]>([]);
const [folders, setFolders] = useState<FileFolder[]>([]);
const [currentFolder, setCurrentFolder] = useState("");
const [pattern, setPattern] = useState("");
const [patternMatches, setPatternMatches] = useState<ManagedFile[] | null>(null);
const [pendingExactFile, setPendingExactFile] = useState<ManagedFile | null>(null);
const [loading, setLoading] = useState(false);
const [resolving, setResolving] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const selectedSpace = spaces.find((item) => item.id === selectedSpaceId) ?? null;
const sourceLocked = mode === "attachment" && Boolean(parsedSource);
const sourceMatchesSpace = selectedSpace
? !parsedSource || (selectedSpace.owner_type === parsedSource.ownerType && selectedSpace.owner_id === parsedSource.ownerId)
: false;
const effectiveRoot = mode === "attachment" ? normalizedBasePath : "";
const entries = useMemo(
() => buildExplorerEntries(files, folders, currentFolder, false),
[currentFolder, files, folders]
);
const [sortColumn, setSortColumn] = useState<SortColumn>("name");
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
const sortedEntries = useMemo(
() => sortExplorerEntries(entries, sortColumn, sortDirection),
[entries, sortColumn, sortDirection]
);
const breadcrumbs = chooserBreadcrumbs(currentFolder, effectiveRoot);
const allTreeNodes = useMemo(() => buildFolderTree(files, folders), [files, folders]);
const treeNodes = useMemo(() => nodesWithinRoot(allTreeNodes, effectiveRoot), [allTreeNodes, effectiveRoot]);
const { expandedTreeNodes, toggleTreeFolder } = useFileTreeState({
activeSpaceId: selectedSpaceId,
currentFolder,
onOpenFolder: (_spaceId, path) => openFolder(path)
});
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError("");
setPattern(remembered.pattern ?? initialPattern.trim());
setPatternMatches(null);
setPendingExactFile(null);
void listFileSpaces(settings)
.then((response) => {
if (cancelled) return;
setSpaces(response.spaces);
const sourceSpace = response.spaces.find((item) => sourceMatches(item, parsedSource));
const rememberedSpace = response.spaces.find((item) => item.id === remembered.spaceId);
const firstSpace = sourceSpace ?? rememberedSpace ?? response.spaces[0] ?? null;
setSelectedSpaceId(firstSpace?.id ?? "");
})
.catch((reason: unknown) => {
if (!cancelled) setError(errorMessage(reason));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [initialPattern, open, parsedSource?.ownerId, parsedSource?.ownerType, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]);
useEffect(() => {
if (!open || !selectedSpace) return;
let cancelled = false;
setLoading(true);
setError("");
setPatternMatches(null);
const rememberedFolder = selectedSpace.id === remembered.spaceId ? normalizeFolder(remembered.folder || "") : "";
const initialFolder = rememberedFolder && isWithinRoot(rememberedFolder, effectiveRoot) ? rememberedFolder : effectiveRoot;
setCurrentFolder(initialFolder || effectiveRoot);
void Promise.all([
listFiles(settings, { owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id }),
listFolders(settings, { owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id })
])
.then(([fileResponse, folderResponse]) => {
if (cancelled) return;
setFiles(fileResponse.files);
setFolders(folderResponse.folders);
})
.catch((reason: unknown) => {
if (!cancelled) setError(errorMessage(reason));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [effectiveRoot, open, selectedSpace?.id, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]);
useEffect(() => {
if (!open || !selectedSpaceId) return;
writeRememberedState(storageKey, { spaceId: selectedSpaceId, folder: currentFolder, pattern });
}, [currentFolder, open, pattern, selectedSpaceId, storageKey]);
function toggleSort(column: SortColumn) {
if (sortColumn === column) {
setSortDirection((current) => current === "asc" ? "desc" : "asc");
return;
}
setSortColumn(column);
setSortDirection(column === "name" ? "asc" : "desc");
}
function openFolder(path: string) {
const normalized = normalizeFolder(path);
if (mode === "attachment" && effectiveRoot && !isWithinRoot(normalized, effectiveRoot)) return;
setCurrentFolder(normalized);
setPatternMatches(null);
}
async function previewPattern(): Promise<ManagedFile[]> {
if (!selectedSpace) return [];
const trimmed = pattern.trim();
if (!trimmed) {
setPatternMatches([]);
return [];
}
setResolving(true);
setError("");
try {
const response = await resolveFilePatterns(settings, {
patterns: [trimmed],
owner_type: selectedSpace.owner_type,
owner_id: selectedSpace.owner_id,
path_prefix: effectiveRoot || undefined,
include_unmatched: false
});
const matches = response.patterns[0]?.matches ?? [];
setPatternMatches(matches);
return matches;
} catch (reason) {
setError(errorMessage(reason));
return [];
} finally {
setResolving(false);
}
}
function requestExactFile(file: ManagedFile) {
const exactPattern = relativePath(file.display_path, effectiveRoot);
const currentPattern = pattern.trim();
if (currentPattern && currentPattern !== exactPattern) {
setPendingExactFile(file);
return;
}
applyExactFile(file);
}
function applyExactFile(file: ManagedFile) {
setPattern(relativePath(file.display_path, effectiveRoot));
setPatternMatches([file]);
setPendingExactFile(null);
}
async function shareMatches(matches: ManagedFile[]) {
const toShare = matches.filter((file) => !file.shares?.some((share) => (
share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at
)));
await Promise.all(toShare.map((file) => shareFileWithCampaign(settings, file.id, campaignId)));
}
async function confirmSelection() {
if (!selectedSpace || !sourceMatchesSpace) return;
setSubmitting(true);
setError("");
try {
if (mode === "folder") {
onSelectFolder?.({
space: selectedSpace,
folderPath: currentFolder,
source: encodeManagedAttachmentSource(selectedSpace)
});
return;
}
const matches = patternMatches ?? await previewPattern();
await shareMatches(matches);
const trimmedPattern = pattern.trim();
onSelectAttachment?.({
space: selectedSpace,
folderPath: effectiveRoot,
source: encodeManagedAttachmentSource(selectedSpace),
fileFilter: trimmedPattern,
matchCount: matches.length,
fileIds: matches.map((file) => file.id),
selectionType: isExactPattern(trimmedPattern) ? "file" : "pattern"
});
} catch (reason) {
setError(errorMessage(reason));
} finally {
setSubmitting(false);
}
}
if (!open || typeof document === "undefined") return null;
const dialog = (
<>
<Dialog
open
title={mode === "folder" ? "Choose managed attachment source" : "Choose managed file or pattern"}
onClose={onClose}
closeDisabled={submitting}
backdropClassName="managed-file-chooser-backdrop"
className="managed-file-chooser-dialog"
bodyClassName="managed-file-chooser-body"
footerClassName="managed-file-chooser-footer"
footer={(
<>
<Button onClick={onClose} disabled={submitting}>Cancel</Button>
<Button
variant="primary"
onClick={() => void confirmSelection()}
disabled={loading || submitting || !selectedSpace || !sourceMatchesSpace || (mode === "attachment" && (!parsedSource || !pattern.trim()))}
>
{submitting ? "Linking…" : mode === "folder" ? "Use this folder" : "Use pattern"}
</Button>
</>
)}
>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{mode === "attachment" && !parsedSource && (
<DismissibleAlert tone="warning" dismissible={false}>
This attachment base path is not connected to a managed file space. Choose its folder under <strong>Attachments Attachment sources</strong> first.
</DismissibleAlert>
)}
{mode === "attachment" && parsedSource && !sourceMatchesSpace && !loading && (
<DismissibleAlert tone="danger" dismissible={false}>The configured managed file space is no longer available to this user.</DismissibleAlert>
)}
<div className="managed-file-chooser-layout" aria-busy={loading}>
<aside className="managed-file-chooser-spaces file-tree-panel" aria-label="File spaces and folders">
<div className="file-tree-heading">Spaces</div>
<div className="file-tree-list">
{spaces.map((space) => {
const selected = space.id === selectedSpaceId;
const lockedOut = sourceLocked && !sourceMatches(space, parsedSource);
return (
<div className="file-tree-space" key={space.id}>
<button
type="button"
className={`file-tree-node file-tree-root ${selected && currentFolder === effectiveRoot ? "is-active" : ""}`}
onClick={() => {
if (!selected) setSelectedSpaceId(space.id);
else openFolder(effectiveRoot);
}}
disabled={loading || lockedOut}
>
{space.owner_type === "group" ? <FolderOpen size={15} aria-hidden="true" /> : <Home size={15} aria-hidden="true" />}
<span>{space.label}</span>
</button>
{selected && (
<FolderTree
nodes={treeNodes}
activeSpaceId={selectedSpaceId}
spaceId={space.id}
currentFolder={currentFolder}
expandedKeys={expandedTreeNodes}
onOpen={(_spaceId, path) => openFolder(path)}
onToggle={toggleTreeFolder}
dragDropEnabled={false}
contextMenuEnabled={false}
disabled={loading}
/>
)}
</div>
);
})}
{!loading && spaces.length === 0 && <p className="muted small-note">No accessible file spaces were found.</p>}
</div>
</aside>
<section className={`managed-file-chooser-browser ${mode === "folder" ? "folder-mode" : "attachment-mode"}`}>
<div className="managed-file-chooser-toolbar">
<div className="managed-file-breadcrumb" aria-label="Current folder">
<button type="button" onClick={() => openFolder(effectiveRoot)} disabled={loading}>
<Home size={14} aria-hidden="true" /> {selectedSpace?.label || "Files"}
</button>
{breadcrumbs.map((crumb) => (
<span key={crumb.path}>
<span aria-hidden="true">/</span>
<button type="button" onClick={() => openFolder(crumb.path)} disabled={loading}>{crumb.name}</button>
</span>
))}
</div>
</div>
{mode === "attachment" && (
<div className="managed-pattern-editor">
<label>
<span>File or pattern relative to <code>{effectiveRoot || "."}</code></span>
<div className="field-with-action split-field-action">
<input
value={pattern}
onChange={(event) => { setPattern(event.target.value); setPatternMatches(null); }}
onKeyDown={(event) => { if (event.key === "Enter") void previewPattern(); }}
placeholder="folder/file.pdf or **/*.pdf"
/>
<Button onClick={() => void previewPattern()} disabled={resolving || !pattern.trim()}>
<Search size={15} aria-hidden="true" /> {resolving ? "Checking…" : "Preview"}
</Button>
</div>
</label>
{patternMatches !== null && (
<div className="managed-pattern-summary">
<span>{patternMatches.length} current file{patternMatches.length === 1 ? "" : "s"} match.</span>
<Button variant="ghost" onClick={() => setPatternMatches(null)}>Back to folder</Button>
</div>
)}
</div>
)}
{patternMatches !== null && mode === "attachment" ? (
<PatternResultList files={patternMatches} campaignId={campaignId} onChoose={requestExactFile} />
) : (
<div className="managed-file-entry-table">
<div className="managed-file-entry-head" role="row">
<span aria-hidden="true" />
<ChooserSortButton column="name" label="Name" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
<ChooserSortButton column="size" label="Size" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
<ChooserSortButton column="modified" label="Modified" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
</div>
<div className="managed-file-entry-list" role="listbox" aria-label={mode === "folder" ? "Folders and files" : "Managed files"}>
<button
type="button"
className="managed-file-entry is-folder is-parent"
onClick={() => openFolder(parentWithinRoot(currentFolder, effectiveRoot))}
disabled={loading || currentFolder === effectiveRoot}
>
<FolderOpen size={18} aria-hidden="true" />
<span className="managed-file-entry-name"><strong>..</strong><small>Parent folder</small></span>
<span className="managed-file-entry-size"></span>
<span className="managed-file-entry-modified"></span>
</button>
{sortedEntries.map((entry) => {
if (entry.kind === "folder") {
return (
<button type="button" key={entry.id} className="managed-file-entry is-folder" onClick={() => openFolder(entry.path)}>
<Folder size={18} aria-hidden="true" />
<span className="managed-file-entry-name"><strong>{entry.name}</strong><small>{entry.fileCount} file(s), {entry.folderCount} folder(s)</small></span>
<span className="managed-file-entry-size">{formatBytes(entry.totalSize)}</span>
<span className="managed-file-entry-modified">{formatDate(entry.updatedAt)}</span>
</button>
);
}
const exactPattern = relativePath(entry.file.display_path, effectiveRoot);
const selected = mode === "attachment" && pattern.trim() === exactPattern;
const campaignShared = isSharedWithCampaign(entry.file, campaignId);
return (
<button
type="button"
key={entry.file.id}
className={`managed-file-entry is-file ${selected ? "is-selected" : ""} ${mode === "folder" ? "is-context-only" : ""}`}
onClick={() => mode === "attachment" ? requestExactFile(entry.file) : undefined}
disabled={mode === "folder"}
aria-label={mode === "folder" ? `${entry.file.filename}, file shown for context` : entry.file.filename}
>
<File size={18} aria-hidden="true" />
<span className="managed-file-entry-name">
<strong>{entry.file.filename}</strong>
<small>{campaignShared ? <><Link2 size={12} aria-hidden="true" /> linked to campaign</> : "File"}</small>
</span>
<span className="managed-file-entry-size">{formatBytes(entry.file.size_bytes)}</span>
<span className="managed-file-entry-modified">{formatDate(entry.file.updated_at)}</span>
</button>
);
})}
{!loading && sortedEntries.length === 0 && (
<p className="managed-file-empty muted">This folder is empty. Upload files in the top-level Files module.</p>
)}
</div>
</div>
)}
{mode === "folder" && (
<p className="muted small-note managed-file-chooser-note">Choose the folder that should act as this campaign attachment source.</p>
)}
{mode === "attachment" && (
<p className="muted small-note managed-file-chooser-note">Clicking a file sets its exact relative path as the pattern. Preview resolves the pattern against the current managed file space.</p>
)}
</section>
</div>
</Dialog>
<ConfirmDialog
open={Boolean(pendingExactFile)}
title="Replace the current pattern?"
message={pendingExactFile ? `Replace “${pattern.trim()}” with the exact file “${relativePath(pendingExactFile.display_path, effectiveRoot)}”?` : "Replace the current pattern?"}
confirmLabel="Replace pattern"
onConfirm={() => pendingExactFile && applyExactFile(pendingExactFile)}
onCancel={() => setPendingExactFile(null)}
/>
</>
);
return createPortal(dialog, document.body);
}
function ChooserSortButton({
column,
label,
activeColumn,
direction,
onSort,
}: {
column: SortColumn;
label: string;
activeColumn: SortColumn;
direction: SortDirection;
onSort: (column: SortColumn) => void;
}) {
const active = activeColumn === column;
const Icon = active ? (direction === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown;
return (
<button
type="button"
className={active ? "is-sorted" : ""}
aria-label={`${label}: ${active ? `sorted ${direction === "asc" ? "ascending" : "descending"}` : "not sorted"}. Activate to sort.`}
onClick={() => onSort(column)}
>
<span>{label}</span><Icon size={14} aria-hidden="true" />
</button>
);
}
function PatternResultList({ files, campaignId, onChoose }: { files: ManagedFile[]; campaignId: string; onChoose: (file: ManagedFile) => void }) {
return (
<div className="managed-pattern-results" role="table" aria-label="Pattern preview results">
<div className="file-list-table-head" role="row">
<span>Name</span><span>Size</span><span>Modified</span>
</div>
<div className="file-list-table">
{files.length === 0 && <div className="file-list-empty">No current files match this pattern.</div>}
{files.map((file) => (
<button type="button" className="file-list-row file-row managed-pattern-result-row" role="row" key={file.id} onClick={() => onChoose(file)}>
<span className="file-list-name-cell">
<span className="file-list-name">
<File className="file-row-icon" size={20} aria-hidden="true" />
<span><strong>{file.display_path}</strong>{isSharedWithCampaign(file, campaignId) && <small>Linked to campaign</small>}</span>
</span>
</span>
<span>{formatBytes(file.size_bytes)}</span>
<span className="file-row-tail"><span>{formatDate(file.updated_at)}</span></span>
</button>
))}
</div>
</div>
);
}
function sourceMatches(space: FileSpace, source: ManagedAttachmentSource | null): boolean {
return Boolean(source && space.owner_type === source.ownerType && space.owner_id === source.ownerId);
}
function normalizeManagedBasePath(value: string): string {
const normalized = normalizeFolder(value);
return normalized === "." ? "" : normalized;
}
function relativePath(path: string, root: string): string {
const normalizedPath = normalizeFilePath(path);
const normalizedRoot = normalizeFolder(root);
if (!normalizedRoot) return normalizedPath;
const prefix = `${normalizedRoot}/`;
return normalizedPath.startsWith(prefix) ? normalizedPath.slice(prefix.length) : normalizedPath;
}
function chooserBreadcrumbs(folder: string, root: string): { name: string; path: string }[] {
const normalizedFolder = normalizeFolder(folder);
const normalizedRoot = normalizeFolder(root);
const relative = normalizedRoot && normalizedFolder.startsWith(`${normalizedRoot}/`)
? normalizedFolder.slice(normalizedRoot.length + 1)
: normalizedRoot === normalizedFolder ? "" : normalizedFolder;
const parts = relative.split("/").filter(Boolean);
return parts.map((name, index) => ({
name,
path: normalizeFolder([normalizedRoot, ...parts.slice(0, index + 1)].filter(Boolean).join("/"))
}));
}
function parentWithinRoot(folder: string, root: string): string {
const normalizedFolder = normalizeFolder(folder);
const normalizedRoot = normalizeFolder(root);
if (!normalizedFolder || normalizedFolder === normalizedRoot) return normalizedRoot;
const parent = parentFolderPath(normalizedFolder);
return isWithinRoot(parent, normalizedRoot) ? parent : normalizedRoot;
}
function isWithinRoot(path: string, root: string): boolean {
const normalizedPath = normalizeFolder(path);
const normalizedRoot = normalizeFolder(root);
return !normalizedRoot || normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
}
function nodesWithinRoot(nodes: FolderNode[], root: string): FolderNode[] {
const normalizedRoot = normalizeFolder(root);
if (!normalizedRoot) return nodes;
const node = findNode(nodes, normalizedRoot);
return node?.children ?? [];
}
function findNode(nodes: FolderNode[], path: string): FolderNode | null {
for (const node of nodes) {
if (node.path === path) return node;
const child = findNode(node.children, path);
if (child) return child;
}
return null;
}
function isExactPattern(value: string): boolean {
return Boolean(value) && !/[?*\[\]{}]/.test(value);
}
function isSharedWithCampaign(file: ManagedFile, campaignId: string): boolean {
return Boolean(file.shares?.some((share) => share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at));
}
function readRememberedState(key: string): RememberedChooserState {
if (typeof window === "undefined") return {};
try {
const parsed = JSON.parse(window.localStorage.getItem(key) || "{}") as RememberedChooserState;
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function writeRememberedState(key: string, state: RememberedChooserState) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, JSON.stringify(state));
} catch {
// Storage is optional; chooser behavior remains functional without it.
}
}
function errorMessage(reason: unknown): string {
return reason instanceof Error ? reason.message : String(reason);
}

View File

@@ -0,0 +1,165 @@
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
import Button from "../../../components/Button";
export type MessagePreviewAttachment = {
filename?: string | null;
label?: string | null;
detail?: string | null;
contentType?: string | null;
sizeBytes?: number | null;
archiveGroup?: string | null;
archiveLabel?: string | null;
};
export type MessagePreviewMetaItem = {
label: string;
value: React.ReactNode;
};
export type MessagePreviewNavigation = {
index: number;
total: number;
onFirst: () => void;
onPrevious: () => void;
onNext: () => void;
onLast: () => void;
};
export type MessagePreviewOverlayProps = {
title?: string;
subject?: string | null;
bodyMode?: "text" | "html";
text?: string | null;
html?: string | null;
recipientLabel?: React.ReactNode;
recipientNote?: React.ReactNode;
metaItems?: MessagePreviewMetaItem[];
attachments?: MessagePreviewAttachment[];
raw?: string | null;
rawLabel?: string;
navigation?: MessagePreviewNavigation;
closeLabel?: string;
onClose: () => void;
};
export default function MessagePreviewOverlay({
title = "Message preview",
subject,
bodyMode = "text",
text,
html,
recipientLabel,
recipientNote,
metaItems = [],
attachments = [],
raw,
rawLabel = "Raw MIME",
navigation,
closeLabel = "Close",
onClose
}: MessagePreviewOverlayProps) {
const shownSubject = subject?.trim() || "No subject";
const shownText = text?.trim() || "No plain-text body to preview.";
const shownHtml = html?.trim() || "<p>No HTML body to preview.</p>";
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="message-preview-title">
<div className="modal-panel template-preview-modal message-preview-modal">
<header className="modal-header">
<h2 id="message-preview-title">{title}</h2>
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body">
{(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>
<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>
</div>
)}
</div>
)}
{metaItems.length > 0 && (
<div className="detail-grid message-preview-meta">
{metaItems.map((item) => (
<div key={item.label}><span className="muted small-note">{item.label}</span><strong>{item.value || "—"}</strong></div>
))}
</div>
)}
<div className="template-preview-box">
<h3>{shownSubject}</h3>
{bodyMode === "html" ? (
<iframe className="template-preview-frame" title="Rendered HTML body preview" sandbox="" srcDoc={shownHtml} />
) : (
<pre>{shownText}</pre>
)}
</div>
<MessagePreviewAttachmentBoxes attachments={attachments} />
{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>
);
}
function MessagePreviewAttachmentBoxes({ attachments }: { attachments: MessagePreviewAttachment[] }) {
const direct = attachments.filter((attachment) => !attachment.archiveGroup);
const archiveGroups = new Map<string, MessagePreviewAttachment[]>();
for (const attachment of attachments) {
if (!attachment.archiveGroup) continue;
const group = archiveGroups.get(attachment.archiveGroup) ?? [];
group.push(attachment);
archiveGroups.set(attachment.archiveGroup, group);
}
function attachmentChip(attachment: MessagePreviewAttachment, index: number) {
const filename = attachment.filename?.trim() || attachment.label?.trim() || "Unnamed attachment";
const details = [attachment.detail, attachment.contentType, attachment.sizeBytes ? `${attachment.sizeBytes} bytes` : ""].filter(Boolean).join(" · ");
return (
<div className="attachment-file-chip" key={`${filename}:${index}`}>
<strong>{filename}</strong>
{details && <span>{details}</span>}
</div>
);
}
return (
<div className="template-preview-attachments message-preview-attachments">
<h3>Attachments</h3>
{attachments.length === 0 ? (
<p className="muted small-note">No attachments are effective for this message.</p>
) : (
<div className="message-preview-attachment-layout">
{direct.length > 0 && <div className="attachment-chip-grid">{direct.map(attachmentChip)}</div>}
{[...archiveGroups.entries()].map(([groupName, items]) => (
<section className="attachment-zip-group" key={groupName}>
<header>
<strong>{items[0]?.archiveLabel || groupName}</strong>
<span>{items.length} file{items.length === 1 ? "" : "s"} inside ZIP</span>
</header>
<div className="attachment-chip-grid">{items.map(attachmentChip)}</div>
</section>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,177 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import Button from "../../../components/Button";
import Dialog from "../../../components/Dialog";
import DismissibleAlert from "../../../components/DismissibleAlert";
import {
buildUndefinedPlaceholders,
extractTemplatePlaceholders,
removePlaceholderFromText,
type TemplateNamespace,
type UndefinedPlaceholder
} from "../utils/templatePlaceholders";
import {
TemplateFieldChipList,
UndefinedPlaceholderDecisionDialog,
UndefinedPlaceholderList,
type TemplateFieldOption
} from "./TemplatePlaceholderControls";
export default function TemplateExpressionEditorDialog({
open,
title,
value,
localFields,
globalFields,
placeholder,
description,
saveLabel = "Save",
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;
}) {
const [draftValue, setDraftValue] = useState(value);
const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!open) return;
setDraftValue(value);
setUndefinedDialog(null);
}, [open, value]);
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(draftValue), [draftValue]);
const localNames = useMemo(() => new Set(localFields.map((field) => field.name)), [localFields]);
const globalNames = useMemo(() => new Set(globalFields.map((field) => field.name)), [globalFields]);
const availableNames = useMemo(() => new Set([...localNames, ...globalNames]), [globalNames, localNames]);
const undefinedPlaceholders = useMemo(
() => buildUndefinedPlaceholders(usedPlaceholders, availableNames, { local: localNames, global: globalNames }),
[availableNames, globalNames, localNames, usedPlaceholders]
);
const validationMessage = validate?.(draftValue) ?? "";
function insertPlaceholder(namespace: TemplateNamespace, name: string) {
const input = inputRef.current;
const token = `{{${namespace}:${name}}}`;
const defaultPosition = draftValue.toLocaleLowerCase().endsWith(".zip") ? draftValue.length - 4 : draftValue.length;
const start = input?.selectionStart ?? defaultPosition;
const end = input?.selectionEnd ?? start;
setDraftValue(`${draftValue.slice(0, start)}${token}${draftValue.slice(end)}`);
window.requestAnimationFrame(() => {
input?.focus();
const cursor = start + token.length;
input?.setSelectionRange(cursor, cursor);
});
}
function requestSave() {
if (validationMessage) return;
if (undefinedPlaceholders.length > 0) {
setUndefinedDialog(undefinedPlaceholders[0]);
return;
}
onSave(draftValue.trim());
}
function removeUndefined(field: UndefinedPlaceholder) {
setDraftValue((current) => removePlaceholderFromText(current, field.raw));
setUndefinedDialog(null);
}
function addUndefined(field: UndefinedPlaceholder) {
if (!field.name || field.reason === "invalid-namespace") return;
onAddField(field.name);
setUndefinedDialog(null);
}
if (!open || typeof document === "undefined") return null;
return createPortal(
<>
<Dialog
open={open && !undefinedDialog}
title={title}
closeLabel="Cancel filename changes"
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>
<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>
<input
ref={inputRef}
value={draftValue}
placeholder={placeholder}
aria-invalid={Boolean(validationMessage) || undefined}
onChange={(event) => setDraftValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
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>
<TemplateFieldChipList
namespace="local"
fields={localFields}
usedPlaceholders={usedPlaceholders}
empty="No recipient fields are available."
onInsert={insertPlaceholder}
/>
<h3 className="section-mini-heading">Campaign fields</h3>
<TemplateFieldChipList
namespace="global"
fields={globalFields}
usedPlaceholders={usedPlaceholders}
empty="No campaign fields or global values are available."
onInsert={insertPlaceholder}
/>
<h3 className="section-mini-heading">Used, but undefined</h3>
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
</Dialog>
<UndefinedPlaceholderDecisionDialog
field={undefinedDialog}
contextLabel="archive filename"
removeLabel="Remove from filename"
onCancel={() => setUndefinedDialog(null)}
onRemove={removeUndefined}
onAddField={addUndefined}
addDisabled={Boolean(undefinedDialog?.name) && !canAddField(undefinedDialog?.name ?? "")}
/>
</>,
document.body
);
}

View File

@@ -0,0 +1,118 @@
import Button from "../../../components/Button";
import Dialog from "../../../components/Dialog";
import DismissibleAlert from "../../../components/DismissibleAlert";
import type { TemplateNamespace, TemplatePlaceholder, UndefinedPlaceholder } from "../utils/templatePlaceholders";
export type TemplateFieldOption = {
name: string;
label: string;
};
export function TemplateFieldChipList({
namespace,
fields,
usedPlaceholders = [],
empty,
disabled = false,
onInsert
}: {
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">
{fields.map((field) => {
const used = usedPlaceholders.some((placeholder) => placeholder.validNamespace && placeholder.namespace === namespace && placeholder.name === field.name);
return (
<button
type="button"
className={`field-chip field-chip-button ${used ? "used" : ""}`}
key={`${namespace}:${field.name}`}
disabled={disabled}
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>
);
})}
</div>
);
}
export function UndefinedPlaceholderList({
items,
empty = "No undefined placeholders detected.",
onSelect
}: {
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)}
>
{item.display}
</button>
))}
</div>
);
}
export function UndefinedPlaceholderDecisionDialog({
field,
contextLabel = "template",
removeLabel = "Remove placeholder",
onCancel,
onRemove,
onAddField,
addDisabled = false
}: {
field: UndefinedPlaceholder | null;
contextLabel?: string;
removeLabel?: string;
onCancel: () => void;
onRemove: (field: UndefinedPlaceholder) => void;
onAddField: (field: UndefinedPlaceholder) => void;
addDisabled?: boolean;
}) {
return (
<Dialog
open={Boolean(field)}
title="Undefined field reference"
closeLabel="Return to editing"
className="template-action-dialog"
onClose={onCancel}
footer={field ? (
<>
<Button onClick={onCancel}>Continue editing</Button>
<Button onClick={() => onRemove(field)}>{removeLabel}</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>
)}
</>
)}
</Dialog>
);
}

View File

@@ -0,0 +1,127 @@
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
import { useCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
import { formatDateTime } from "../utils/campaignView";
type VersionLineProps = {
version: CampaignVersionDetail | CampaignVersionListItem | null;
versions?: CampaignVersionListItem[];
status?: string;
loadedAt?: string | null;
};
export default function VersionLine({ version, versions = [], status, loadedAt }: VersionLineProps) {
const location = useLocation();
const navigate = useNavigate();
const { requestNavigation } = useCampaignUnsavedChanges();
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;
const previous = currentIndex > 0 ? sorted[currentIndex - 1] : null;
const next = currentIndex >= 0 && currentIndex < sorted.length - 1 ? sorted[currentIndex + 1] : null;
const latest = currentIndex >= 0 && currentIndex < sorted.length - 1 ? sorted[sorted.length - 1] : null;
const suffix = status ?? `Loaded ${formatDateTime(loadedAt ?? version?.updated_at)}`;
function openVersion(target: CampaignVersionListItem) {
const targetUrl = versionTarget(location.pathname, location.search, target.id);
requestNavigation(() => navigate(targetUrl));
}
return (
<p className="mono-small version-line">
<VersionArrow
icon="first"
target={first}
fallbackLabel="Already at first version"
onOpen={openVersion}
/>
<VersionArrow
icon="previous"
target={previous}
fallbackLabel="No older version"
onOpen={openVersion}
/>
<span>Version {version ? `#${version.version_number}` : "—"}</span>
<VersionArrow
icon="next"
target={next}
fallbackLabel="No newer version"
onOpen={openVersion}
/>
<VersionArrow
icon="latest"
target={latest}
fallbackLabel="Already at latest version"
onOpen={openVersion}
/>
<span className="version-line-separator">·</span>
<span>{suffix}</span>
</p>
);
}
type VersionArrowProps = {
icon: "first" | "previous" | "next" | "latest";
target: CampaignVersionListItem | null;
fallbackLabel: string;
onOpen: (target: CampaignVersionListItem) => void;
};
function VersionArrow({ icon, target, fallbackLabel, onOpen }: VersionArrowProps) {
const Icon = getVersionIcon(icon);
const actionLabel = getVersionActionLabel(icon, target);
if (!target) {
return (
<span className="version-arrow disabled" title={fallbackLabel} aria-label={fallbackLabel}>
<Icon aria-hidden="true" />
</span>
);
}
return (
<button
type="button"
className="version-arrow"
onClick={() => onOpen(target)}
title={actionLabel}
aria-label={actionLabel}
>
<Icon aria-hidden="true" />
</button>
);
}
function getVersionIcon(icon: VersionArrowProps["icon"]) {
switch (icon) {
case "first":
return ArrowBigLeftDash;
case "previous":
return ArrowBigLeft;
case "next":
return ArrowBigRight;
case "latest":
return ArrowBigRightDash;
}
}
function getVersionActionLabel(icon: VersionArrowProps["icon"], target: CampaignVersionListItem | null): string {
const versionLabel = target ? `version #${target.version_number}` : "version";
switch (icon) {
case "first":
return `Open first ${versionLabel}`;
case "previous":
return `Open previous ${versionLabel}`;
case "next":
return `Open next ${versionLabel}`;
case "latest":
return `Open latest ${versionLabel}`;
}
}
function versionTarget(pathname: string, search: string, versionId: string): string {
const params = new URLSearchParams(search);
params.set("version", versionId);
return `${pathname}?${params.toString()}`;
}

View File

@@ -0,0 +1,178 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import Button from "../../../components/Button";
import DismissibleAlert from "../../../components/DismissibleAlert";
type NavigationAction = () => void;
type UnsavedChangesRegistration = {
title?: string;
message?: string;
onSave: () => boolean | Promise<boolean>;
onDiscard?: () => void;
};
type UnsavedChangesContextValue = {
hasUnsavedChanges: boolean;
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
requestNavigation: (action: NavigationAction) => void;
};
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
export function CampaignUnsavedChangesProvider({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const [registration, setRegistration] = useState<UnsavedChangesRegistration | null>(null);
const [pendingAction, setPendingAction] = useState<NavigationAction | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const registrationRef = useRef<UnsavedChangesRegistration | null>(null);
useEffect(() => {
registrationRef.current = registration;
}, [registration]);
const hasUnsavedChanges = Boolean(registration);
const registerUnsavedChanges = useCallback((next: UnsavedChangesRegistration | null) => {
setRegistration(next);
return () => {
setRegistration((current) => current === next ? null : current);
};
}, []);
const proceed = useCallback((action: NavigationAction) => {
setPendingAction(null);
setSaveError("");
action();
}, []);
const requestNavigation = useCallback((action: NavigationAction) => {
const active = registrationRef.current;
if (!active) {
action();
return;
}
setSaveError("");
setPendingAction(() => action);
}, []);
useEffect(() => {
function onBeforeUnload(event: BeforeUnloadEvent) {
if (!registrationRef.current) return;
event.preventDefault();
event.returnValue = "";
}
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, []);
useEffect(() => {
function onDocumentClick(event: MouseEvent) {
if (!registrationRef.current) return;
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
const target = event.target as Element | null;
const anchor = target?.closest?.("a[href]") as HTMLAnchorElement | null;
if (!anchor) return;
if (anchor.target && anchor.target !== "_self") return;
if (anchor.hasAttribute("download")) return;
if (anchor.getAttribute("href")?.startsWith("#")) return;
const destination = new URL(anchor.href, window.location.href);
const current = new URL(window.location.href);
if (destination.href === current.href) return;
event.preventDefault();
event.stopPropagation();
requestNavigation(() => {
if (destination.origin === current.origin) {
navigate(`${destination.pathname}${destination.search}${destination.hash}`);
} else {
window.location.assign(destination.href);
}
});
}
document.addEventListener("click", onDocumentClick, true);
return () => document.removeEventListener("click", onDocumentClick, true);
}, [navigate, requestNavigation]);
async function handleSaveAndLeave() {
const action = pendingAction;
const active = registrationRef.current;
if (!action || !active) return;
setSaving(true);
setSaveError("");
try {
const ok = await active.onSave();
if (!ok) {
setSaveError("The changes could not be saved. Please review the page message and try again.");
return;
}
proceed(action);
} catch (err) {
setSaveError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
function handleDiscardAndLeave() {
const action = pendingAction;
const active = registrationRef.current;
if (!action) return;
active?.onDiscard?.();
proceed(action);
}
const value = useMemo<UnsavedChangesContextValue>(() => ({
hasUnsavedChanges,
registerUnsavedChanges,
requestNavigation
}), [hasUnsavedChanges, registerUnsavedChanges, requestNavigation]);
return (
<UnsavedChangesContext.Provider value={value}>
{children}
{pendingAction && registration && (
<div className="overlay-backdrop" role="dialog" aria-modal="true">
<div className="modal-panel unsaved-changes-dialog">
<header className="modal-header">
<h2>{registration.title ?? "Unsaved campaign changes"}</h2>
<button className="modal-close" onClick={() => setPendingAction(null)} disabled={saving}>×</button>
</header>
<div className="modal-body">
<p>{registration.message ?? "This campaign page has unsaved changes. Save them before leaving, or discard the changes and continue."}</p>
{saveError && <DismissibleAlert tone="danger" resetKey={saveError}>{saveError}</DismissibleAlert>}
</div>
<footer className="modal-footer unsaved-changes-actions">
<Button onClick={() => setPendingAction(null)} disabled={saving}>Cancel</Button>
<Button onClick={handleDiscardAndLeave} disabled={saving}>Discard</Button>
<Button variant="primary" onClick={handleSaveAndLeave} disabled={saving}>{saving ? "Saving…" : "Save and leave"}</Button>
</footer>
</div>
</div>
)}
</UnsavedChangesContext.Provider>
);
}
export function useCampaignUnsavedChanges() {
const context = useContext(UnsavedChangesContext);
if (!context) {
throw new Error("useCampaignUnsavedChanges must be used inside CampaignUnsavedChangesProvider");
}
return context;
}
export function useRegisterCampaignUnsavedChanges(registration: UnsavedChangesRegistration | null) {
const { registerUnsavedChanges } = useCampaignUnsavedChanges();
useEffect(() => {
return registerUnsavedChanges(registration);
}, [registerUnsavedChanges, registration]);
}

View File

@@ -0,0 +1,143 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ApiSettings } from "../../../types";
import { autosaveCampaignVersion, type CampaignVersionDetail, type CampaignVersionUpdatePayload } from "../../../api/campaigns";
import { formatDateTime, getCampaignJson } from "../utils/campaignView";
import { ensureCampaignDraft, updateNested } from "../utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
type StepValue = string | (() => string | null | undefined);
type UseCampaignDraftEditorOptions = {
settings: ApiSettings;
campaignId: string;
version: CampaignVersionDetail | null;
locked: boolean;
reload: () => Promise<void>;
setError: (message: string) => void;
currentStep: StepValue;
currentFlow?: string;
workflowState?: string;
isComplete?: boolean;
unsavedTitle: string;
unsavedMessage: string;
loadedLabel?: (version: CampaignVersionDetail) => string;
transformLoadedDraft?: (version: CampaignVersionDetail, draft: Record<string, unknown>) => Record<string, unknown>;
extraPayload?: () => Partial<CampaignVersionUpdatePayload>;
onLoaded?: (version: CampaignVersionDetail, draft: Record<string, unknown>) => void;
onSaved?: (version: CampaignVersionDetail) => void;
};
function resolveStep(value: StepValue): string | null | undefined {
return typeof value === "function" ? value() : value;
}
function defaultLoadedLabel(version: CampaignVersionDetail): string {
return version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded";
}
export function useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep,
currentFlow = "manual",
workflowState = "editing",
isComplete = false,
unsavedTitle,
unsavedMessage,
loadedLabel = defaultLoadedLabel,
transformLoadedDraft,
extraPayload,
onLoaded,
onSaved
}: UseCampaignDraftEditorOptions) {
const loadedLabelRef = useRef(loadedLabel);
const transformLoadedDraftRef = useRef(transformLoadedDraft);
const onLoadedRef = useRef(onLoaded);
useEffect(() => {
loadedLabelRef.current = loadedLabel;
transformLoadedDraftRef.current = transformLoadedDraft;
onLoadedRef.current = onLoaded;
}, [loadedLabel, transformLoadedDraft, onLoaded]);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
useEffect(() => {
if (!version) return;
const initialDraft = ensureCampaignDraft(version);
const loadedDraft = transformLoadedDraftRef.current?.(version, initialDraft) ?? initialDraft;
setDraft(loadedDraft);
setDirty(false);
setLocalError("");
setSaveState(loadedLabelRef.current(version));
onLoadedRef.current?.(version, loadedDraft);
}, [version]);
const markDirty = useCallback(() => {
setDirty(true);
setLocalError("");
}, []);
const patch = useCallback((path: string[], value: unknown) => {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
markDirty();
}, [locked, markDirty]);
const saveDraft = useCallback(async (_mode: "auto" | "manual" = "manual"): Promise<boolean> => {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: currentFlow,
current_step: resolveStep(currentStep),
workflow_state: workflowState,
is_complete: isComplete,
...(extraPayload?.() ?? {})
});
setDraft(getCampaignJson(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
onSaved?.(saved);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
return false;
}
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, setError, settings, version, workflowState]);
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: unsavedTitle,
message: unsavedMessage,
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
return {
draft,
setDraft,
displayDraft: draft ?? ensureCampaignDraft(null),
dirty,
setDirty,
saveState,
setSaveState,
localError,
setLocalError,
patch,
markDirty,
saveDraft
};
}

View File

@@ -0,0 +1,80 @@
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import type { ApiSettings } from "../../../types";
import {
getCampaign,
getCampaignSummary,
getCampaignVersion,
listCampaignVersions,
type CampaignSummary,
type CampaignVersionDetail
} from "../../../api/campaigns";
import type { CampaignWorkspaceData } from "../utils/campaignView";
const initialData: CampaignWorkspaceData = {
campaign: null,
versions: [],
currentVersion: null,
summary: null
};
type UseCampaignWorkspaceDataOptions = {
includeCurrentVersion?: boolean;
includeSummary?: boolean;
includeVersions?: boolean;
};
export function useCampaignWorkspaceData(
settings: ApiSettings,
campaignId: string,
options: UseCampaignWorkspaceDataOptions = {}
) {
const {
includeCurrentVersion = true,
includeSummary = false,
includeVersions = false
} = options;
const [searchParams] = useSearchParams();
const selectedVersionId = searchParams.get("version");
const [data, setData] = useState<CampaignWorkspaceData>(initialData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const reload = useCallback(async () => {
if (!campaignId) return;
setLoading(true);
setError("");
try {
const needsCampaign = includeCurrentVersion || includeVersions;
const shouldLoadVersions = includeCurrentVersion || includeVersions;
const [campaign, versions] = await Promise.all([
needsCampaign ? getCampaign(settings, campaignId) : Promise.resolve(null),
shouldLoadVersions ? listCampaignVersions(settings, campaignId) : Promise.resolve([]),
]);
const wantedVersionId = selectedVersionId || campaign?.current_version_id || versions[0]?.id;
const [versionResult, summaryResult] = await Promise.allSettled([
includeCurrentVersion && wantedVersionId ? getCampaignVersion(settings, campaignId, wantedVersionId) : Promise.resolve(null),
includeSummary ? getCampaignSummary(settings, campaignId, wantedVersionId) : Promise.resolve(null)
]);
setData({
campaign,
versions,
currentVersion: versionResult.status === "fulfilled" ? (versionResult.value as CampaignVersionDetail | null) : null,
summary: summaryResult.status === "fulfilled" ? (summaryResult.value as CampaignSummary | null) : null
});
} catch (err) {
setData(initialData);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [settings, campaignId, includeCurrentVersion, includeSummary, includeVersions, selectedVersionId]);
useEffect(() => {
reload();
}, [reload]);
return { data, loading, error, reload, setError };
}

View File

@@ -0,0 +1,273 @@
import type { FileSpace } from "../../../api/files";
import { asArray, asRecord, isRecord } from "./campaignView";
import { getBool, getText } from "./draftEditor";
export type AttachmentRule = Record<string, unknown>;
export type AttachmentZipPasswordScope = "local" | "global";
export type AttachmentZipArchive = {
id: string;
name: string;
standard: boolean;
password_enabled: boolean;
password_field: string;
password_scope: AttachmentZipPasswordScope;
method: "aes" | "zip_standard";
// Read-only compatibility values retained when normalizing older campaigns.
password_mode?: "none" | "direct" | "field" | "template";
password?: string;
password_template?: string;
};
export type AttachmentZipCollection = {
enabled: boolean;
archives: AttachmentZipArchive[];
};
export function createAttachmentZipArchive(name = "attachments.zip", standard = false): AttachmentZipArchive {
return {
id: `zip-${Date.now()}-${Math.random().toString(36).slice(2)}`,
name,
standard,
password_enabled: false,
password_field: "",
password_scope: "local",
method: "aes"
};
}
export function normalizeAttachmentZipCollection(value: unknown): AttachmentZipCollection {
const zip = asRecord(value);
const rawArchives = asArray(zip.archives).filter(isRecord);
if (rawArchives.length > 0 || Object.prototype.hasOwnProperty.call(zip, "archives")) {
return {
enabled: getBool(zip, "enabled"),
archives: rawArchives.map((archive, index) => {
const legacyMode = getText(archive, "password_mode") as AttachmentZipArchive["password_mode"];
return {
id: getText(archive, "id", `zip-${index + 1}`),
name: getText(archive, "name", getText(archive, "filename_template", "attachments.zip")),
standard: getBool(archive, "standard"),
password_enabled: getBool(archive, "password_enabled", ["direct", "field", "template"].includes(legacyMode || "")),
password_field: getText(archive, "password_field"),
password_scope: getText(archive, "password_scope") === "global" ? "global" : "local",
method: getText(archive, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
...(legacyMode ? { password_mode: legacyMode } : {}),
...(getText(archive, "password") ? { password: getText(archive, "password") } : {}),
...(getText(archive, "password_template") ? { password_template: getText(archive, "password_template") } : {})
};
})
};
}
// Upgrade the first, single-archive implementation in memory.
const legacyEnabled = getBool(zip, "enabled");
const legacyName = getText(zip, "filename_template");
const legacyMode = getText(zip, "password_mode", getText(zip, "password_template") ? "template" : "none") as AttachmentZipArchive["password_mode"];
const meaningful = legacyEnabled || Boolean(legacyName || getText(zip, "password") || getText(zip, "password_field") || getText(zip, "password_template"));
if (!meaningful) return { enabled: false, archives: [] };
return {
enabled: legacyEnabled,
archives: [{
id: "default",
name: legacyName || "attachments.zip",
standard: true,
password_enabled: ["direct", "field", "template"].includes(legacyMode || ""),
password_field: getText(zip, "password_field"),
password_scope: "local",
method: getText(zip, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
...(legacyMode ? { password_mode: legacyMode } : {}),
...(getText(zip, "password") ? { password: getText(zip, "password") } : {}),
...(getText(zip, "password_template") ? { password_template: getText(zip, "password_template") } : {})
}]
};
}
export function attachmentRuleZipSelection(rule: AttachmentRule): string {
const zip = asRecord(rule.zip);
const archiveId = getText(zip, "archive_id");
if (archiveId) return archiveId;
const legacyMode = getText(zip, "mode");
if (legacyMode === "exclude") return "exclude";
return "inherit";
}
export type ManagedAttachmentSource = {
ownerType: "user" | "group";
ownerId: string;
};
const MANAGED_ATTACHMENT_SOURCE_PREFIX = "managed:";
export function encodeManagedAttachmentSource(space: Pick<FileSpace, "owner_type" | "owner_id">): string {
return `${MANAGED_ATTACHMENT_SOURCE_PREFIX}${space.owner_type}:${space.owner_id}`;
}
export function parseManagedAttachmentSource(value: unknown): ManagedAttachmentSource | null {
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;
return { ownerType, ownerId };
}
export type AttachmentBasePath = {
id: string;
name: string;
path: string;
source?: string;
allow_individual?: boolean;
unsent_warning?: boolean;
};
export type AttachmentSummary = {
direct: number;
rules: number;
};
export type MockAttachmentPathOption = Partial<AttachmentBasePath> & {
label: string;
};
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" }
];
export function createAttachmentBasePath(name = "New attachment source", path = "."): AttachmentBasePath {
return {
id: `base-path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
name,
path,
allow_individual: false,
unsent_warning: false
};
}
export function createAttachmentRule(baseDir = "", label = "", basePathId = ""): AttachmentRule {
return {
id: `attachment-${Date.now()}-${Math.random().toString(36).slice(2)}`,
label,
base_path_id: basePathId || undefined,
base_dir: baseDir,
file_filter: "",
required: true,
include_subdirs: false,
zip: { archive_id: "inherit" }
};
}
export function normalizeAttachmentBasePaths(value: unknown, attachments: Record<string, unknown>, fallbackAllowIndividual = false): AttachmentBasePath[] {
if (Array.isArray(value) && value.length > 0) {
return value.filter(isRecord).map((basePath, index) => ({
id: getText(basePath, "id", `base-path-${index + 1}`),
name: getText(basePath, "name", `Base path ${index + 1}`),
source: getText(basePath, "source"),
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
allow_individual: getBool(basePath, "allow_individual"),
unsent_warning: getBool(basePath, "unsent_warning")
}));
}
return [{
id: "base-path-campaign",
name: "Campaign files",
path: getText(attachments, "base_path", "."),
allow_individual: getBool(attachments, "allow_individual", fallbackAllowIndividual),
unsent_warning: false
}];
}
export function ensureAttachmentBasePaths(paths: AttachmentBasePath[]): AttachmentBasePath[] {
return paths.length > 0 ? paths : [createAttachmentBasePath("Campaign files", ".")];
}
export function getIndividualAttachmentBasePaths(paths: AttachmentBasePath[]): AttachmentBasePath[] {
return paths.filter((basePath) => basePath.allow_individual);
}
export function attachmentRuleUsesBasePath(rule: AttachmentRule, basePath: AttachmentBasePath): boolean {
const basePathId = getText(rule, "base_path_id");
if (basePathId) return basePathId === basePath.id;
return getText(rule, "base_dir") === basePath.path;
}
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;
}
export function removeIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): Record<string, unknown> {
const entries = asRecord(entriesValue);
const inline = asArray(entries.inline).map(asRecord).map((entry) => ({
...entry,
attachments: normalizeAttachmentRules(entry.attachments).filter((rule) => !attachmentRuleUsesBasePath(rule, basePath))
}));
return { ...entries, inline };
}
export function nextAttachmentLabel(rules: AttachmentRule[]): string {
let highest = 0;
for (const rule of rules) {
const match = /^Attachment\s+(\d+)$/i.exec(getText(rule, "label").trim());
if (match) highest = Math.max(highest, Number(match[1]));
}
highest = Math.max(highest, rules.length);
return `Attachment ${highest + 1}`;
}
export function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
if (!Array.isArray(value)) return [];
return value.filter(isRecord).map((rule) => ({
id: getText(rule, "id", `attachment-${Math.random().toString(36).slice(2)}`),
label: getText(rule, "label"),
base_path_id: getText(rule, "base_path_id"),
base_dir: getText(rule, "base_dir", ""),
file_filter: getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path"),
include_subdirs: getBool(rule, "include_subdirs"),
required: getBool(rule, "required", true),
missing_behavior: getText(rule, "missing_behavior", "ask"),
ambiguous_behavior: getText(rule, "ambiguous_behavior", "ask"),
...(isRecord(rule.zip) ? { zip: rule.zip } : {})
}));
}
export function summarizeAttachmentRules(rules: AttachmentRule[]): AttachmentSummary {
return rules.reduce<AttachmentSummary>((summary, rule) => {
if (isDirectAttachmentRule(rule)) {
summary.direct += 1;
} else {
summary.rules += 1;
}
return summary;
}, { direct: 0, rules: 0 });
}
export function countIndividualAttachmentRules(entriesValue: unknown): number {
const entries = asRecord(entriesValue);
return asArray(entries.inline)
.map(asRecord)
.flatMap((entry) => asArray(entry.attachments))
.length;
}
export function isDirectAttachmentRule(rule: AttachmentRule): boolean {
const explicitType = getText(rule, "type");
if (explicitType === "direct") return true;
if (explicitType === "pattern") return false;
const fileFilter = getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path");
if (!fileFilter) return false;
return !/[{}*?\[\]]/.test(fileFilter);
}

View File

@@ -0,0 +1,232 @@
import type { CampaignListItem } from "../../../types";
import type { CampaignSummary, CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
export type CampaignWorkspaceData = {
campaign: CampaignListItem | null;
versions: CampaignVersionListItem[];
currentVersion: CampaignVersionDetail | null;
summary: CampaignSummary | null;
};
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function asRecord(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {};
}
export function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
export function getCampaignJson(version: CampaignVersionDetail | null): Record<string, unknown> {
return version?.raw_json ?? version?.campaign_json ?? {};
}
export function getCampaignSection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).campaign);
}
export function getRecipientsSection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).recipients);
}
export function getTemplateSection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).template);
}
export function getDeliverySection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).delivery);
}
export function getEntriesSection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).entries);
}
export function getAttachmentsSection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).attachments);
}
export function getFields(version: CampaignVersionDetail | null): unknown[] {
return asArray(getCampaignJson(version).fields);
}
export function userLockState(
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;
}
// Backwards compatibility for snapshots created before explicit user locks.
return version.published_at ? "permanent" : null;
}
export function isTemporaryUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
return userLockState(version) === "temporary";
}
export function isPermanentUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
return userLockState(version) === "permanent";
}
export function isUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
return userLockState(version) !== null;
}
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());
}
export function isVersionReadyForDelivery(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
if (!version?.locked_at || isUserLockedVersion(version)) return false;
const validation = asRecord(version.validation_summary);
return validation.ok === true;
}
export function canUnlockValidationVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
if (!version) return false;
if (isUserLockedVersion(version)) return false;
if (!isVersionReadyForDelivery(version)) return false;
if (isFinalLockedVersion(version)) return false;
return ["approved", "built"].includes(version.workflow_state ?? "");
}
export function isHistoricalCampaignVersion(
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 {
if (!version) return false;
if (isHistoricalCampaignVersion(version, currentVersionId)) return true;
if (version.locked_at || isUserLockedVersion(version)) return true;
return isFinalLockedVersion(version);
}
export function versionLockReason(
version: CampaignVersionDetail | CampaignVersionListItem | null,
currentVersionId?: string | null,
): string {
if (!version) return "No campaign version is loaded.";
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.";
}
if (isTemporaryUserLockedVersion(version)) {
return `Temporarily user-locked at ${formatDateTime(version.user_locked_at)}. Authorized users may unlock it or make the lock permanent.`;
}
if (isPermanentUserLockedVersion(version)) {
return `Permanently user-locked at ${formatDateTime(version.user_locked_at ?? version.published_at)}. It cannot be unlocked; create an editable copy.`;
}
if (canUnlockValidationVersion(version)) {
return `Temporarily locked by validation at ${formatDateTime(version.locked_at)}. Unlocking invalidates validation, build and queue state.`;
}
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.";
}
export function currentStepLabel(version: CampaignVersionDetail | CampaignVersionListItem | null): string {
if (!version) return "—";
const flow = version.current_flow || "manual";
const step = version.current_step || "not set";
return `${humanize(flow)} / ${humanize(step)}`;
}
export function formatDateTime(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
export function humanize(value?: string | null): string {
if (!value) return "—";
return value.replace(/_/g, " ").replace(/-/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
export function summaryValue(summary: Record<string, unknown> | null | undefined, keys: string[]): string | number {
if (!summary) return "—";
for (const key of keys) {
const value = summary[key];
if (typeof value === "number" || typeof value === "string") return value;
}
return "—";
}
export function getString(record: Record<string, unknown>, key: string, fallback = "—"): string {
const value = record[key];
if (typeof value === "string" && value.trim()) return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return fallback;
}
export function getNestedString(record: Record<string, unknown>, path: string[], fallback = "—"): string {
let current: unknown = record;
for (const part of path) {
if (!isRecord(current)) return fallback;
current = current[part];
}
if (typeof current === "string" && current.trim()) return current;
if (typeof current === "number" || typeof current === "boolean") return String(current);
if (Array.isArray(current)) return current.length ? current.join(", ") : fallback;
if (isRecord(current)) return stringifyPreview(current, 120);
return fallback;
}
export function stringifyPreview(value: unknown, maxLength = 220): string {
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2) ?? "";
return text.length > maxLength ? `${text.slice(0, maxLength)}` : text;
}
export function cloneCampaignJsonForCopy(
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");
const baseName = String(campaignSection.name || campaign?.name || "Campaign");
const description = String(campaignSection.description || campaign?.description || "");
const externalId = `${baseId}-copy-${stamp}`.replace(/[^a-zA-Z0-9_.-]+/g, "-").toLowerCase();
const name = `${baseName} (copy)`;
rawJson.campaign = {
...campaignSection,
id: externalId,
name,
description
};
return { externalId, name, description, rawJson };
}
export function timestampSlug(date = new Date()): string {
return date.toISOString().slice(0, 19).replace(/[-:T]/g, "");
}

View File

@@ -0,0 +1,110 @@
import type { CampaignVersionDetail } from "../../../api/campaigns";
import { asRecord, getCampaignJson, isRecord } from "./campaignView";
export type DraftPatch = (draft: Record<string, unknown>) => Record<string, unknown>;
export function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value ?? {})) as T;
}
export function ensureCampaignDraft(version: CampaignVersionDetail | null): Record<string, unknown> {
const raw = cloneJson(getCampaignJson(version));
raw.version = typeof raw.version === "string" ? raw.version : "1";
raw.campaign = {
id: "",
name: "",
description: "",
mode: "draft",
...asRecord(raw.campaign)
};
raw.fields = Array.isArray(raw.fields) ? raw.fields : [];
raw.global_values = isRecord(raw.global_values) ? raw.global_values : {};
raw.server = isRecord(raw.server) ? raw.server : {};
raw.recipients = isRecord(raw.recipients) ? raw.recipients : {};
raw.template = isRecord(raw.template) ? raw.template : { subject: "", text: "" };
raw.attachments = {
base_path: ".",
allow_individual: false,
send_without_attachments: true,
global: [],
missing_behavior: "ask",
ambiguous_behavior: "ask",
...asRecord(raw.attachments)
};
raw.entries = isRecord(raw.entries) ? raw.entries : { inline: [] };
raw.validation_policy = {
unsent_attachment_files: "warn",
...asRecord(raw.validation_policy)
};
raw.delivery = isRecord(raw.delivery) ? raw.delivery : {};
raw.status_tracking = isRecord(raw.status_tracking) ? raw.status_tracking : { enabled: true };
return raw;
}
export function updateNested(
draft: Record<string, unknown>,
path: string[],
value: unknown
): Record<string, unknown> {
const next = cloneJson(draft);
let current: Record<string, unknown> = next;
path.forEach((segment, index) => {
if (index === path.length - 1) {
current[segment] = value;
return;
}
const existing = current[segment];
if (!isRecord(existing)) {
current[segment] = {};
}
current = current[segment] as Record<string, unknown>;
});
return next;
}
export function parseJsonTextarea<T>(text: string, fallback: T): { value: T; error: string } {
if (!text.trim()) return { value: fallback, error: "" };
try {
return { value: JSON.parse(text) as T, error: "" };
} catch (error) {
return { value: fallback, error: error instanceof Error ? error.message : String(error) };
}
}
export function stringifyJson(value: unknown): string {
return JSON.stringify(value ?? {}, null, 2);
}
export function getBool(record: Record<string, unknown>, key: string, fallback = false): boolean {
const value = record[key];
return typeof value === "boolean" ? value : fallback;
}
export function getNumber(record: Record<string, unknown>, key: string, fallback = 0): number {
const value = record[key];
return typeof value === "number" ? value : fallback;
}
export function getText(record: Record<string, unknown>, key: string, fallback = ""): string {
const value = record[key];
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return fallback;
}
export function downloadJson(filename: string, data: Record<string, unknown>) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
export function safeFileStem(value?: string | null): string {
const stem = (value || "campaign").replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
return stem || "campaign";
}

View File

@@ -0,0 +1,56 @@
import { asArray, asRecord } from "./campaignView";
import { getBool, getText } from "./draftEditor";
export const fieldTypeOptions = ["string", "integer", "double", "date", "password"] as const;
export type CampaignFieldType = typeof fieldTypeOptions[number];
export type CampaignFieldDefinition = {
name: string;
label: string;
type: CampaignFieldType;
required: boolean;
can_override: boolean;
};
export function normalizeFieldType(value: string | undefined | null): CampaignFieldType {
const normalized = String(value || "string").trim();
return fieldTypeOptions.includes(normalized as CampaignFieldType) ? normalized as CampaignFieldType : "string";
}
export function getDraftFields(draft: Record<string, unknown> | null | undefined): CampaignFieldDefinition[] {
return asArray(draft?.fields)
.map(asRecord)
.map((field) => ({
name: getText(field, "name") || getText(field, "id"),
label: getText(field, "label"),
type: normalizeFieldType(getText(field, "type", "string")),
required: getBool(field, "required"),
can_override: getBool(field, "can_override", true)
}))
.filter((field) => Boolean(field.name));
}
export function humanizeFieldName(value: string): string {
return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
export function valueToInputText(value: unknown, fieldType: CampaignFieldType): string {
if (value === undefined || value === null) return "";
if (fieldType === "date" && typeof value === "string") return value.slice(0, 10);
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
export function inputValueToFieldValue(fieldType: CampaignFieldType, value: string): unknown {
if (value === "") return "";
if (fieldType === "integer") {
const numberValue = Number.parseInt(value, 10);
return Number.isFinite(numberValue) ? numberValue : value;
}
if (fieldType === "double") {
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : value;
}
return value;
}

View File

@@ -0,0 +1,256 @@
import { asArray, asRecord } from "./campaignView";
import { getBool } from "./draftEditor";
import { addressesFromValue, type MailboxAddress } from "../../../utils/emailAddresses";
export type TemplateNamespace = "global" | "local";
export const RECIPIENT_ADDRESS_FIELD_IDS = ["from", "to", "reply_to", "cc", "bcc"] as const;
export function recipientAddressTemplateFieldOptions(): Array<{ name: string; label: string }> {
return RECIPIENT_ADDRESS_FIELD_IDS.flatMap((name) => [
{ name, label: name },
{ name: `all_${name}`, label: `all_${name}` }
]);
}
export function isRecipientAddressPlaceholderName(name: string): boolean {
const field = "(?:from|to|reply_to|cc|bcc)";
return new RegExp(`^(?:all_)?${field}(?:\\.(?:email|name))?$`).test(name)
|| new RegExp(`^${field}\\[[1-9]\\d*\\](?:\\.(?:email|name))?$`).test(name)
|| new RegExp(`^${field}\\.\\d+\\.(?:email|name|type)$`).test(name);
}
export type TemplatePlaceholder = {
raw: string;
namespace: string;
name: string;
validNamespace: boolean;
display: string;
};
export type UndefinedPlaceholder = TemplatePlaceholder & {
reason: "missing-field" | "invalid-namespace";
};
export function extractTemplatePlaceholders(text: string): TemplatePlaceholder[] {
const placeholders = new Map<string, TemplatePlaceholder>();
const patterns = [/\$\{\s*([^}]+?)\s*\}/g, /\{\{\s*([^}]+?)\s*\}\}/g];
for (const pattern of patterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(text))) {
const raw = match[1].trim();
if (!raw || placeholders.has(raw)) continue;
placeholders.set(raw, parseTemplatePlaceholder(raw));
}
}
return [...placeholders.values()].sort((a, b) => a.display.localeCompare(b.display));
}
export function parseTemplatePlaceholder(raw: string): TemplatePlaceholder {
const cleaned = normalizeTemplatePlaceholderKey(raw);
const separator = cleaned.indexOf(":");
const namespace = separator > -1 ? cleaned.slice(0, separator).trim() : "";
const name = separator > -1 ? cleaned.slice(separator + 1).trim() : cleaned.trim();
const validNamespace = namespace === "global" || namespace === "local";
return {
raw,
namespace,
name,
validNamespace,
display: validNamespace ? `${namespace}:${name}` : raw
};
}
export function normalizeTemplatePlaceholderKey(raw: string): string {
return raw.trim()
.replace(/^fields\./, "local:")
.replace(/^local\./, "local:")
.replace(/^global\./, "global:")
.replace(/^local::/, "local:")
.replace(/^global::/, "global:");
}
export function uniquePlaceholders<T extends TemplatePlaceholder>(items: T[]): T[] {
const seen = new Set<string>();
const result: T[] = [];
for (const item of items) {
const key = item.raw;
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
export function buildUndefinedPlaceholders(
placeholders: TemplatePlaceholder[],
availableNames: Set<string>,
availableByNamespace?: Partial<Record<TemplateNamespace, Set<string>>>
): UndefinedPlaceholder[] {
return uniquePlaceholders(placeholders
.filter((field) => {
if (!field.validNamespace) return true;
const namesForNamespace = availableByNamespace?.[field.namespace as TemplateNamespace];
if ((namesForNamespace ?? availableNames).has(field.name)) return false;
return !(field.namespace === "local" && isRecipientAddressPlaceholderName(field.name));
})
.map((field): UndefinedPlaceholder => ({
...field,
reason: field.validNamespace ? "missing-field" : "invalid-namespace"
})));
}
export function buildTemplatePreviewContext(draft: Record<string, unknown> | null, entry: Record<string, unknown>): Record<string, string> {
const context: Record<string, string> = {};
const globalValues = asRecord(draft?.global_values);
const entryFields = asRecord(entry.fields);
const overridePolicy = fieldOverridePolicy(draft);
for (const field of asArray(draft?.fields).map(asRecord)) {
const name = String(field.name || field.id || "").trim();
if (!name) continue;
addPreviewContextValue(context, name, "global", "");
addPreviewContextValue(context, name, "local", "");
}
for (const [key, value] of Object.entries(globalValues)) {
addPreviewContextValue(context, key, "global", value);
addPreviewContextValue(context, key, "local", value);
}
for (const [key, value] of Object.entries(entryFields)) {
if (canOverrideField(overridePolicy, key) && hasPreviewOverrideValue(value)) {
addPreviewContextValue(context, key, "local", value);
}
}
if (entry.name) addPreviewContextValue(context, "name", "local", entry.name);
if (entry.email) addPreviewContextValue(context, "email", "local", entry.email);
addRecipientAddressPreviewValues(context, effectivePreviewAddresses(draft, entry));
return context;
}
function effectivePreviewAddresses(draft: Record<string, unknown> | null, entry: Record<string, unknown>): Record<string, MailboxAddress[]> {
const recipients = asRecord(draft?.recipients);
const defaults = asRecord(asRecord(draft?.entries).defaults);
const result: Record<string, MailboxAddress[]> = {};
for (const field of RECIPIENT_ADDRESS_FIELD_IDS) {
const globalAddresses = addressesFromValue(recipients[field]);
const localAddresses = addressesFromValue(entry[field]);
const allowIndividual = getBool(recipients, `allow_individual_${field}`, field === "to");
const defaultMerge = field !== "from";
const merge = mergeFlag(entry, defaults, field, defaultMerge);
if (!allowIndividual) {
result[field] = dedupeAddressOrder(globalAddresses);
} else if (localAddresses.length === 0) {
result[field] = dedupeAddressOrder(globalAddresses);
} else {
result[field] = dedupeAddressOrder(merge ? [...globalAddresses, ...localAddresses] : localAddresses);
}
}
return result;
}
function dedupeAddressOrder(addresses: MailboxAddress[]): MailboxAddress[] {
const seen = new Set<string>();
return addresses.filter((address) => {
const key = address.email.trim().toLowerCase();
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}
function mergeFlag(entry: Record<string, unknown>, defaults: Record<string, unknown>, field: string, fallback: boolean): boolean {
const mergeKey = `merge_${field}`;
const legacyKey = `combine_${field}`;
if (typeof entry[mergeKey] === "boolean") return entry[mergeKey] as boolean;
if (typeof entry[legacyKey] === "boolean") return entry[legacyKey] as boolean;
if (typeof defaults[mergeKey] === "boolean") return defaults[mergeKey] as boolean;
if (typeof defaults[legacyKey] === "boolean") return defaults[legacyKey] as boolean;
return fallback;
}
function addRecipientAddressPreviewValues(context: Record<string, string>, addresses: Record<string, MailboxAddress[]>) {
for (const field of RECIPIENT_ADDRESS_FIELD_IDS) {
const values = addresses[field] ?? [];
const formatted = values.map(formatPreviewAddress);
const emails = values.map((address) => address.email);
const names = values.map((address) => address.name ?? "");
addPreviewContextValue(context, field, "local", formatted[0] ?? "");
addPreviewContextValue(context, `${field}.email`, "local", emails[0] ?? "");
addPreviewContextValue(context, `${field}.name`, "local", names[0] ?? "");
addPreviewContextValue(context, `all_${field}`, "local", formatted.join(", "));
addPreviewContextValue(context, `all_${field}.email`, "local", emails.join(", "));
addPreviewContextValue(context, `all_${field}.name`, "local", names.filter(Boolean).join(", "));
values.forEach((address, index) => {
const oneBased = index + 1;
addPreviewContextValue(context, `${field}[${oneBased}]`, "local", formatted[index]);
addPreviewContextValue(context, `${field}[${oneBased}].email`, "local", address.email);
addPreviewContextValue(context, `${field}[${oneBased}].name`, "local", address.name ?? "");
addPreviewContextValue(context, `${field}.${index}.email`, "local", address.email);
addPreviewContextValue(context, `${field}.${index}.name`, "local", address.name ?? "");
});
}
}
function formatPreviewAddress(address: MailboxAddress): string {
return address.name ? `${address.name} <${address.email}>` : address.email;
}
export function renderTemplatePreviewText(text: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
if (!text) return "";
return text
.replace(/\$\{\s*([^}]+?)\s*\}/g, (_match, raw: string) => previewValueFor(raw, context, ignoreEmptyFields))
.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, raw: string) => previewValueFor(raw, context, ignoreEmptyFields));
}
export function valueToPreview(value: unknown): string {
if (value === undefined || value === null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
export function removePlaceholderFromText(text: string, raw: string): string {
if (!text) return text;
const escaped = escapeRegExp(raw.trim());
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), "");
}
function fieldOverridePolicy(draft: Record<string, unknown> | null): Map<string, boolean> {
const policy = new Map<string, boolean>();
for (const field of asArray(draft?.fields).map(asRecord)) {
const name = String(field.name || field.id || "").trim();
if (!name) continue;
policy.set(name, getBool(field, "can_override", true));
}
return policy;
}
function canOverrideField(policy: Map<string, boolean>, name: string): boolean {
if (!policy.has(name)) return true;
return policy.get(name) !== false;
}
function addPreviewContextValue(context: Record<string, string>, key: string, namespace: TemplateNamespace, value: unknown) {
const text = valueToPreview(value);
context[key] = text;
context[`${namespace}:${key}`] = text;
context[`${namespace}::${key}`] = text;
}
function hasPreviewOverrideValue(value: unknown): boolean {
if (value === undefined || value === null) return false;
if (typeof value === "string") return value.trim() !== "";
return true;
}
function previewValueFor(raw: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
const key = normalizeTemplatePlaceholderKey(raw);
const value = context[key];
if (value !== undefined) return value;
return ignoreEmptyFields ? "" : `{{${raw.trim()}}}`;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -0,0 +1,143 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings, WizardStep } from "../../../types";
import Stepper from "../../../components/Stepper";
import Card from "../../../components/Card";
import Button from "../../../components/Button";
import DismissibleAlert from "../../../components/DismissibleAlert";
import PageTitle from "../../../components/PageTitle";
import LockedVersionNotice from "../components/LockedVersionNotice";
import { validatePartial } from "../../../api/campaigns";
import { useCampaignWorkspaceData } from "../hooks/useCampaignWorkspaceData";
import { isAuditLockedVersion } from "../utils/campaignView";
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" }
];
export default function CreateWizard({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const [activeStep, setActiveStep] = useState("basics");
const [localError, setLocalError] = useState("");
const [validationMessage, setValidationMessage] = useState("");
const index = steps.findIndex((s) => s.id === activeStep);
const { data, loading, reload } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, dirty, saveState, patch, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
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.",
onLoaded: (loadedVersion) => {
if (loadedVersion.current_step && steps.some((step) => step.id === loadedVersion.current_step)) {
setActiveStep(loadedVersion.current_step);
}
}
});
function patchRoot(key: string, value: unknown) {
patch([key], value);
}
function selectStep(stepId: string) {
setActiveStep(stepId);
}
function nextStep() {
setActiveStep(steps[Math.min(steps.length - 1, index + 1)].id);
}
function previousStep() {
setActiveStep(steps[Math.max(0, index - 1)].id);
}
async function validateCurrentStep() {
if (!version || !draft) return;
setValidationMessage("Validating…");
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.`);
} catch (err) {
setValidationMessage(err instanceof Error ? err.message : String(err));
}
}
if (locked) {
return (
<div className="wizard-page">
<div className="wizard-card locked-wizard-card">
<div className="wizard-body standalone-wizard-body">
<div className="wizard-heading">
<div>
<PageTitle>Create campaign</PageTitle>
</div>
<div className="save-state">Locked</div>
</div>
<Card>
<LockedVersionNotice
settings={settings}
campaignId={campaignId}
version={data.currentVersion}
currentVersionId={data.campaign?.current_version_id}
reload={reload}
message="This wizard is read-only for the selected version."
/>
<div className="button-row">
<Link to="../.."><Button>Back to overview</Button></Link>
</div>
</Card>
</div>
</div>
</div>
);
}
return (
<div className="wizard-page">
<div className="wizard-card">
<Stepper steps={steps} activeStep={activeStep} onSelect={selectStep} />
<div className="wizard-body">
<div className="wizard-heading">
<div>
<PageTitle loading={loading}>Create campaign</PageTitle>
</div>
<div className="save-state">{saveState}</div>
</div>
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{validationMessage && <DismissibleAlert tone="info" resetKey={validationMessage} floating>{validationMessage}</DismissibleAlert>}
<Card>
{draft && activeStep === "basics" && <BasicsStep draft={draft} patch={patch} />}
{draft && activeStep === "sender" && <SenderStep draft={draft} patch={patch} />}
{draft && activeStep === "fields" && <FieldsStep draft={draft} patchRoot={patchRoot} />}
{draft && activeStep === "recipients" && <RecipientsStep draft={draft} patchRoot={patchRoot} />}
{draft && activeStep === "template" && <TemplateStep draft={draft} patch={patch} />}
{draft && activeStep === "attachments" && <AttachmentsStep draft={draft} patch={patch} />}
{draft && activeStep === "review" && <ReviewStep version={version} onValidate={validateCurrentStep} />}
{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>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import Card from "../../../components/Card";
import MetricCard from "../../../components/MetricCard";
import Button from "../../../components/Button";
export default function ReviewWizard() {
return (
<div className="content-pad">
<div className="page-heading">
<h1>Review Wizard</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" />
</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>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import Card from "../../../components/Card";
import Button from "../../../components/Button";
export default function SendWizard() {
return (
<div className="content-pad">
<div className="page-heading">
<h1>Send Wizard</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>
<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>
</div>
</div>
);
}

View File

@@ -0,0 +1,235 @@
import { useEffect, useState } from "react";
import Button from "../../../../components/Button";
import FormField from "../../../../components/FormField";
import ToggleSwitch from "../../../../components/ToggleSwitch";
import EmailAddressInput from "../../../../components/email/EmailAddressInput";
import MetricCard from "../../../../components/MetricCard";
import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "../../../../utils/emailAddresses";
import { asArray, asRecord, stringifyPreview, summaryValue } from "../../utils/campaignView";
import { getBool, getNumber, getText, parseJsonTextarea, stringifyJson } from "../../utils/draftEditor";
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
export type WizardStepPatch = (path: string[], value: unknown) => void;
export type WizardStepProps = {
draft: Record<string, unknown>;
patch: WizardStepPatch;
};
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.">
<input value={getText(campaign, "name")} onChange={(event) => patch(["campaign", "name"], event.target.value)} />
</FormField>
<FormField label="Campaign ID" help="Stable technical identifier.">
<input value={getText(campaign, "id")} onChange={(event) => patch(["campaign", "id"], event.target.value)} />
</FormField>
<FormField label="Mode">
<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>
</select>
</FormField>
<FormField label="Description">
<textarea rows={5} value={getText(campaign, "description")} onChange={(event) => patch(["campaign", "description"], event.target.value)} />
</FormField>
</div>
);
}
export function SenderStep({ draft, patch }: WizardStepProps) {
const recipients = asRecord(draft.recipients);
const from = addressesFromValue(recipients.from);
const suggestions = collectCampaignAddressSuggestions(draft);
const globalTo = addressesFromValue(recipients.to);
const globalCc = addressesFromValue(recipients.cc);
const globalBcc = addressesFromValue(recipients.bcc);
const globalReplyTo = addressesFromValue(recipients.reply_to);
const server = asRecord(draft.server);
const smtp = asRecord(server.smtp);
const delivery = asRecord(draft.delivery);
const imapAppend = asRecord(delivery.imap_append_sent);
return (
<div className="form-grid">
<FormField label="Default From address">
<EmailAddressInput
value={from}
suggestions={suggestions}
allowMultiple
addLabel="Add From"
emptyText="No global From address configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses)}
/>
</FormField>
<FormField label="Global recipients">
<EmailAddressInput
value={globalTo}
suggestions={suggestions}
allowMultiple
addLabel="Add recipient"
emptyText="No global recipients configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "to"], addresses)}
/>
</FormField>
<FormField label="CC">
<EmailAddressInput
value={globalCc}
suggestions={suggestions}
allowMultiple
addLabel="Add CC"
emptyText="No global CC recipients configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "cc"], addresses)}
/>
</FormField>
<FormField label="BCC">
<EmailAddressInput
value={globalBcc}
suggestions={suggestions}
allowMultiple
addLabel="Add BCC"
emptyText="No global BCC recipients configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "bcc"], addresses)}
/>
</FormField>
<FormField label="Reply-To">
<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))}
/>
</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>
);
}
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>
</div>
<JsonEditor value={draft.fields ?? []} onValid={(value) => patchRoot("fields", value)} />
</div>
);
}
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>
</div>
<JsonEditor value={draft.entries ?? { inline: [] }} onValid={(value) => patchRoot("entries", value)} />
</div>
);
}
export function TemplateStep({ draft, patch }: WizardStepProps) {
const template = asRecord(draft.template);
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>
</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>
</div>
</div>
);
}
export function AttachmentsStep({ draft, patch }: WizardStepProps) {
const attachments = asRecord(draft.attachments);
return (
<div>
<div className="step-intro">
<h2>Attachments</h2>
<p>Configure campaign-wide attachment behavior and global matching rules.</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)} />
</div>
<JsonEditor value={attachments.global ?? []} onValid={(value) => patch(["attachments", "global"], value)} />
</div>
);
}
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>
</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" />
</div>
<Button variant="primary" onClick={onValidate}>Validate campaign</Button>
</div>
);
}
export function SendStep({ draft, patch }: WizardStepProps) {
const delivery = asRecord(draft.delivery);
const rateLimit = asRecord(delivery.rate_limit);
return (
<div>
<div className="step-intro">
<h2>Send preparation</h2>
<p>Configure rate limits and prepare the final send workflow.</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>
</div>
<p className="muted">Test send and queue actions remain in the Send Wizard for now.</p>
</div>
);
}
function JsonEditor({ value, onValid }: { value: unknown; onValid: (value: unknown) => void }) {
const [text, setText] = useState(stringifyJson(value));
const [error, setError] = useState("");
useEffect(() => {
setText(stringifyJson(value));
setError("");
}, [value]);
function change(nextText: string) {
setText(nextText);
const parsed = parseJsonTextarea(nextText, value);
setError(parsed.error);
if (!parsed.error) onValid(parsed.value);
}
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>
);
}

View File

@@ -0,0 +1 @@
export { FolderTree } from "@govoplan/files-webui";

View File

@@ -0,0 +1 @@
export { useFileTreeState } from "@govoplan/files-webui";

View File

@@ -0,0 +1 @@
export type { FolderNode, SortColumn, SortDirection } from "@govoplan/files-webui";

View File

@@ -0,0 +1 @@
export { buildExplorerEntries, buildFolderEntryFromSources, buildFolderTree, candidateRenamedPath, directFolderCounts, entryModifiedTime, entryName, entrySelectionKey, entrySize, fileIdsForSelection, folderAncestorPaths, folderBreadcrumbs, folderContentLabel, formatBytes, formatDate, isFileInFolder, isPathUnderOrSame, joinFolder, lastPathSegment, normalizeFilePath, normalizeFolder, parentFolderPath, parseDragState, sortExplorerEntries, sortFolderNodes, treeNodeKey } from "@govoplan/files-webui";

View File

@@ -0,0 +1,4 @@
import { MailProfileScopeManager } from "@govoplan/mail-webui";
export { MailProfilePolicyEditor, MailProfileScopeManager } from "@govoplan/mail-webui";
export default MailProfileScopeManager;

View File

@@ -0,0 +1,178 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import type { ApiSettings, CampaignListItem } from "../../types";
import { getCampaignSummary, listCampaigns, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns";
import Button from "../../components/Button";
import Card from "../../components/Card";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import DismissibleAlert from "../../components/DismissibleAlert";
import LoadingFrame from "../../components/LoadingFrame";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
import { asRecord, formatDateTime, humanize } from "../campaigns/utils/campaignView";
type OperatorRow = {
campaign: CampaignListItem;
summary: CampaignSummary | null;
failed: number;
outcomeUnknown: number;
notAttempted: number;
queuedOrActive: number;
imapFailed: number;
queueable: number;
needsAttention: number;
};
export default function OperatorQueuePage({ settings }: { settings: ApiSettings }) {
const navigate = useNavigate();
const [rows, setRows] = useState<OperatorRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [busy, setBusy] = useState("");
useEffect(() => { void load(); }, [settings.apiBaseUrl, settings.apiKey]);
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)));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
async function runAction(row: OperatorRow, action: "retry" | "unattempted") {
const key = `${action}:${row.campaign.id}`;
setBusy(key);
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 result = asRecord(response.result ?? response);
setMessage(`${row.campaign.name}: ${humanize(String(result.action ?? action))}, ${String(result.enqueued_count ?? 0)} enqueued.`);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy("");
}
}
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,
}), { 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: 320,
sticky: "end",
render: (row) => (
<div className="button-row compact-actions">
<Button onClick={() => navigate(`/campaigns/${row.campaign.id}/report`)}>Open queue</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>
</div>
)
}
], [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>
</div>
<div className="button-row compact-actions">
<Button onClick={() => void load()} disabled={loading}>Refresh</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Queue pressure"><MetricGrid items={[
["Failed", totals.failed],
["Outcome unknown", totals.outcomeUnknown],
["Unattempted", totals.notAttempted],
["Queued/active", totals.queuedOrActive],
["IMAP failed", totals.imapFailed],
]} /></Card>
</div>
<LoadingFrame loading={loading} label="Loading operator queue…">
<Card title="Campaign queues">
<DataGrid
id="operator-queue-campaigns"
rows={rows}
columns={columns}
getRowKey={(row) => row.campaign.id}
emptyText="No accessible campaigns."
className="data-table compact-table"
/>
</Card>
</LoadingFrame>
</div>
);
}
function toRow(campaign: CampaignListItem, summary: CampaignSummary | null): OperatorRow {
const cards = asRecord(summary?.cards);
return {
campaign,
summary,
failed: numberValue(cards.failed),
outcomeUnknown: numberValue(cards.outcome_unknown),
notAttempted: numberValue(cards.not_attempted),
queuedOrActive: numberValue(cards.queued_or_active),
imapFailed: numberValue(cards.imap_failed),
queueable: numberValue(cards.queueable),
needsAttention: numberValue(cards.needs_attention),
};
}
function numberValue(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function MetricGrid({ items }: { items: Array<[string, number]> }) {
return (
<div className="status-grid">
{items.map(([label, value]) => (
<div className="status-card" key={label}>
<span>{label}</span>
<strong>{value}</strong>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,493 @@
import { useEffect, useMemo, useState } from "react";
import type { ApiSettings } from "../../types";
import {
getPrivacyRetentionPolicy,
updatePrivacyRetentionPolicy,
type PrivacyRetentionLimitPermissionPatch,
type PrivacyRetentionLimitPermissions,
type PrivacyRetentionPolicy,
type PrivacyRetentionPolicyFieldKey,
type PrivacyRetentionPolicyPatch,
type PrivacyRetentionPolicyScope
} from "../../api/admin";
import Button from "../../components/Button";
import Card from "../../components/Card";
import DismissibleAlert from "../../components/DismissibleAlert";
import FormField from "../../components/FormField";
import ToggleSwitch from "../../components/ToggleSwitch";
export type RetentionPolicyTargetOption = {
id: string;
label: string;
secondary?: string | null;
};
type RetentionPolicyScopeManagerProps = {
settings: ApiSettings;
scopeType: PrivacyRetentionPolicyScope;
scopeId?: string | null;
targetOptions?: RetentionPolicyTargetOption[];
targetLabel?: string;
title?: string;
description?: string;
canWrite: boolean;
locked?: boolean;
};
type RetentionPolicyEditorProps = {
settings: ApiSettings;
scopeType: PrivacyRetentionPolicyScope;
scopeId?: string | null;
title?: string;
description?: string;
canWrite: boolean;
locked?: boolean;
};
type DayKey =
| "raw_campaign_json_retention_days"
| "generated_eml_retention_days"
| "stored_report_detail_retention_days"
| "mock_mailbox_retention_days"
| "audit_detail_retention_days";
type FieldDefinition = {
key: PrivacyRetentionPolicyFieldKey;
label: string;
kind: "raw-json" | "days" | "audit";
systemOnly?: boolean;
};
type RawJsonValue = "inherit" | "keep" | "disable";
type AuditDetailValue = "inherit" | PrivacyRetentionPolicy["audit_detail_level"];
const defaultAllowLowerLevelLimits: PrivacyRetentionLimitPermissions = {
store_raw_campaign_json: true,
raw_campaign_json_retention_days: true,
generated_eml_retention_days: true,
stored_report_detail_retention_days: true,
mock_mailbox_retention_days: true,
audit_detail_retention_days: true,
audit_detail_level: true
};
const defaultPrivacyPolicy: PrivacyRetentionPolicy = {
store_raw_campaign_json: true,
raw_campaign_json_retention_days: null,
generated_eml_retention_days: null,
stored_report_detail_retention_days: null,
mock_mailbox_retention_days: null,
audit_detail_retention_days: null,
audit_detail_level: "full",
allow_lower_level_limits: defaultAllowLowerLevelLimits
};
const fieldDefinitions: FieldDefinition[] = [
{ key: "store_raw_campaign_json", label: "Raw campaign JSON", kind: "raw-json" },
{ key: "raw_campaign_json_retention_days", label: "Raw campaign JSON days", kind: "days" },
{ key: "generated_eml_retention_days", label: "Generated EML days", kind: "days" },
{ key: "stored_report_detail_retention_days", label: "Stored report detail days", kind: "days" },
{ key: "mock_mailbox_retention_days", label: "Mock mailbox days", kind: "days", systemOnly: true },
{ key: "audit_detail_retention_days", label: "Audit detail days", kind: "days" },
{ key: "audit_detail_level", label: "Audit detail level", kind: "audit" }
];
export function RetentionPolicyScopeManager({
settings,
scopeType,
scopeId = null,
targetOptions = [],
targetLabel = "Target",
title = "Retention policy",
description,
canWrite,
locked = false
}: RetentionPolicyScopeManagerProps) {
const [selectedTargetId, setSelectedTargetId] = useState(scopeId || targetOptions[0]?.id || "");
const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign";
const activeScopeId = scopeId || selectedTargetId || null;
const defaultDescription = scopeType === "system"
? "System retention defaults and the fields lower levels may limit further."
: "Local retention limits for this scope. Values inherit from the parent unless explicitly narrowed.";
useEffect(() => {
if (scopeId) {
setSelectedTargetId(scopeId);
return;
}
if (targetOptions.length > 0 && !targetOptions.some((option) => option.id === selectedTargetId)) {
setSelectedTargetId(targetOptions[0].id);
}
}, [scopeId, selectedTargetId, targetOptions]);
return (
<div className="retention-policy-manager">
{targetOptions.length > 0 && !scopeId && (
<Card title={`${targetLabel} scope`}>
<div className="retention-policy-target-row">
<FormField label={targetLabel}>
<select value={selectedTargetId} onChange={(event) => setSelectedTargetId(event.target.value)}>
{targetOptions.map((option) => <option key={option.id} value={option.id}>{option.secondary ? `${option.label} (${option.secondary})` : option.label}</option>)}
</select>
</FormField>
</div>
</Card>
)}
<RetentionPolicyEditor
settings={settings}
scopeType={scopeType}
scopeId={activeScopeId}
title={title}
description={requiresTarget && !activeScopeId ? `Select a ${targetLabel.toLowerCase()} before editing retention.` : (description ?? defaultDescription)}
canWrite={canWrite}
locked={locked}
/>
</div>
);
}
export function RetentionPolicyEditor({
settings,
scopeType,
scopeId = null,
title = "Retention policy",
description,
canWrite,
locked = false
}: RetentionPolicyEditorProps) {
const [policy, setPolicy] = useState<PrivacyRetentionPolicyPatch>({});
const [effectivePolicy, setEffectivePolicy] = useState<PrivacyRetentionPolicy | null>(null);
const [parentPolicy, setParentPolicy] = useState<PrivacyRetentionPolicy | null>(null);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const isSystem = scopeType === "system";
const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign";
const scopeReady = !requiresTarget || Boolean(scopeId);
const disabled = locked || loading || busy || !canWrite || !scopeReady;
const showAllowColumn = scopeType !== "campaign";
const activeParentPolicy = parentPolicy ? normalizeFullPolicy(parentPolicy) : null;
const activeFullPolicy = normalizeFullPolicy({ ...defaultPrivacyPolicy, ...policy, allow_lower_level_limits: { ...defaultAllowLowerLevelLimits, ...(policy.allow_lower_level_limits ?? {}) } });
const visibleFields = useMemo(() => fieldDefinitions.filter((field) => isSystem || !field.systemOnly), [isSystem]);
useEffect(() => { void loadPolicy(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, scopeId]);
async function loadPolicy() {
setError("");
setSuccess("");
if (!scopeReady) {
setPolicy({});
setEffectivePolicy(null);
setParentPolicy(null);
return;
}
setLoading(true);
try {
const response = await getPrivacyRetentionPolicy(settings, scopeType, scopeId);
setPolicy(isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy));
setEffectivePolicy(normalizeFullPolicy(response.effective_policy));
setParentPolicy(response.parent_policy ? normalizeFullPolicy(response.parent_policy) : null);
} catch (err) {
setPolicy({});
setEffectivePolicy(null);
setParentPolicy(null);
setError(errorMessage(err));
} finally {
setLoading(false);
}
}
async function savePolicy() {
if (!scopeReady) return;
setBusy(true);
setError("");
setSuccess("");
try {
const payload = isSystem ? normalizeFullPolicy(policy) : normalizePatchForSave(policy, activeParentPolicy, scopeType);
const response = await updatePrivacyRetentionPolicy(settings, scopeType, payload, scopeId);
setPolicy(isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy));
setEffectivePolicy(normalizeFullPolicy(response.effective_policy));
setParentPolicy(response.parent_policy ? normalizeFullPolicy(response.parent_policy) : null);
setSuccess("Retention policy saved.");
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
function patchPolicy(patch: PrivacyRetentionPolicyPatch) {
const next = { ...policy, ...patch };
if (policy.allow_lower_level_limits || patch.allow_lower_level_limits) {
next.allow_lower_level_limits = { ...(policy.allow_lower_level_limits ?? {}), ...(patch.allow_lower_level_limits ?? {}) };
}
setPolicy(isSystem ? normalizeFullPolicy(next) : normalizePatch(next));
}
function setRawJson(value: RawJsonValue | "store" | "disabled") {
if (isSystem) {
patchPolicy({ store_raw_campaign_json: value === "store" });
return;
}
patchPolicy({ store_raw_campaign_json: value === "inherit" ? undefined : value === "keep" });
}
function setRetentionDays(key: DayKey, value: string) {
const trimmed = value.trim();
patchPolicy({ [key]: trimmed === "" ? (isSystem ? null : undefined) : Math.max(0, Number(trimmed) || 0) });
}
function setAuditDetail(value: AuditDetailValue) {
patchPolicy({ audit_detail_level: value === "inherit" ? undefined : value });
}
function setAllowLowerLevelLimit(key: PrivacyRetentionPolicyFieldKey, allowed: boolean) {
patchPolicy({ allow_lower_level_limits: { [key]: allowed } });
}
function parentAllows(key: PrivacyRetentionPolicyFieldKey): boolean {
return !activeParentPolicy || activeParentPolicy.allow_lower_level_limits[key] !== false;
}
function localAllowsLower(key: PrivacyRetentionPolicyFieldKey): boolean {
if (isSystem) return activeFullPolicy.allow_lower_level_limits[key] !== false;
const localValue = policy.allow_lower_level_limits?.[key];
if (localValue !== undefined) return localValue && parentAllows(key);
return parentAllows(key);
}
const localPolicySummary = useMemo(() => isSystem ? "System baseline" : localPolicyCount(policy), [isSystem, policy]);
const defaultDescription = isSystem
? "Set concrete system retention values. Blank day fields mean unlimited retention."
: "Blank values inherit. Numeric values may only shorten the inherited retention period; audit detail may only become more restrictive.";
return (
<Card
title={title}
actions={
<div className="button-row compact-actions">
<Button onClick={() => void loadPolicy()} disabled={loading || !scopeReady}>{loading ? "Loading…" : "Reload"}</Button>
<Button variant="primary" onClick={() => void savePolicy()} disabled={disabled}>{busy ? "Saving…" : "Save policy"}</Button>
</div>
}
>
<div className="retention-policy-editor">
<p className="muted small-note retention-policy-description">{description ?? defaultDescription}</p>
{!isSystem && <p className="muted small-note retention-policy-description">Mock mailbox retention is currently managed at system level because mock mailbox records do not carry tenant or campaign ownership metadata.</p>}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
<section className="retention-policy-section">
<div className="subsection-heading split">
<h3>{isSystem ? "System policy" : "Local policy"}</h3>
<span className="muted small-note">{localPolicySummary}</span>
</div>
<div className={`retention-policy-table${showAllowColumn ? " with-allow-column" : ""}`}>
<div className="retention-policy-row retention-policy-row-header">
<span>Field</span>
<span>Value</span>
{showAllowColumn && <span>Lower levels</span>}
</div>
{visibleFields.map((field) => {
const fieldLocked = !parentAllows(field.key);
const fieldDisabled = disabled || fieldLocked;
return (
<div className="retention-policy-row" key={field.key}>
<div className="retention-policy-field-label">
<strong>{field.label}</strong>
{fieldLocked && <small>Locked by parent policy</small>}
{field.systemOnly && <small>System-level cleanup scope</small>}
</div>
<div>{renderFieldControl(field, activeFullPolicy, policy, isSystem, fieldDisabled, setRawJson, setRetentionDays, setAuditDetail, activeParentPolicy)}</div>
{showAllowColumn && (
<ToggleSwitch
checked={localAllowsLower(field.key)}
disabled={disabled || fieldLocked}
onChange={(checked) => setAllowLowerLevelLimit(field.key, checked)}
label="Allow limiting"
/>
)}
</div>
);
})}
</div>
</section>
{effectivePolicy && (
<section className="retention-policy-section retention-policy-effective">
<h3>Effective policy</h3>
<div className="retention-policy-effective-grid">
<PolicyValue label="Raw JSON" value={effectivePolicy.store_raw_campaign_json ? "Stored" : "Disabled"} />
<PolicyValue label="Raw JSON days" value={daysLabel(effectivePolicy.raw_campaign_json_retention_days)} />
<PolicyValue label="Generated EML days" value={daysLabel(effectivePolicy.generated_eml_retention_days)} />
<PolicyValue label="Report detail days" value={daysLabel(effectivePolicy.stored_report_detail_retention_days)} />
<PolicyValue label="Mock mailbox days" value={daysLabel(effectivePolicy.mock_mailbox_retention_days)} />
<PolicyValue label="Audit detail days" value={daysLabel(effectivePolicy.audit_detail_retention_days)} />
<PolicyValue label="Audit detail" value={auditDetailLabel(effectivePolicy.audit_detail_level)} />
{showAllowColumn && <PolicyValue label="Lower limits" value={allowLowerSummary(effectivePolicy.allow_lower_level_limits)} />}
</div>
</section>
)}
</div>
</Card>
);
}
function renderFieldControl(
field: FieldDefinition,
fullPolicy: PrivacyRetentionPolicy,
patchPolicy: PrivacyRetentionPolicyPatch,
isSystem: boolean,
disabled: boolean,
setRawJson: (value: RawJsonValue | "store" | "disabled") => void,
setRetentionDays: (key: DayKey, value: string) => void,
setAuditDetail: (value: AuditDetailValue) => void,
parentPolicy: PrivacyRetentionPolicy | null
) {
if (field.kind === "raw-json") {
if (isSystem) {
return <RawJsonSystemSelect value={fullPolicy.store_raw_campaign_json ? "store" : "disabled"} disabled={disabled} onChange={setRawJson} />;
}
return <RawJsonScopedSelect value={rawJsonValue(patchPolicy.store_raw_campaign_json)} parentStoresRawJson={parentPolicy?.store_raw_campaign_json ?? true} disabled={disabled} onChange={setRawJson} />;
}
if (field.kind === "audit") {
return <AuditDetailSelect value={isSystem ? fullPolicy.audit_detail_level : auditDetailValue(patchPolicy.audit_detail_level)} includeInherit={!isSystem} disabled={disabled} onChange={setAuditDetail} />;
}
return (
<RetentionDaysField
value={isSystem ? fullPolicy[field.key as DayKey] : patchPolicy[field.key as DayKey]}
disabled={disabled}
placeholder={isSystem ? "Unlimited" : "Inherit"}
onChange={(value) => setRetentionDays(field.key as DayKey, value)}
/>
);
}
function RawJsonSystemSelect({ value, disabled, onChange }: { value: "store" | "disabled"; disabled: boolean; onChange: (value: "store" | "disabled") => void }) {
return (
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as "store" | "disabled")}>
<option value="store">Store</option>
<option value="disabled">Disable storage</option>
</select>
);
}
function RawJsonScopedSelect({ value, parentStoresRawJson, disabled, onChange }: { value: RawJsonValue; parentStoresRawJson: boolean; disabled: boolean; onChange: (value: RawJsonValue) => void }) {
return (
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as RawJsonValue)}>
<option value="inherit">Inherit</option>
<option value="keep" disabled={!parentStoresRawJson}>Store if parent allows</option>
<option value="disable">Disable storage</option>
</select>
);
}
function RetentionDaysField({ value, disabled, placeholder, onChange }: { value?: number | null; disabled: boolean; placeholder: string; onChange: (value: string) => void }) {
return <input type="number" min={0} value={value ?? ""} disabled={disabled} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} />;
}
function AuditDetailSelect({ value, includeInherit, disabled, onChange }: { value: AuditDetailValue; includeInherit: boolean; disabled: boolean; onChange: (value: AuditDetailValue) => void }) {
return (
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as AuditDetailValue)}>
{includeInherit && <option value="inherit">Inherit</option>}
<option value="full">Full</option>
<option value="redacted">Redacted</option>
<option value="minimal">Minimal</option>
</select>
);
}
function PolicyValue({ label, value }: { label: string; value: string }) {
return <div><span>{label}</span><strong>{value}</strong></div>;
}
function rawJsonValue(value: boolean | undefined): RawJsonValue {
if (value === undefined) return "inherit";
return value ? "keep" : "disable";
}
function auditDetailValue(value: PrivacyRetentionPolicyPatch["audit_detail_level"]): AuditDetailValue {
return value ?? "inherit";
}
function daysLabel(value?: number | null): string {
return value === null || value === undefined ? "Unlimited" : `${value} day${value === 1 ? "" : "s"}`;
}
function auditDetailLabel(value: PrivacyRetentionPolicy["audit_detail_level"]): string {
if (value === "full") return "Full";
if (value === "redacted") return "Redacted";
return "Minimal";
}
function allowLowerSummary(allow: PrivacyRetentionLimitPermissions): string {
const count = fieldDefinitions.filter((field) => allow[field.key] !== false).length;
if (count === fieldDefinitions.length) return "Allowed";
if (count === 0) return "Locked";
return `${count}/${fieldDefinitions.length} allowed`;
}
function localPolicyCount(policy: PrivacyRetentionPolicyPatch): string {
let count = 0;
for (const field of fieldDefinitions) {
if (field.systemOnly) continue;
if (policy[field.key as keyof PrivacyRetentionPolicyPatch] !== undefined && policy[field.key as keyof PrivacyRetentionPolicyPatch] !== null) count += 1;
}
const allowCount = Object.keys(policy.allow_lower_level_limits ?? {}).length;
count += allowCount;
return count === 0 ? "Fully inherited" : `${count} local override${count === 1 ? "" : "s"}`;
}
function normalizeAllowLimits(value: PrivacyRetentionLimitPermissionPatch | PrivacyRetentionLimitPermissions | null | undefined): PrivacyRetentionLimitPermissions {
return { ...defaultAllowLowerLevelLimits, ...(value ?? {}) };
}
function normalizeFullPolicy(policy: PrivacyRetentionPolicyPatch | Partial<PrivacyRetentionPolicy> | null | undefined): PrivacyRetentionPolicy {
return {
...defaultPrivacyPolicy,
...(policy ?? {}),
raw_campaign_json_retention_days: policy?.raw_campaign_json_retention_days ?? null,
generated_eml_retention_days: policy?.generated_eml_retention_days ?? null,
stored_report_detail_retention_days: policy?.stored_report_detail_retention_days ?? null,
mock_mailbox_retention_days: policy?.mock_mailbox_retention_days ?? null,
audit_detail_retention_days: policy?.audit_detail_retention_days ?? null,
allow_lower_level_limits: normalizeAllowLimits(policy?.allow_lower_level_limits)
};
}
function normalizePatch(policy: PrivacyRetentionPolicyPatch | null | undefined): PrivacyRetentionPolicyPatch {
const normalized: PrivacyRetentionPolicyPatch = {};
if (!policy) return normalized;
if (policy.store_raw_campaign_json !== undefined) normalized.store_raw_campaign_json = policy.store_raw_campaign_json;
for (const field of fieldDefinitions) {
if (field.kind !== "days" || field.systemOnly) continue;
const value = policy[field.key as DayKey];
if (value !== undefined && value !== null) normalized[field.key as DayKey] = Math.max(0, Number(value) || 0);
}
if (policy.audit_detail_level) normalized.audit_detail_level = policy.audit_detail_level;
if (policy.allow_lower_level_limits) normalized.allow_lower_level_limits = { ...policy.allow_lower_level_limits };
return normalized;
}
function normalizePatchForSave(policy: PrivacyRetentionPolicyPatch, parentPolicy: PrivacyRetentionPolicy | null, scopeType: PrivacyRetentionPolicyScope): PrivacyRetentionPolicyPatch {
const normalized = normalizePatch(policy);
const allowPatch = { ...(normalized.allow_lower_level_limits ?? {}) };
for (const field of fieldDefinitions) {
if (field.systemOnly || scopeType === "campaign") delete allowPatch[field.key];
if (field.systemOnly) delete normalized[field.key as keyof PrivacyRetentionPolicyPatch];
if (parentPolicy && parentPolicy.allow_lower_level_limits[field.key] === false) {
delete normalized[field.key as keyof PrivacyRetentionPolicyPatch];
delete allowPatch[field.key];
}
}
if (Object.keys(allowPatch).length > 0) normalized.allow_lower_level_limits = allowPatch;
else delete normalized.allow_lower_level_limits;
return normalized;
}
function errorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return "Request failed";
}

View File

@@ -0,0 +1,272 @@
import { useMemo, useState } from "react";
import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import FieldLabel from "../../components/help/FieldLabel";
import StatusBadge from "../../components/StatusBadge";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
type TemplateRecord = {
id: string;
name: string;
description: string;
type: string;
status: string;
fields: string[];
updatedAt: string;
usedBy: string;
subject: string;
body: string;
versions: number;
};
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
}
];
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" }
]
}
];
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: 110, sticky: "end", render: (template) => <Button onClick={() => openTemplate(template.id)}>Open</Button> }
];
}
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> }
];
}
export default function TemplatesPage() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [active, setActive] = useState<TemplateDetailSection>("overview");
const selectedTemplate = useMemo(
() => templateRecords.find((template) => template.id === selectedId) ?? null,
[selectedId]
);
function openTemplate(templateId: string) {
setSelectedId(templateId);
setActive("overview");
}
if (selectedTemplate) {
return (
<div className="workspace module-workspace">
<ModuleSubnav active={active} groups={templateSubnav(() => setSelectedId(null))} onSelect={setActive} />
<section className="workspace-content">
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>{selectedTemplate.name}</PageTitle>
<p>{selectedTemplate.description}</p>
</div>
<div className="button-row compact-actions">
<Button disabled>Duplicate</Button>
<Button variant="primary" disabled>Use in campaign</Button>
</div>
</div>
{active === "overview" && <TemplateOverview template={selectedTemplate} />}
{active === "content" && <TemplateContent template={selectedTemplate} />}
{active === "fields" && <TemplateFields template={selectedTemplate} />}
{active === "preview" && <TemplatePreview template={selectedTemplate} />}
{active === "usage" && <TemplateUsage template={selectedTemplate} />}
{active === "versions" && <TemplateVersions template={selectedTemplate} />}
</div>
</section>
</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>
</div>
<div className="button-row compact-actions">
<Button disabled>Import</Button>
<Button variant="primary" disabled>Export</Button>
</div>
</div>
<Card
title={
<div className="module-card-heading">
<h2>All templates</h2>
<span>Last loaded: local demo data</span>
</div>
}
actions={<Button disabled>Refresh</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"
/>
</Card>
</div>
);
}
function TemplateOverview({ template }: { template: TemplateRecord }) {
return (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Template status">
<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>
</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>
<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>
</div>
</Card>
</div>
);
}
function TemplateContent({ template }: { template: TemplateRecord }) {
return (
<Card title="Template content" actions={<Button disabled>Save changes</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>
</div>
</Card>
);
}
function TemplateFields({ template }: { template: TemplateRecord }) {
return (
<Card title="Template fields" actions={<Button disabled>Add field hint</Button>}>
<DataGrid
id={`template-${template.id}-fields`}
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>
);
}
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");
return (
<Card title="Preview" actions={<Button disabled>Change sample data</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>
<pre>{preview}</pre>
</div>
</Card>
);
}
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>
<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>
</dl>
</Card>
);
}
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>}>
<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>
</div>
</Card>
);
}

10
webui/src/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import "./styles/campaign-workspace.css";
export { default } from "./module";
export * from "./module";
export * from "./api/campaigns";
export { default as AddressBookPage } from "./features/addressbook/AddressBookPage";
export { default as CampaignListPage } from "./features/campaigns/CampaignListPage";
export { default as CampaignWorkspace } from "./features/campaigns/CampaignWorkspace";
export { default as OperatorQueuePage } from "./features/operator/OperatorQueuePage";
export { default as TemplatesPage } from "./features/templates/TemplatesPage";
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";

View File

@@ -0,0 +1,4 @@
import { ModuleSubnav } from "@govoplan/core-webui";
export type { ModuleSubnavGroup, ModuleSubnavItem } from "@govoplan/core-webui";
export default ModuleSubnav;

View File

@@ -0,0 +1,52 @@
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: "Server settings" },
{ id: "global-settings", label: "Campaign settings" }
]
},
{
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 }]
}
];
export default function SectionSidebar({
active,
onSelect
}: {
active: CampaignWorkspaceSection;
onSelect: (section: CampaignWorkspaceSection) => void;
}) {
return <ModuleSubnav active={active} groups={campaignSubnav} onSelect={onSelect} />;
}

25
webui/src/module.ts Normal file
View File

@@ -0,0 +1,25 @@
import { Activity, FileText, Form, MailCheck, Users } from "lucide-react";
import type { PlatformWebModule } from "@govoplan/core-webui";
export const campaignModule: PlatformWebModule = {
id: "campaigns",
label: "Campaigns",
version: "1.0.0",
dependencies: ["access"],
optionalDependencies: ["files", "mail"],
navItems: [
{ to: "/campaigns", label: "Campaigns", icon: MailCheck, anyOf: ["campaigns:campaign:read"], order: 20 },
{
to: "/operator",
label: "Operator Queue",
icon: Activity,
anyOf: ["campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control", "campaigns:campaign:send"],
order: 30
},
{ to: "/reports", label: "Reports", icon: FileText, anyOf: ["campaigns:report:read"], order: 70 },
{ to: "/address-book", label: "Address Book", icon: Users, order: 80 },
{ to: "/templates", label: "Templates", icon: Form, order: 90 }
]
};
export default campaignModule;

File diff suppressed because it is too large Load Diff

1
webui/src/types.ts Normal file
View File

@@ -0,0 +1 @@
export type { ApiSettings, AuthInfo, CampaignListItem, CampaignWorkspaceSection, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext, PlatformWebModule, WizardStep } from "@govoplan/core-webui";

View File

@@ -0,0 +1 @@
export { insertAfter, moveArrayItem } from "@govoplan/core-webui";

View File

@@ -0,0 +1,101 @@
import { asArray, asRecord } from "../features/campaigns/utils/campaignView";
export type MailboxAddress = {
name?: string;
email: string;
};
export function normalizeEmailAddress(address: MailboxAddress): MailboxAddress {
return {
name: (address.name ?? "").trim(),
email: (address.email ?? "").trim().toLowerCase()
};
}
export function isValidEmailAddress(email: string): boolean {
const normalized = email.trim();
if (!normalized || normalized.length > 254) return false;
return /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(normalized);
}
export function addressFromRecord(value: unknown): MailboxAddress | null {
const record = asRecord(value);
const email = typeof record.email === "string" ? record.email.trim() : "";
if (!email) return null;
const name = typeof record.name === "string" ? record.name.trim() : "";
return normalizeEmailAddress({ name, email });
}
export function addressesFromValue(value: unknown): MailboxAddress[] {
if (Array.isArray(value)) {
return value.map(addressFromRecord).filter((item): item is MailboxAddress => Boolean(item));
}
const single = addressFromRecord(value);
return single ? [single] : [];
}
export function parseMailboxAddressText(input: string): MailboxAddress | null {
const text = input.trim().replace(/[;,]+$/g, "");
if (!text) return null;
const angleMatch = text.match(/^(.+?)\s*<\s*([^<>\s]+@[^<>\s]+)\s*>$/);
if (angleMatch) {
return normalizeEmailAddress({ name: cleanAddressName(angleMatch[1]), email: angleMatch[2] });
}
const emailMatch = text.match(/([^\s<>;,]+@[^\s<>;,]+\.[^\s<>;,]+)/);
if (!emailMatch) return null;
const email = emailMatch[1];
const name = cleanAddressName(`${text.slice(0, emailMatch.index)} ${text.slice((emailMatch.index ?? 0) + email.length)}`);
return normalizeEmailAddress({ name, email });
}
function cleanAddressName(value: string): string {
return value
.trim()
.replace(/[<>]/g, "")
.replace(/^[\s"'`]+|[\s"'`]+$/g, "")
.replace(/[;,]+$/g, "")
.trim();
}
export function collectCampaignAddressSuggestions(draft: Record<string, unknown> | null | undefined): MailboxAddress[] {
if (!draft) return [];
const suggestions: MailboxAddress[] = [];
const recipients = asRecord(draft.recipients);
const sender = addressFromRecord(recipients.from);
if (sender) suggestions.push(sender);
for (const key of ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]) {
suggestions.push(...addressesFromValue(recipients[key]));
}
const entries = asRecord(draft.entries);
for (const entryValue of asArray(entries.inline)) {
const entry = asRecord(entryValue);
const toAddresses = asArray(entry.to).map(addressFromRecord).filter((item): item is MailboxAddress => Boolean(item));
suggestions.push(...toAddresses);
const direct = addressFromRecord(entry.recipient) ?? addressFromRecord(entry);
if (direct) suggestions.push(direct);
}
return dedupeAddresses(suggestions);
}
export function dedupeAddresses(addresses: MailboxAddress[]): MailboxAddress[] {
const seen = new Map<string, MailboxAddress>();
addresses.map(normalizeEmailAddress).forEach((address) => {
if (!address.email) return;
const existing = seen.get(address.email);
if (!existing || (!existing.name && address.name)) {
seen.set(address.email, address);
}
});
return [...seen.values()].sort((left, right) => (left.name || left.email).localeCompare(right.name || right.email));
}
export function addressDisplayName(address: MailboxAddress): string {
const normalized = normalizeEmailAddress(address);
return normalized.name || normalized.email;
}

View File

@@ -0,0 +1 @@
export { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui";