Release v0.1.2
This commit is contained in:
@@ -13,10 +13,6 @@
|
||||
},
|
||||
"./styles/campaign-workspace.css": "./src/styles/campaign-workspace.css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.1",
|
||||
"lucide-react": "^0.555.0",
|
||||
@@ -25,7 +21,8 @@
|
||||
"react-router-dom": "^7.1.1"
|
||||
},
|
||||
"scripts": {
|
||||
"test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js"
|
||||
"test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js",
|
||||
"test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -1,519 +0,0 @@
|
||||
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" });
|
||||
}
|
||||
@@ -77,6 +77,21 @@ export type CampaignVersionDetail = CampaignVersionListItem & {
|
||||
campaign_json?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignWorkspaceResponse = {
|
||||
campaign: CampaignListItem | null;
|
||||
versions: CampaignVersionListItem[];
|
||||
current_version: CampaignVersionDetail | null;
|
||||
summary: CampaignSummary | null;
|
||||
selected_version_id?: string | null;
|
||||
};
|
||||
|
||||
export type CampaignWorkspaceQuery = {
|
||||
versionId?: string | null;
|
||||
includeCurrentVersion?: boolean;
|
||||
includeSummary?: boolean;
|
||||
includeVersions?: boolean;
|
||||
};
|
||||
|
||||
export type CampaignVersionUpdatePayload = {
|
||||
campaign_json?: Record<string, unknown> | null;
|
||||
current_flow?: string | null;
|
||||
@@ -157,6 +172,12 @@ export type CampaignSendNowPayload = {
|
||||
enqueue_imap_task?: boolean;
|
||||
};
|
||||
|
||||
export type CampaignAppendSentPayload = {
|
||||
dry_run?: boolean;
|
||||
enqueue_celery?: boolean;
|
||||
run_inline?: boolean;
|
||||
};
|
||||
|
||||
|
||||
export type CampaignAttachmentPreviewFile = {
|
||||
id: string;
|
||||
@@ -316,6 +337,24 @@ export async function getCampaignSchema(settings: ApiSettings): Promise<unknown>
|
||||
return apiFetch(settings, "/api/v1/schemas/campaign");
|
||||
}
|
||||
|
||||
export async function getCampaignSchemaUi(settings: ApiSettings): Promise<unknown> {
|
||||
return apiFetch(settings, "/api/v1/schemas/campaign/ui");
|
||||
}
|
||||
|
||||
export async function getCampaignWorkspace(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
options: CampaignWorkspaceQuery = {}
|
||||
): Promise<CampaignWorkspaceResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.versionId) params.set("version_id", options.versionId);
|
||||
if (options.includeCurrentVersion !== undefined) params.set("include_current_version", String(options.includeCurrentVersion));
|
||||
if (options.includeSummary !== undefined) params.set("include_summary", String(options.includeSummary));
|
||||
if (options.includeVersions !== undefined) params.set("include_versions", String(options.includeVersions));
|
||||
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return apiFetch<CampaignWorkspaceResponse>(settings, `/api/v1/campaigns/${campaignId}/workspace${suffix}`);
|
||||
}
|
||||
|
||||
export async function listCampaignVersions(
|
||||
settings: ApiSettings,
|
||||
campaignId: string
|
||||
@@ -642,11 +681,15 @@ export async function cancelCampaign(settings: ApiSettings, campaignId: string):
|
||||
export async function appendSent(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
dryRun = false
|
||||
payload: CampaignAppendSentPayload = {}
|
||||
): Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/append-sent`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ dry_run: dryRun })
|
||||
body: JSON.stringify({
|
||||
dry_run: payload.dry_run ?? false,
|
||||
enqueue_celery: payload.enqueue_celery ?? true,
|
||||
run_inline: payload.run_inline ?? false
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1,91 @@
|
||||
export * from "@govoplan/files-webui";
|
||||
import type { ApiSettings } from "../types";
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type FileSpace = {
|
||||
id: string;
|
||||
label: string;
|
||||
owner_type: "user" | "group";
|
||||
owner_id: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type FileShare = {
|
||||
id: string;
|
||||
target_type: string;
|
||||
target_id: string;
|
||||
permission: string;
|
||||
created_at: string;
|
||||
revoked_at?: string | null;
|
||||
};
|
||||
|
||||
export type ManagedFile = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
owner_type: "user" | "group";
|
||||
owner_id: string;
|
||||
display_path: string;
|
||||
filename: string;
|
||||
description?: string | null;
|
||||
size_bytes: number;
|
||||
content_type?: string | null;
|
||||
checksum_sha256: string;
|
||||
version_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at?: string | null;
|
||||
audit_relevant: boolean;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
shares?: FileShare[];
|
||||
};
|
||||
|
||||
export type FileListResponse = { files: ManagedFile[] };
|
||||
export type FileSpacesResponse = { spaces: FileSpace[] };
|
||||
export type FileFolder = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
owner_type: "user" | "group";
|
||||
owner_id: string;
|
||||
path: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at?: string | null;
|
||||
};
|
||||
export type FileFoldersResponse = { folders: FileFolder[] };
|
||||
export type PatternResolveResponse = {
|
||||
patterns: { pattern: string; matches: ManagedFile[] }[];
|
||||
unmatched: ManagedFile[];
|
||||
};
|
||||
|
||||
export function listFileSpaces(settings: ApiSettings): Promise<FileSpacesResponse> {
|
||||
return apiFetch<FileSpacesResponse>(settings, "/api/v1/files/spaces");
|
||||
}
|
||||
|
||||
export function listFolders(settings: ApiSettings, params: { owner_type: "user" | "group"; owner_id: string }): Promise<FileFoldersResponse> {
|
||||
const search = new URLSearchParams();
|
||||
search.set("owner_type", params.owner_type);
|
||||
search.set("owner_id", params.owner_id);
|
||||
return apiFetch<FileFoldersResponse>(settings, `/api/v1/files/folders?${search.toString()}`);
|
||||
}
|
||||
|
||||
export function listFiles(settings: ApiSettings, params: { owner_type?: string; owner_id?: string; campaign_id?: string; path_prefix?: string } = {}): Promise<FileListResponse> {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value) search.set(key, value);
|
||||
}
|
||||
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||
return apiFetch<FileListResponse>(settings, `/api/v1/files${suffix}`);
|
||||
}
|
||||
|
||||
export function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise<FileShare> {
|
||||
return apiFetch<FileShare>(settings, `/api/v1/files/${fileId}/shares`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ target_type: "campaign", target_id: campaignId, permission: "read" })
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveFilePatterns(
|
||||
settings: ApiSettings,
|
||||
payload: { patterns: string[]; owner_type?: "user" | "group"; owner_id?: string; campaign_id?: string; path_prefix?: string; include_unmatched?: boolean; case_sensitive?: boolean }
|
||||
): Promise<PatternResolveResponse> {
|
||||
return apiFetch<PatternResolveResponse>(settings, "/api/v1/files/resolve-patterns", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
@@ -1 +1,222 @@
|
||||
export * from "@govoplan/mail-webui";
|
||||
import type { ApiSettings } from "../types";
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type MailSecurity = "plain" | "tls" | "starttls";
|
||||
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign";
|
||||
|
||||
export type MailSmtpTestPayload = {
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
security?: MailSecurity;
|
||||
timeout_seconds?: number;
|
||||
};
|
||||
|
||||
export type MailImapTestPayload = MailSmtpTestPayload & {
|
||||
sent_folder?: string | null;
|
||||
};
|
||||
|
||||
export type MailTransportCredentialsPayload = {
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
};
|
||||
|
||||
export type MailServerProfileCredentialsPayload = {
|
||||
smtp?: MailTransportCredentialsPayload;
|
||||
imap?: MailTransportCredentialsPayload;
|
||||
};
|
||||
|
||||
export type MailConnectionTestResponse = {
|
||||
ok: boolean;
|
||||
protocol: "smtp" | "imap";
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
security?: MailSecurity | string | null;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type MailImapFolderResponse = {
|
||||
name: string;
|
||||
flags?: string[];
|
||||
};
|
||||
|
||||
export type MailImapFolderListResponse = {
|
||||
ok: boolean;
|
||||
protocol: "imap";
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
security?: MailSecurity | string | null;
|
||||
message: string;
|
||||
folders: MailImapFolderResponse[];
|
||||
detected_sent_folder?: string | null;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type MailServerProfile = {
|
||||
id: string;
|
||||
tenant_id?: string | null;
|
||||
scope_type: MailProfileScope;
|
||||
scope_id?: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
is_active: boolean;
|
||||
smtp: MailSmtpTestPayload;
|
||||
imap?: MailImapTestPayload | null;
|
||||
credentials?: MailServerProfileCredentialsPayload | null;
|
||||
smtp_password_configured: boolean;
|
||||
imap_password_configured: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
|
||||
|
||||
export const mailProfilePatternKeys = [
|
||||
"smtp_hosts",
|
||||
"imap_hosts",
|
||||
"envelope_senders",
|
||||
"from_headers",
|
||||
"recipient_domains"
|
||||
] as const;
|
||||
|
||||
export type MailProfilePatternKey = typeof mailProfilePatternKeys[number];
|
||||
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
|
||||
|
||||
export type MailCredentialPolicy = {
|
||||
inherit?: boolean | null;
|
||||
allow_override?: boolean | null;
|
||||
};
|
||||
|
||||
export type MailProfilePolicy = {
|
||||
allowed_profile_ids?: string[] | null;
|
||||
allow_user_profiles?: boolean | null;
|
||||
allow_group_profiles?: boolean | null;
|
||||
allow_campaign_profiles?: boolean | null;
|
||||
smtp_credentials?: MailCredentialPolicy | null;
|
||||
imap_credentials?: MailCredentialPolicy | null;
|
||||
whitelist?: MailProfilePatternRules | null;
|
||||
blacklist?: MailProfilePatternRules | null;
|
||||
};
|
||||
|
||||
export type PolicySourceStep = {
|
||||
scope_type: string;
|
||||
scope_id?: string | null;
|
||||
label: string;
|
||||
applied_fields?: string[];
|
||||
};
|
||||
|
||||
export type MailProfilePolicyResponse = {
|
||||
scope_type: MailProfileScope;
|
||||
scope_id?: string | null;
|
||||
policy: MailProfilePolicy;
|
||||
effective_policy?: MailProfilePolicy | null;
|
||||
parent_policy?: MailProfilePolicy | null;
|
||||
effective_policy_sources?: PolicySourceStep[];
|
||||
parent_policy_sources?: PolicySourceStep[];
|
||||
};
|
||||
|
||||
export type MailServerProfilePayload = {
|
||||
name: string;
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
is_active?: boolean;
|
||||
scope_type?: MailProfileScope;
|
||||
scope_id?: string | null;
|
||||
smtp: MailSmtpTestPayload;
|
||||
imap?: MailImapTestPayload | null;
|
||||
credentials?: MailServerProfileCredentialsPayload | null;
|
||||
};
|
||||
|
||||
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (includeInactive) params.set("include_inactive", "true");
|
||||
if (campaignId) params.set("campaign_id", campaignId);
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
const response = await apiFetch<MailServerProfileListResponse>(settings, `/api/v1/mail/profiles${suffix}`);
|
||||
return response.profiles ?? [];
|
||||
}
|
||||
|
||||
export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> {
|
||||
return apiFetch<MailServerProfile>(settings, "/api/v1/mail/profiles", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMailProfilePolicy(
|
||||
settings: ApiSettings,
|
||||
scopeType: MailProfileScope,
|
||||
scopeId?: string | null,
|
||||
campaignId?: string | null
|
||||
): Promise<MailProfilePolicyResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (scopeId) params.set("scope_id", scopeId);
|
||||
if (campaignId) params.set("campaign_id", campaignId);
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`);
|
||||
}
|
||||
|
||||
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
||||
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, { method: "POST" });
|
||||
}
|
||||
|
||||
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
||||
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, { method: "POST" });
|
||||
}
|
||||
|
||||
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
|
||||
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" });
|
||||
}
|
||||
|
||||
export async function testSmtpSettings(settings: ApiSettings, payload: MailSmtpTestPayload): Promise<MailConnectionTestResponse> {
|
||||
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-smtp", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function testImapSettings(settings: ApiSettings, payload: MailImapTestPayload): Promise<MailConnectionTestResponse> {
|
||||
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-imap", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function listImapFolders(settings: ApiSettings, payload: MailImapTestPayload): Promise<MailImapFolderListResponse> {
|
||||
return apiFetch<MailImapFolderListResponse>(settings, "/api/v1/mail/list-imap-folders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export type MockMailboxMessage = {
|
||||
id: string;
|
||||
kind: "smtp" | "imap_append" | string;
|
||||
created_at: string;
|
||||
envelope_from?: string | null;
|
||||
envelope_recipients?: string[];
|
||||
subject?: string | null;
|
||||
from_header?: string | null;
|
||||
to_header?: string | null;
|
||||
cc_header?: string | null;
|
||||
bcc_header?: string | null;
|
||||
message_id?: string | null;
|
||||
size_bytes?: number;
|
||||
body_preview?: string | null;
|
||||
attachment_count?: number;
|
||||
folder?: string | null;
|
||||
raw_eml?: string | null;
|
||||
headers?: Record<string, string>;
|
||||
attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>;
|
||||
};
|
||||
|
||||
export type MockMailboxMessageResponse = {
|
||||
message: MockMailboxMessage;
|
||||
};
|
||||
|
||||
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
|
||||
return apiFetch<MockMailboxMessageResponse>(settings, `/api/v1/dev/mailbox/messages/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
export default Button;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
export default Card;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
export default ConfirmDialog;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
export default Dialog;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
export default DismissibleAlert;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
export default FormField;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
export default LoadingFrame;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { LoadingIndicator } from "@govoplan/core-webui";
|
||||
export default LoadingIndicator;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { MetricCard } from "@govoplan/core-webui";
|
||||
export default MetricCard;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
export default PageTitle;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
export default StatusBadge;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { Stepper } from "@govoplan/core-webui";
|
||||
export default Stepper;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
export default ToggleSwitch;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { EmailAddressInput } from "@govoplan/core-webui";
|
||||
export default EmailAddressInput;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { FieldLabel } from "@govoplan/core-webui";
|
||||
export default FieldLabel;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { InlineHelp } from "@govoplan/core-webui";
|
||||
export default InlineHelp;
|
||||
@@ -1,7 +1,7 @@
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
|
||||
const personalContacts = [
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { usePlatformModuleInstalled } from "@govoplan/core-webui";
|
||||
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 { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
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 { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
@@ -20,7 +21,7 @@ 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 { insertAfter, moveArrayItem } from "@govoplan/core-webui";
|
||||
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
|
||||
import { recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
||||
|
||||
@@ -28,6 +29,7 @@ type PathChooserState = { index: number };
|
||||
type IndividualDisableState = { index: number; usageCount: number };
|
||||
|
||||
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
|
||||
const [fileSpaces, setFileSpaces] = useState<FileSpace[]>([]);
|
||||
@@ -61,12 +63,16 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filesModuleInstalled) {
|
||||
setFileSpaces([]);
|
||||
return;
|
||||
}
|
||||
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]);
|
||||
}, [filesModuleInstalled, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
function patchBasePaths(paths: AttachmentBasePath[]) {
|
||||
if (locked) return;
|
||||
@@ -220,7 +226,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => { window.location.href = "/files"; }}>Manage files</Button>
|
||||
{filesModuleInstalled && <Button onClick={() => { window.location.href = "/files"; }}>Manage files</Button>}
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
|
||||
</div>
|
||||
@@ -233,15 +239,17 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
<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"
|
||||
/>
|
||||
<div className="admin-table-surface attachment-sources-table-surface">
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-attachment-sources`}
|
||||
rows={basePaths}
|
||||
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, filesModuleInstalled, 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"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="ZIP attachments" collapsible>
|
||||
@@ -253,26 +261,28 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
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"
|
||||
/>
|
||||
<div className="admin-table-surface attachment-zip-table-surface">
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
{zipConfig.enabled && passwordFields.length === 0 && zipConfig.archives.some((archive) => archive.password_enabled) && (
|
||||
<DismissibleAlert tone="warning">No password field exists. Add one under Fields with field type <strong>Password</strong>.</DismissibleAlert>
|
||||
)}
|
||||
@@ -282,7 +292,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
<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">
|
||||
<Card title="Global Attachments" collapsible>
|
||||
<AttachmentRulesDataGrid
|
||||
id={`campaign-${campaignId}-global-attachments`}
|
||||
rules={globalRules}
|
||||
@@ -292,23 +302,21 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
zipConfig={zipConfig}
|
||||
filesModuleInstalled={filesModuleInstalled}
|
||||
onChange={(rules) => patch(["attachments", "global"], rules)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Statistics">
|
||||
<Card title="Statistics" collapsible>
|
||||
<dl className="detail-list">
|
||||
<div><dt>Base paths</dt><dd>{basePaths.length}</dd></div>
|
||||
<div><dt>Global attachments</dt><dd>direct: {globalSummary.direct} / rules: {globalSummary.rules}</dd></div>
|
||||
<div><dt>Per-recipient patterns</dt><dd>{individualRulesCount}</dd></div>
|
||||
<div><dt>Upload support</dt><dd>Connected through Files</dd></div>
|
||||
<div><dt>Upload support</dt><dd>{filesModuleInstalled ? "Connected through Files" : "Manual paths"}</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>
|
||||
<p className="muted small-note">{filesModuleInstalled ? "Files are managed in the top-level Files module. Upload there, share files with campaigns, then use these rules to resolve concrete attachments during review/build." : "The Files module is not installed. Use manual paths and patterns; managed file browsing and sharing are unavailable."}</p>
|
||||
</Card>
|
||||
|
||||
<div className="button-row page-bottom-actions">
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
|
||||
</div>
|
||||
</>
|
||||
</LoadingFrame>
|
||||
|
||||
@@ -340,7 +348,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)}
|
||||
/>
|
||||
|
||||
{pathChooser && (
|
||||
{pathChooser && filesModuleInstalled && (
|
||||
<ManagedFileChooser
|
||||
open
|
||||
settings={settings}
|
||||
@@ -546,6 +554,7 @@ type AttachmentSourceColumnContext = {
|
||||
locked: boolean;
|
||||
basePaths: AttachmentBasePath[];
|
||||
fileSpaces: FileSpace[];
|
||||
filesModuleInstalled: boolean;
|
||||
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
|
||||
setIndividualEligibility: (index: number, checked: boolean) => void;
|
||||
addBasePath: (afterIndex?: number) => void;
|
||||
@@ -554,7 +563,7 @@ type AttachmentSourceColumnContext = {
|
||||
setPathChooser: (state: PathChooserState | null) => void;
|
||||
};
|
||||
|
||||
function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
|
||||
function attachmentSourceColumns({ locked, basePaths, fileSpaces, filesModuleInstalled, 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 },
|
||||
{
|
||||
@@ -568,20 +577,23 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath,
|
||||
<div className="field-with-action split-field-action">
|
||||
<input
|
||||
className="chooser-display-input"
|
||||
value={formatAttachmentSourcePath(basePath, fileSpaces)}
|
||||
value={filesModuleInstalled ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path}
|
||||
disabled={locked}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
readOnly={filesModuleInstalled}
|
||||
tabIndex={filesModuleInstalled ? -1 : undefined}
|
||||
placeholder="attachments"
|
||||
onClick={() => !locked && setPathChooser({ index })}
|
||||
onChange={(event) => {
|
||||
if (!filesModuleInstalled) patchBasePath(index, { path: event.target.value, source: "" });
|
||||
}}
|
||||
onClick={() => filesModuleInstalled && !locked && setPathChooser({ index })}
|
||||
onKeyDown={(event) => {
|
||||
if (!locked && (event.key === "Enter" || event.key === " ")) {
|
||||
if (filesModuleInstalled && !locked && (event.key === "Enter" || event.key === " ")) {
|
||||
event.preventDefault();
|
||||
setPathChooser({ index });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>
|
||||
{filesModuleInstalled && <Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>}
|
||||
</div>
|
||||
),
|
||||
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
|
||||
export default function CampaignAuditPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
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 { Button } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
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 { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
|
||||
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
|
||||
|
||||
|
||||
import { insertAfter, moveArrayItem } from "@govoplan/core-webui";
|
||||
export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const fieldValueKeys = useRef<string[]>([]);
|
||||
@@ -170,21 +169,19 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
||||
|
||||
<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>
|
||||
<Card title="Campaign fields">
|
||||
<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>
|
||||
</Card>
|
||||
</>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { Card } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asRecord, getCampaignJson } from "./utils/campaignView";
|
||||
import { downloadJson, safeFileStem } from "./utils/draftEditor";
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate } from "@govoplan/core-webui";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import 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 { Card } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import { createNewCampaign, listCampaigns } from "../../api/campaigns";
|
||||
import type { CampaignListItem } from "../../types";
|
||||
@@ -112,9 +113,19 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 110,
|
||||
width: 70,
|
||||
sticky: "end",
|
||||
render: (campaign) => <Link to={`/campaigns/${campaign.id}`}><Button variant="primary">Open</Button></Link>
|
||||
align: "right",
|
||||
render: (campaign) => (
|
||||
<Link
|
||||
to={`/campaigns/${campaign.id}`}
|
||||
className="btn btn-primary admin-icon-button"
|
||||
aria-label={`Open ${campaign.name || campaign.external_id || campaign.id}`}
|
||||
title={`Open ${campaign.name || campaign.external_id || campaign.id}`}
|
||||
>
|
||||
<ExternalLink aria-hidden="true" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -135,7 +146,7 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Card title="Campaigns">
|
||||
<LoadingFrame loading={loading} label="Loading campaigns…">
|
||||
{campaigns.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
@@ -146,15 +157,17 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
||||
</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."
|
||||
/>
|
||||
<div className="admin-table-surface campaigns-table-surface">
|
||||
<DataGrid
|
||||
id="campaigns"
|
||||
rows={campaigns}
|
||||
columns={columns}
|
||||
getRowKey={(campaign) => campaign.id}
|
||||
initialSort={{ columnId: "updated", direction: "desc" }}
|
||||
className="campaign-table-wrap"
|
||||
emptyText="No campaigns found."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</LoadingFrame>
|
||||
</Card>
|
||||
|
||||
@@ -1,33 +1,39 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ExternalLink, LockKeyhole } from "lucide-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 { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { MetricCard } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import {
|
||||
lockCampaignVersionPermanently,
|
||||
lockCampaignVersionTemporarily,
|
||||
unlockCampaignVersionUserLock,
|
||||
updateCampaignMetadata,
|
||||
type CampaignVersionDetail,
|
||||
type CampaignVersionListItem,
|
||||
} from "../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import {
|
||||
asArray,
|
||||
asRecord,
|
||||
canUnlockValidationVersion,
|
||||
formatDateTime,
|
||||
getCampaignJson,
|
||||
isFinalLockedVersion,
|
||||
isPermanentUserLockedVersion,
|
||||
isTemporaryUserLockedVersion,
|
||||
isVersionReadyForDelivery,
|
||||
summaryValue,
|
||||
} from "./utils/campaignView";
|
||||
import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
||||
|
||||
const campaignModeOptions = ["draft", "test", "send"];
|
||||
type LockAction = "temporary" | "unlock" | "permanent";
|
||||
@@ -43,6 +49,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
|
||||
const [lockBusy, setLockBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const versionMetrics = useMemo(() => campaignVersionMetrics(data.currentVersion), [data.currentVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!campaign || identityDirty) return;
|
||||
@@ -125,6 +132,13 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
||||
|
||||
<LoadingFrame loading={loading} label="Loading campaign overview…">
|
||||
<div className="metric-grid campaign-overview-metrics current-version-metrics">
|
||||
<MetricCard label="Version" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
|
||||
<MetricCard label="Fields" value={versionMetrics.fieldCount} tone="info" />
|
||||
<MetricCard label="Recipients" value={versionMetrics.recipientCount} tone="neutral" detail="Active inline recipients" />
|
||||
<MetricCard label="Template health" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} />
|
||||
</div>
|
||||
|
||||
<div className="metric-grid campaign-overview-metrics">
|
||||
<MetricCard label="Queueable" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="Ready or warning" />
|
||||
<MetricCard label="Needs attention" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="Review first" />
|
||||
@@ -132,7 +146,23 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
<MetricCard label="Failed" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
|
||||
</div>
|
||||
|
||||
<Card title="Campaign identity">
|
||||
<Card title="Current version state" actions={<Link
|
||||
to={`send?version=${campaign?.current_version_id}`}
|
||||
className={`btn btn-primary`}
|
||||
aria-label={`Open curent version`}
|
||||
title={`Open curent version`}
|
||||
>
|
||||
Open
|
||||
</Link>}>
|
||||
<div className="summary-grid overview-summary-grid">
|
||||
<SummaryTile label="Validation errors" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
|
||||
<SummaryTile label="Warnings" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
|
||||
<SummaryTile label="Built messages" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
|
||||
<SummaryTile label="Jobs total" value={data.summary?.cards?.jobs_total ?? "—"} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Campaign identity" collapsible>
|
||||
<div className="form-grid campaign-identity-grid">
|
||||
<FormField label="Campaign ID">
|
||||
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
|
||||
@@ -151,25 +181,18 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
</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 ?? "—"} />
|
||||
<Card title="Version history" collapsible>
|
||||
<div className="admin-table-surface">
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
@@ -188,6 +211,63 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
);
|
||||
}
|
||||
|
||||
type TemplateHealthTone = "neutral" | "good" | "warning" | "danger" | "info";
|
||||
|
||||
type CampaignVersionMetrics = {
|
||||
fieldCount: number;
|
||||
recipientCount: number;
|
||||
templateHealthValue: string;
|
||||
templateHealthTone: TemplateHealthTone;
|
||||
templateHealthDetail: string;
|
||||
};
|
||||
|
||||
function campaignVersionMetrics(version: CampaignVersionDetail | null): CampaignVersionMetrics {
|
||||
const campaignJson = getCampaignJson(version);
|
||||
const fields = asArray(campaignJson.fields).map(asRecord);
|
||||
const entries = asRecord(campaignJson.entries);
|
||||
const inlineEntries = asArray(entries.inline).map(asRecord);
|
||||
const template = asRecord(campaignJson.template);
|
||||
const globalValues = asRecord(campaignJson.global_values);
|
||||
const bodyMode = normalizeTemplateBodyMode(textValue(template.body_mode, "both"));
|
||||
const subject = textValue(template.subject);
|
||||
const textBody = bodyMode !== "html" ? textValue(template.text) : "";
|
||||
const htmlBody = bodyMode !== "text" ? textValue(template.html) : "";
|
||||
const subjectOk = subject.trim().length > 0;
|
||||
const bodyOk = bodyMode === "html" ? htmlBody.trim().length > 0 : bodyMode === "text" ? textBody.trim().length > 0 : Boolean(textBody.trim() || htmlBody.trim());
|
||||
const localFieldNames = fields.map((field) => textValue(field.name || field.id)).filter(Boolean);
|
||||
const globalFieldNames = Object.keys(globalValues).filter(Boolean);
|
||||
const addressFieldNames = recipientAddressTemplateFieldOptions().map((field) => field.name);
|
||||
const localAvailableNames = new Set([...localFieldNames, ...addressFieldNames]);
|
||||
const globalAvailableNames = new Set(globalFieldNames);
|
||||
const allAvailableNames = new Set([...localAvailableNames, ...globalAvailableNames]);
|
||||
const placeholders = extractTemplatePlaceholders([subject, textBody, htmlBody].join("\n"));
|
||||
const undefinedPlaceholders = buildUndefinedPlaceholders(placeholders, allAvailableNames, {
|
||||
local: localAvailableNames,
|
||||
global: globalAvailableNames
|
||||
});
|
||||
const placeholdersOk = undefinedPlaceholders.length === 0;
|
||||
const score = [subjectOk, bodyOk, placeholdersOk].filter(Boolean).length;
|
||||
return {
|
||||
fieldCount: localFieldNames.length,
|
||||
recipientCount: inlineEntries.filter((entry) => entry.active !== false).length,
|
||||
templateHealthValue: `${score}/3`,
|
||||
templateHealthTone: score === 3 ? "good" : score === 2 ? "warning" : "danger",
|
||||
templateHealthDetail: [
|
||||
subjectOk ? "Subject OK" : "Subject missing",
|
||||
bodyOk ? "Body OK" : "Body missing",
|
||||
placeholdersOk ? "Placeholders OK" : `${undefinedPlaceholders.length} undefined`
|
||||
].join(" · ")
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTemplateBodyMode(value: string): "text" | "html" | "both" {
|
||||
return value === "text" || value === "html" || value === "both" ? value : "both";
|
||||
}
|
||||
|
||||
function textValue(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
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 },
|
||||
@@ -199,20 +279,34 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 310,
|
||||
width: 260,
|
||||
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>
|
||||
<Link
|
||||
to={`send?version=${version.id}`}
|
||||
className={`btn ${isCurrent ? "btn-primary" : "btn-secondary"} admin-icon-button`}
|
||||
aria-label={`Open version ${version.version_number}`}
|
||||
title={`Open version ${version.version_number}`}
|
||||
>
|
||||
<ExternalLink aria-hidden="true" />
|
||||
</Link>
|
||||
{isCurrent && (isTemporaryUserLockedVersion(version) ? (
|
||||
<>
|
||||
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>Unlock</Button>
|
||||
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>Lock permanently</Button>
|
||||
</>
|
||||
) : !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ? (
|
||||
<Button onClick={() => setPendingLockAction({ version, action: "temporary" })}>Lock</Button>
|
||||
<Button
|
||||
className="admin-icon-button"
|
||||
onClick={() => setPendingLockAction({ version, action: "temporary" })}
|
||||
aria-label={`Temporarily lock version ${version.version_number}`}
|
||||
title="Temporarily lock version"
|
||||
>
|
||||
<LockKeyhole aria-hidden="true" />
|
||||
</Button>
|
||||
) : null)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,16 +11,16 @@ import {
|
||||
type CampaignJobDetailResponse,
|
||||
type CampaignJobsResponse,
|
||||
} from "../../api/campaigns";
|
||||
import Card from "../../components/Card";
|
||||
import Button from "../../components/Button";
|
||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
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 { Dialog } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asRecord, formatDateTime, humanize, stringifyPreview } from "./utils/campaignView";
|
||||
|
||||
|
||||
@@ -17,18 +17,20 @@ import SendWizard from "./wizard/SendWizard";
|
||||
import CampaignJsonView from "./CampaignJsonView";
|
||||
import CampaignReportPage from "./CampaignReportPage";
|
||||
import CampaignAuditPage from "./CampaignAuditPage";
|
||||
import { CampaignUnsavedChangesProvider, useCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
|
||||
import { useCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
|
||||
|
||||
const sectionPaths: Record<CampaignWorkspaceSection, string> = {
|
||||
overview: "",
|
||||
campaign: "recipients",
|
||||
"global-settings": "global-settings",
|
||||
policies: "policies",
|
||||
fields: "fields",
|
||||
recipients: "recipients",
|
||||
"recipient-data": "recipient-data",
|
||||
template: "template",
|
||||
files: "files",
|
||||
"mail-settings": "mail-settings",
|
||||
"mail-policy": "mail-policy",
|
||||
review: "review",
|
||||
report: "report",
|
||||
audit: "audit",
|
||||
@@ -36,11 +38,7 @@ const sectionPaths: Record<CampaignWorkspaceSection, string> = {
|
||||
};
|
||||
|
||||
export default function CampaignWorkspace({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
||||
return (
|
||||
<CampaignUnsavedChangesProvider>
|
||||
<CampaignWorkspaceInner settings={settings} auth={auth} />
|
||||
</CampaignUnsavedChangesProvider>
|
||||
);
|
||||
return <CampaignWorkspaceInner settings={settings} auth={auth} />;
|
||||
}
|
||||
|
||||
function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
||||
@@ -91,9 +89,13 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
||||
<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="mail-settings" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} view="settings" />} />
|
||||
<Route path="mail-policy" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} view="policy" />} />
|
||||
<Route path="mail-policies" element={<Navigate to="../mail-policy" replace />} />
|
||||
<Route path="server-settings" element={<Navigate to="../mail-settings" replace />} />
|
||||
<Route path="global-settings" element={<GlobalSettingsPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
|
||||
<Route path="global-settings" element={<GlobalSettingsPage settings={settings} auth={auth} campaignId={campaignId || ""} view="settings" />} />
|
||||
<Route path="policies" element={<GlobalSettingsPage settings={settings} auth={auth} campaignId={campaignId || ""} view="policy" />} />
|
||||
<Route path="policy" element={<Navigate to="../policies" replace />} />
|
||||
<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 || ""} />} />
|
||||
@@ -122,12 +124,14 @@ function sectionFromPath(pathname: string): CampaignWorkspaceSection {
|
||||
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 === "policies" || section === "policy") return "policies";
|
||||
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 === "mail-policy" || section === "mail-policies") return "mail-policy";
|
||||
if (section === "review") return "review";
|
||||
if (section === "send") return "review";
|
||||
if (section === "report" || section === "reports") return "report";
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useState } from "react";
|
||||
import { useState, type ReactNode } 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 { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import 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 { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { hasScope } from "@govoplan/core-webui";
|
||||
import { RetentionPolicyEditor } from "../privacy/RetentionPolicyManagement";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
@@ -20,10 +20,19 @@ import { cloneJson, getBool, getNumber, getText, updateNested } from "./utils/dr
|
||||
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
|
||||
|
||||
type EditorState = Record<string, unknown>;
|
||||
type SettingsView = "settings" | "policy";
|
||||
|
||||
export default function GlobalSettingsPage({ settings, auth, campaignId }: { settings: ApiSettings; auth: AuthInfo; campaignId: string }) {
|
||||
type GlobalSettingsPageProps = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
campaignId: string;
|
||||
view?: SettingsView;
|
||||
};
|
||||
|
||||
export default function GlobalSettingsPage({ settings, auth, campaignId, view = "settings" }: GlobalSettingsPageProps) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [editorState, setEditorState] = useState<EditorState>({});
|
||||
const isPolicyView = view === "policy";
|
||||
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
@@ -34,9 +43,11 @@ export default function GlobalSettingsPage({ settings, auth, campaignId }: { set
|
||||
locked,
|
||||
reload,
|
||||
setError,
|
||||
currentStep: "global-settings",
|
||||
unsavedTitle: "Unsaved global settings",
|
||||
unsavedMessage: "Policies have unsaved changes. Save them before leaving, or discard them and continue.",
|
||||
currentStep: isPolicyView ? "policies" : "global-settings",
|
||||
unsavedTitle: isPolicyView ? "Unsaved campaign policy changes" : "Unsaved campaign settings",
|
||||
unsavedMessage: isPolicyView
|
||||
? "Campaign policies have unsaved changes. Save them before leaving, or discard them and continue."
|
||||
: "Campaign settings have unsaved changes. Save them before leaving, or discard them and continue.",
|
||||
extraPayload: () => ({ editor_state: editorState }),
|
||||
onLoaded: (loadedVersion) => setEditorState(cloneJson(loadedVersion.editor_state ?? {})),
|
||||
onSaved: (savedVersion) => setEditorState(cloneJson(savedVersion.editor_state ?? editorState))
|
||||
@@ -50,8 +61,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId }: { set
|
||||
const optIns = asRecord(editorState.opt_ins);
|
||||
const canReadRetentionPolicy = hasScope(auth, "admin:policies:read");
|
||||
const canWriteRetentionPolicy = hasScope(auth, "admin:policies:write");
|
||||
|
||||
|
||||
const pageTitle = isPolicyView ? "Campaign policies" : "Campaign settings";
|
||||
|
||||
function patchEditor(path: string[], value: unknown) {
|
||||
if (locked) return;
|
||||
@@ -59,12 +69,11 @@ export default function GlobalSettingsPage({ settings, auth, campaignId }: { set
|
||||
markDirty();
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Campaign settings</PageTitle>
|
||||
<PageTitle loading={loading}>{pageTitle}</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
@@ -78,81 +87,161 @@ export default function GlobalSettingsPage({ settings, auth, campaignId }: { set
|
||||
{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} />}
|
||||
{isPolicyView ? (
|
||||
<>
|
||||
{canReadRetentionPolicy && (
|
||||
<RetentionPolicyEditor
|
||||
settings={settings}
|
||||
scopeType="campaign"
|
||||
scopeId={campaignId}
|
||||
title="Campaign retention policy"
|
||||
description="Campaign-level retention limits applied after system, tenant and owner policy. Blank values inherit."
|
||||
canWrite={canWriteRetentionPolicy}
|
||||
locked={locked}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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 below-grid">
|
||||
<Card title="Validation policy" collapsible>
|
||||
<PolicyTable>
|
||||
<PolicyRow
|
||||
label="Missing required attachment"
|
||||
note="Required attachment rule found no matching file."
|
||||
control={<PolicySelectControl value={getText(validationPolicy, "missing_required_attachment", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_required_attachment"], value)} />}
|
||||
effective={behaviorSummary(getText(validationPolicy, "missing_required_attachment", "ask"))}
|
||||
/>
|
||||
<PolicyRow
|
||||
label="Missing optional attachment"
|
||||
note="Optional attachment rule found no matching file."
|
||||
control={<PolicySelectControl value={getText(validationPolicy, "missing_optional_attachment", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_optional_attachment"], value)} />}
|
||||
effective={behaviorSummary(getText(validationPolicy, "missing_optional_attachment", "warn"))}
|
||||
/>
|
||||
<PolicyRow
|
||||
label="Ambiguous attachment match"
|
||||
note="Attachment rule matched more files than expected."
|
||||
control={<PolicySelectControl value={getText(validationPolicy, "ambiguous_attachment_match", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "ambiguous_attachment_match"], value)} />}
|
||||
effective={behaviorSummary(getText(validationPolicy, "ambiguous_attachment_match", "ask"))}
|
||||
/>
|
||||
<PolicyRow
|
||||
label="Unsent attachment file"
|
||||
note="A watched attachment directory contains files that were not selected."
|
||||
control={<PolicySelectControl value={getText(validationPolicy, "unsent_attachment_files", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "unsent_attachment_files"], value)} />}
|
||||
effective={behaviorSummary(getText(validationPolicy, "unsent_attachment_files", "warn"))}
|
||||
/>
|
||||
<PolicyRow
|
||||
label="Missing email address"
|
||||
note="The effective recipient list has no To address."
|
||||
control={<PolicySelectControl value={getText(validationPolicy, "missing_email", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_email"], value)} options={["block", "drop"]} />}
|
||||
effective={behaviorSummary(getText(validationPolicy, "missing_email", "block"))}
|
||||
/>
|
||||
<PolicyRow
|
||||
label="Template error"
|
||||
note="A selected template body contains unresolved placeholders."
|
||||
control={<PolicySelectControl value={getText(validationPolicy, "template_error", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "template_error"], value)} options={["block", "drop"]} />}
|
||||
effective={behaviorSummary(getText(validationPolicy, "template_error", "block"))}
|
||||
/>
|
||||
<PolicyRow
|
||||
label="Ignore empty fields"
|
||||
note="Controls how missing placeholder values are rendered."
|
||||
control={<ToggleSwitch label="Enabled" checked={getBool(validationPolicy, "ignore_empty_fields")} disabled={locked} onChange={(checked) => patch(["validation_policy", "ignore_empty_fields"], checked)} />}
|
||||
effective={getBool(validationPolicy, "ignore_empty_fields") ? "Missing values render as empty text." : "Missing values remain visible and can trigger template errors."}
|
||||
/>
|
||||
</PolicyTable>
|
||||
</Card>
|
||||
|
||||
<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 policy" collapsible>
|
||||
<PolicyTable>
|
||||
<PolicyRow
|
||||
label="Default missing behavior"
|
||||
note="Used by attachment rules that do not override missing-file behavior."
|
||||
control={<PolicySelectControl value={getText(attachments, "missing_behavior", "ask")} disabled={locked} onChange={(value) => patch(["attachments", "missing_behavior"], value)} />}
|
||||
effective={behaviorSummary(getText(attachments, "missing_behavior", "ask"))}
|
||||
/>
|
||||
<PolicyRow
|
||||
label="Default ambiguous behavior"
|
||||
note="Used by attachment rules that do not override ambiguous-match behavior."
|
||||
control={<PolicySelectControl value={getText(attachments, "ambiguous_behavior", "ask")} disabled={locked} onChange={(value) => patch(["attachments", "ambiguous_behavior"], value)} />}
|
||||
effective={behaviorSummary(getText(attachments, "ambiguous_behavior", "ask"))}
|
||||
/>
|
||||
<PolicyRow
|
||||
label="Send without attachments"
|
||||
note="Campaign-wide fallback when no attachment is available."
|
||||
control={<ToggleSwitch label="Allowed" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />}
|
||||
effective={getBool(attachments, "send_without_attachments", true) ? "Messages may be sent when attachment rules allow it." : "Missing attachment coverage blocks sending."}
|
||||
/>
|
||||
</PolicyTable>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{data.campaign && <CampaignAccessCard settings={settings} campaign={data.campaign} onChanged={reload} onError={setError} />}
|
||||
|
||||
<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" collapsible>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<FormField label="Messages per minute"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
|
||||
<FormField label="Concurrency"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
|
||||
<FormField label="Max attempts"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
|
||||
<ToggleSwitch label="Status tracking" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
</>
|
||||
<Card title="Opt-ins and local assistance" collapsible>
|
||||
<div className="toggle-grid">
|
||||
<ToggleSwitch label="Suggest addresses from this campaign" checked={getBool(optIns, "campaign_address_suggestions", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "campaign_address_suggestions"], checked)} />
|
||||
<ToggleSwitch label="Remember newly used addresses" checked={getBool(optIns, "remember_used_addresses")} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "remember_used_addresses"], checked)} />
|
||||
<ToggleSwitch label="Show guided warnings while editing" checked={getBool(optIns, "inline_guidance", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "inline_guidance"], checked)} />
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicySelect({ label, value, disabled, onChange, options = behaviorOptions }: { label: string; value: string; disabled?: boolean; onChange: (value: string) => void; options?: string[] }) {
|
||||
function PolicyTable({ children }: { children: ReactNode }) {
|
||||
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>
|
||||
<div className="campaign-policy-table policy-table">
|
||||
<div className="campaign-policy-row policy-row campaign-policy-row-header policy-row-header">
|
||||
<span>Policy</span>
|
||||
<span>Setting</span>
|
||||
<span>Effective behavior</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyRow({ label, note, control, effective }: { label: string; note: string; control: ReactNode; effective: string }) {
|
||||
return (
|
||||
<div className="campaign-policy-row policy-row">
|
||||
<div className="campaign-policy-label policy-field-label">
|
||||
<strong>{label}</strong>
|
||||
<small>{note}</small>
|
||||
</div>
|
||||
<div className="campaign-policy-control policy-control">{control}</div>
|
||||
<p className="muted small-note campaign-policy-effective-note policy-effective-note">{effective}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicySelectControl({ value, disabled, onChange, options = behaviorOptions }: { value: string; disabled?: boolean; onChange: (value: string) => void; options?: string[] }) {
|
||||
return (
|
||||
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function behaviorSummary(value: string): string {
|
||||
if (value === "block") return "Blocks the affected message.";
|
||||
if (value === "ask") return "Marks the message for review.";
|
||||
if (value === "drop") return "Excludes the affected message.";
|
||||
if (value === "continue") return "Continues without a review issue.";
|
||||
if (value === "warn") return "Keeps sending possible with a warning.";
|
||||
return "Uses the configured behavior.";
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { MailServerSettingsPanel, type MailServerConnectionTestResult, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { MailServerSettingsPanel, addressesFromValue, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTransportCredentialsPayloadFromRecords, usePlatformModuleInstalled, usePlatformUiCapability, type MailProfilesUiCapability, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui";
|
||||
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 { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import { MailProfilePolicyEditor } from "../mail/MailProfileManagement";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import {
|
||||
createMailServerProfile,
|
||||
getMailProfilePolicy,
|
||||
@@ -26,13 +25,25 @@ import {
|
||||
} from "../../api/mail";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import { getBool, getNumber, getText } from "./utils/draftEditor";
|
||||
import { campaignMailSettingsPolicyState } from "./policyUi";
|
||||
|
||||
const securityOptions = ["plain", "tls", "starttls"];
|
||||
const securityOptions = mailServerSecurityOptions as readonly MailSecurity[];
|
||||
|
||||
export default function MailSettingsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
type MailSettingsView = "settings" | "policy";
|
||||
|
||||
type MailSettingsPageProps = {
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
view?: MailSettingsView;
|
||||
};
|
||||
|
||||
export default function MailSettingsPage({ settings, campaignId, view = "settings" }: MailSettingsPageProps) {
|
||||
const mailModuleInstalled = usePlatformModuleInstalled("mail");
|
||||
const isPolicyView = view === "policy";
|
||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||
const MailProfilePolicyEditor = mailProfilesUi?.MailProfilePolicyEditor ?? null;
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||
@@ -55,35 +66,84 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
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."
|
||||
currentStep: isPolicyView ? "mail-policy" : "mail-settings",
|
||||
unsavedTitle: isPolicyView ? "Unsaved mail policy changes" : "Unsaved mail settings",
|
||||
unsavedMessage: isPolicyView
|
||||
? "Mail policy changes have unsaved draft changes. Save them before leaving, or discard them and continue."
|
||||
: "Mail settings have unsaved changes. Save them before leaving, or discard them and continue."
|
||||
});
|
||||
const server = asRecord(displayDraft.server);
|
||||
const smtp = asRecord(server.smtp);
|
||||
const imap = asRecord(server.imap);
|
||||
const credentials = asRecord(server.credentials);
|
||||
const smtpCredentials = asRecord(credentials.smtp);
|
||||
const imapCredentials = asRecord(credentials.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 selectedProfileId = mailModuleInstalled ? getText(server, "mail_profile_id") : "";
|
||||
const selectedProfile = mailModuleInstalled ? mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null : null;
|
||||
const usingMailProfile = mailModuleInstalled && Boolean(selectedProfileId);
|
||||
const selectedProfileHasImap = Boolean(selectedProfile?.imap);
|
||||
const effectiveImapAvailable = usingMailProfile ? selectedProfileHasImap : imapEnabled;
|
||||
const imapUnavailable = usingMailProfile && !selectedProfileHasImap;
|
||||
const smtpCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.smtp_credentials?.inherit !== false : false;
|
||||
const imapCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.imap_credentials?.inherit !== false : false;
|
||||
const mailPolicyState = campaignMailSettingsPolicyState({ effectivePolicy: effectiveMailPolicy, selectedProfileId, locked });
|
||||
const effectiveMailPolicyForState: MailProfilePolicy | null = mailModuleInstalled ? effectiveMailPolicy : { allow_campaign_profiles: true };
|
||||
const mailPolicyState = campaignMailSettingsPolicyState({ effectivePolicy: effectiveMailPolicyForState, selectedProfileId, locked });
|
||||
const campaignProfilesAllowed = mailPolicyState.campaignProfilesAllowed;
|
||||
const inlineMailSettingsBlocked = mailPolicyState.inlineMailSettingsBlocked;
|
||||
const smtpDisabled = locked || usingMailProfile || inlineMailSettingsBlocked;
|
||||
const smtpCredentialDisabled = locked || inlineMailSettingsBlocked || (usingMailProfile && smtpCredentialsInherited);
|
||||
const imapServerDisabled = locked || inlineMailSettingsBlocked || usingMailProfile || !imapEnabled;
|
||||
const imapCredentialDisabled = locked || inlineMailSettingsBlocked || !effectiveImapAvailable || (usingMailProfile && imapCredentialsInherited);
|
||||
const imapDisabled = locked || inlineMailSettingsBlocked || !effectiveImapAvailable;
|
||||
const imapServerDisabled = locked || inlineMailSettingsBlocked || usingMailProfile;
|
||||
const imapCredentialDisabled = locked || inlineMailSettingsBlocked || imapUnavailable || (usingMailProfile && imapCredentialsInherited);
|
||||
const imapDisabled = locked || inlineMailSettingsBlocked || imapUnavailable;
|
||||
const imapAppendEnabled = getBool(imapAppend, "enabled");
|
||||
const appendTargetFolderDisabled = imapDisabled || !imapAppendEnabled;
|
||||
const inlinePolicyMessages = useMemo(() => {
|
||||
const validateMailPolicy = mailProfilesUi?.validateMailPolicy;
|
||||
if (!mailModuleInstalled || usingMailProfile || !validateMailPolicy || !effectiveMailPolicy) return [];
|
||||
const recipients = asRecord(displayDraft.recipients);
|
||||
const fromEmail = firstAddressEmail(recipients.from);
|
||||
return validateMailPolicy(effectiveMailPolicy, {
|
||||
smtpHost: getText(smtp, "host"),
|
||||
imapHost: getText(imap, "host"),
|
||||
envelopeSender: fromEmail || getText(smtpCredentials, "username", getText(smtp, "username")),
|
||||
fromHeader: fromEmail,
|
||||
recipientDomains: collectRecipientDomains(displayDraft)
|
||||
});
|
||||
}, [displayDraft, effectiveMailPolicy, imap, mailModuleInstalled, mailProfilesUi, smtp, smtpCredentials, usingMailProfile]);
|
||||
const displayedSmtp = {
|
||||
host: usingMailProfile && selectedProfile ? stringOrEmpty(selectedProfile.smtp.host) : getText(smtp, "host"),
|
||||
port: usingMailProfile && selectedProfile ? selectedProfile.smtp.port ?? 587 : getNumber(smtp, "port", 587),
|
||||
username: usingMailProfile && smtpCredentialsInherited ? profileUsername(selectedProfile, "smtp") : getText(smtpCredentials, "username", getText(smtp, "username")),
|
||||
password: usingMailProfile && smtpCredentialsInherited ? "" : getText(smtpCredentials, "password", getText(smtp, "password")),
|
||||
security: usingMailProfile && selectedProfile ? selectedProfile.smtp.security ?? "starttls" : getText(smtp, "security", "starttls"),
|
||||
timeout_seconds: usingMailProfile && selectedProfile ? selectedProfile.smtp.timeout_seconds ?? 30 : getNumber(smtp, "timeout_seconds", 30)
|
||||
};
|
||||
const displayedImap = {
|
||||
host: usingMailProfile && selectedProfile?.imap ? stringOrEmpty(selectedProfile.imap.host) : getText(imap, "host"),
|
||||
port: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.port ?? 993 : getNumber(imap, "port", 993),
|
||||
username: usingMailProfile && imapCredentialsInherited ? profileUsername(selectedProfile, "imap") : getText(imapCredentials, "username", getText(imap, "username")),
|
||||
password: usingMailProfile && imapCredentialsInherited ? "" : getText(imapCredentials, "password", getText(imap, "password")),
|
||||
security: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.security ?? "tls" : getText(imap, "security", "tls"),
|
||||
sent_folder: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.sent_folder ?? "auto" : getText(imap, "sent_folder", "auto"),
|
||||
timeout_seconds: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.timeout_seconds ?? 30 : getNumber(imap, "timeout_seconds", 30)
|
||||
};
|
||||
const selectedProfileNeedsLocalCredentials = usingMailProfile && (!smtpCredentialsInherited || (selectedProfileHasImap && !imapCredentialsInherited));
|
||||
|
||||
useEffect(() => { void refreshMailProfiles(); }, [settings.apiBaseUrl, settings.apiKey, campaignId]);
|
||||
useEffect(() => {
|
||||
if (!mailModuleInstalled) {
|
||||
setMailProfiles([]);
|
||||
setPolicyProfiles([]);
|
||||
setEffectiveMailPolicy({ allow_campaign_profiles: true });
|
||||
setProfileError("");
|
||||
setProfilesLoading(false);
|
||||
return;
|
||||
}
|
||||
void refreshMailProfiles();
|
||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, campaignId, mailModuleInstalled]);
|
||||
|
||||
async function refreshMailProfiles() {
|
||||
if (!mailModuleInstalled) return;
|
||||
setProfilesLoading(true);
|
||||
setProfileError("");
|
||||
try {
|
||||
@@ -106,7 +166,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
}
|
||||
|
||||
function selectMailProfile(profileId: string) {
|
||||
if (locked) return;
|
||||
if (!mailModuleInstalled || locked) return;
|
||||
if (!profileId && !campaignProfilesAllowed) {
|
||||
setProfileError(mailPolicyState.inlineBlockedMessage);
|
||||
return;
|
||||
@@ -117,6 +177,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
if (profileId) {
|
||||
patch(["server", "smtp"], {});
|
||||
patch(["server", "imap"], {});
|
||||
patch(["server", "credentials"], {});
|
||||
setSmtpTestResult(null);
|
||||
setImapTestResult(null);
|
||||
setFolderResult(null);
|
||||
@@ -124,7 +185,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
}
|
||||
|
||||
async function saveCurrentSettingsAsProfile() {
|
||||
if (locked || usingMailProfile || !campaignProfilesAllowed) return;
|
||||
if (!mailModuleInstalled || locked || usingMailProfile || !campaignProfilesAllowed) return;
|
||||
setProfilesLoading(true);
|
||||
setProfileMessage("");
|
||||
setProfileError("");
|
||||
@@ -133,14 +194,16 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
name: profileName.trim() || `${data.campaign?.name || "Campaign"} mail profile`,
|
||||
scope_type: "campaign",
|
||||
scope_id: campaignId,
|
||||
smtp: smtpPayload(),
|
||||
imap: imapEnabled ? imapPayload() : null,
|
||||
smtp: smtpServerPayload(),
|
||||
imap: hasInlineImapSettings() ? imapServerPayload() : null,
|
||||
credentials: mailProfileCredentialsPayload(false),
|
||||
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"], {});
|
||||
patch(["server", "credentials"], {});
|
||||
setProfileName("");
|
||||
setProfileMessage(`Saved profile ${created.name}.`);
|
||||
} catch (err) {
|
||||
@@ -150,30 +213,30 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
}
|
||||
}
|
||||
|
||||
function toggleImap(enabled: boolean) {
|
||||
patch(["server", "imap", "enabled"], enabled);
|
||||
if (!enabled) {
|
||||
patch(["delivery", "imap_append_sent", "enabled"], false);
|
||||
}
|
||||
}
|
||||
|
||||
function patchSmtpSettings(patchValue: Partial<MailServerSmtpSettings>) {
|
||||
if (patchValue.host !== undefined) patch(["server", "smtp", "host"], String(patchValue.host ?? ""));
|
||||
if (patchValue.port !== undefined) patch(["server", "smtp", "port"], Number(patchValue.port || 0));
|
||||
if (patchValue.username !== undefined) patch(["server", "smtp", "username"], String(patchValue.username ?? ""));
|
||||
if (patchValue.password !== undefined) patch(["server", "smtp", "password"], String(patchValue.password ?? ""));
|
||||
if (patchValue.port !== undefined) patch(["server", "smtp", "port"], mailNumberOrNull(patchValue.port));
|
||||
if (patchValue.security !== undefined) patch(["server", "smtp", "security"], String(patchValue.security || "starttls"));
|
||||
if (patchValue.timeout_seconds !== undefined) patch(["server", "smtp", "timeout_seconds"], Number(patchValue.timeout_seconds || 0));
|
||||
if (patchValue.timeout_seconds !== undefined) patch(["server", "smtp", "timeout_seconds"], mailNumberOrDefault(patchValue.timeout_seconds, 30));
|
||||
}
|
||||
|
||||
function patchImapSettings(patchValue: Partial<MailServerImapSettings>) {
|
||||
if (patchValue.host !== undefined) patch(["server", "imap", "host"], String(patchValue.host ?? ""));
|
||||
if (patchValue.port !== undefined) patch(["server", "imap", "port"], Number(patchValue.port || 0));
|
||||
if (patchValue.username !== undefined) patch(["server", "imap", "username"], String(patchValue.username ?? ""));
|
||||
if (patchValue.password !== undefined) patch(["server", "imap", "password"], String(patchValue.password ?? ""));
|
||||
if (patchValue.port !== undefined) patch(["server", "imap", "port"], mailNumberOrNull(patchValue.port));
|
||||
if (patchValue.security !== undefined) patch(["server", "imap", "security"], String(patchValue.security || "tls"));
|
||||
if (patchValue.sent_folder !== undefined) patch(["server", "imap", "sent_folder"], String(patchValue.sent_folder ?? ""));
|
||||
if (patchValue.timeout_seconds !== undefined) patch(["server", "imap", "timeout_seconds"], Number(patchValue.timeout_seconds || 0));
|
||||
if (patchValue.timeout_seconds !== undefined) patch(["server", "imap", "timeout_seconds"], mailNumberOrDefault(patchValue.timeout_seconds, 30));
|
||||
}
|
||||
|
||||
function patchSmtpCredentials(patchValue: Partial<MailServerCredentialSettings>) {
|
||||
if (patchValue.username !== undefined) patch(["server", "credentials", "smtp", "username"], String(patchValue.username ?? ""));
|
||||
if (patchValue.password !== undefined) patch(["server", "credentials", "smtp", "password"], String(patchValue.password ?? ""));
|
||||
}
|
||||
|
||||
function patchImapCredentials(patchValue: Partial<MailServerCredentialSettings>) {
|
||||
if (patchValue.username !== undefined) patch(["server", "credentials", "imap", "username"], String(patchValue.username ?? ""));
|
||||
if (patchValue.password !== undefined) patch(["server", "credentials", "imap", "password"], String(patchValue.password ?? ""));
|
||||
}
|
||||
|
||||
function profileScopeLabel(profile: MailServerProfile): string {
|
||||
@@ -185,71 +248,60 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
}
|
||||
|
||||
|
||||
function emptyToNull(value: string, trim = true): string | null {
|
||||
const normalized = trim ? value.trim() : value;
|
||||
return normalized ? normalized : null;
|
||||
function smtpServerPayload() {
|
||||
return mailSmtpSettingsPayload<MailSecurity>(
|
||||
{ host: getText(smtp, "host"), port: getNumber(smtp, "port", 587), security: getText(smtp, "security", "starttls"), timeout_seconds: getNumber(smtp, "timeout_seconds", 30) },
|
||||
{ fallbackSecurity: "starttls", allowedSecurity: securityOptions },
|
||||
);
|
||||
}
|
||||
|
||||
function readSecurity(value: string, fallback: MailSecurity): MailSecurity {
|
||||
return securityOptions.includes(value as MailSecurity) ? (value as MailSecurity) : fallback;
|
||||
function imapServerPayload() {
|
||||
return mailImapSettingsPayload<MailSecurity>(
|
||||
{ host: getText(imap, "host"), port: getNumber(imap, "port", 993), security: getText(imap, "security", "tls"), sent_folder: getText(imap, "sent_folder", "auto"), timeout_seconds: getNumber(imap, "timeout_seconds", 30) },
|
||||
{ fallbackSecurity: "tls", allowedSecurity: securityOptions },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function smtpPayload() {
|
||||
function mailProfileCredentialsPayload(preserveBlankPassword: boolean) {
|
||||
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)
|
||||
smtp: mailTransportCredentialsPayloadFromRecords(smtpCredentials, smtp, preserveBlankPassword),
|
||||
imap: mailTransportCredentialsPayloadFromRecords(imapCredentials, imap, preserveBlankPassword),
|
||||
};
|
||||
}
|
||||
|
||||
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 rawSmtpPayload() {
|
||||
const serverPayload = selectedProfile && !smtpCredentialsInherited ? selectedProfile.smtp : smtpServerPayload();
|
||||
return { ...serverPayload, ...mailTransportCredentialsPayloadFromRecords(smtpCredentials, smtp, false) };
|
||||
}
|
||||
|
||||
function effectiveSmtpPayload() {
|
||||
if (selectedProfile && !smtpCredentialsInherited) {
|
||||
return {
|
||||
...selectedProfile.smtp,
|
||||
username: emptyToNull(getText(smtp, "username")),
|
||||
password: emptyToNull(getText(smtp, "password"), false)
|
||||
};
|
||||
}
|
||||
return smtpPayload();
|
||||
function rawImapPayload() {
|
||||
const serverPayload = selectedProfile?.imap && !imapCredentialsInherited ? selectedProfile.imap : imapServerPayload();
|
||||
return { ...serverPayload, ...mailTransportCredentialsPayloadFromRecords(imapCredentials, imap, false) };
|
||||
}
|
||||
|
||||
function effectiveImapPayload() {
|
||||
if (selectedProfile?.imap && !imapCredentialsInherited) {
|
||||
return {
|
||||
...selectedProfile.imap,
|
||||
enabled: true,
|
||||
username: emptyToNull(getText(imap, "username")),
|
||||
password: emptyToNull(getText(imap, "password"), false)
|
||||
};
|
||||
}
|
||||
return imapPayload();
|
||||
function hasInlineImapSettings(): boolean {
|
||||
return hasMailImapSettings([getText(imap, "host"), getText(imapCredentials, "username", getText(imap, "username")), getText(imapCredentials, "password", getText(imap, "password"))]);
|
||||
}
|
||||
|
||||
function profileUsername(profile: MailServerProfile | null, protocol: "smtp" | "imap"): string {
|
||||
if (!profile) return "";
|
||||
if (protocol === "smtp") return stringOrEmpty(profile.credentials?.smtp?.username ?? profile.smtp.username);
|
||||
return stringOrEmpty(profile.credentials?.imap?.username ?? profile.imap?.username);
|
||||
}
|
||||
|
||||
|
||||
async function runSmtpTest() {
|
||||
if (!mailModuleInstalled) {
|
||||
setSmtpTestResult({ ok: false, protocol: "smtp", message: "Install and enable the Mail module to test SMTP settings.", details: {} });
|
||||
return;
|
||||
}
|
||||
if (locked || inlineMailSettingsBlocked) return;
|
||||
setMailActionState("smtp");
|
||||
setLocalError("");
|
||||
try {
|
||||
setSmtpTestResult(selectedProfileId && smtpCredentialsInherited
|
||||
? await testMailProfileSmtp(settings, selectedProfileId)
|
||||
: await testSmtpSettings(settings, effectiveSmtpPayload()));
|
||||
: await testSmtpSettings(settings, rawSmtpPayload()));
|
||||
} catch (err) {
|
||||
setSmtpTestResult({ ok: false, protocol: "smtp", message: err instanceof Error ? err.message : String(err), details: {} });
|
||||
} finally {
|
||||
@@ -258,13 +310,17 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
}
|
||||
|
||||
async function runImapTest() {
|
||||
if (!mailModuleInstalled) {
|
||||
setImapTestResult({ ok: false, protocol: "imap", message: "Install and enable the Mail module to test IMAP settings.", details: {} });
|
||||
return;
|
||||
}
|
||||
if (imapDisabled) return;
|
||||
setMailActionState("imap");
|
||||
setLocalError("");
|
||||
try {
|
||||
setImapTestResult(selectedProfileId && imapCredentialsInherited
|
||||
? await testMailProfileImap(settings, selectedProfileId)
|
||||
: await testImapSettings(settings, effectiveImapPayload()));
|
||||
: await testImapSettings(settings, rawImapPayload()));
|
||||
} catch (err) {
|
||||
setImapTestResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), details: {} });
|
||||
} finally {
|
||||
@@ -273,13 +329,17 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
}
|
||||
|
||||
async function runFolderLookup() {
|
||||
if (imapDisabled) return;
|
||||
if (!mailModuleInstalled) {
|
||||
setFolderResult({ ok: false, protocol: "imap", message: "Install and enable the Mail module to inspect IMAP folders.", folders: [], details: {} });
|
||||
return;
|
||||
}
|
||||
if (appendTargetFolderDisabled) return;
|
||||
setMailActionState("folders");
|
||||
setLocalError("");
|
||||
try {
|
||||
setFolderResult(selectedProfileId && imapCredentialsInherited
|
||||
? await listMailProfileImapFolders(settings, selectedProfileId)
|
||||
: await listImapFolders(settings, effectiveImapPayload()));
|
||||
: await listImapFolders(settings, rawImapPayload()));
|
||||
} catch (err) {
|
||||
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
|
||||
} finally {
|
||||
@@ -289,25 +349,20 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
|
||||
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);
|
||||
}
|
||||
if (!folder || appendTargetFolderDisabled) return;
|
||||
patch(["delivery", "imap_append_sent", "folder"], folder);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Server settings</PageTitle>
|
||||
<PageTitle loading={loading}>{isPolicyView ? "Mail policy" : "Mail 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 || inlineMailSettingsBlocked}>{dirty ? "Save now" : "Saved"}</Button>
|
||||
{!isPolicyView && <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft || inlineMailSettingsBlocked}>{dirty ? "Save now" : "Saved"}</Button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -317,21 +372,34 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
|
||||
<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-local mail policy"
|
||||
description="Campaign-local mail limits applied after system, tenant and owner policies."
|
||||
onSaved={refreshMailProfiles}
|
||||
/>
|
||||
{!mailModuleInstalled && (
|
||||
<DismissibleAlert tone="info" dismissible={false}>
|
||||
The Mail module is not installed. Inline SMTP and IMAP values can be edited in the campaign draft, but reusable profiles, connection tests and sending require the Mail module.
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
|
||||
{isPolicyView && mailModuleInstalled && MailProfilePolicyEditor && (
|
||||
<MailProfilePolicyEditor
|
||||
settings={settings}
|
||||
scopeType="campaign"
|
||||
scopeId={campaignId}
|
||||
campaignId={campaignId}
|
||||
profiles={policyProfiles}
|
||||
ownerUserId={data.campaign?.owner_user_id}
|
||||
ownerGroupId={data.campaign?.owner_group_id}
|
||||
canWrite={!locked}
|
||||
locked={locked}
|
||||
title="Campaign-local mail policy"
|
||||
description="Campaign-local mail limits applied after system, tenant and owner policies."
|
||||
onSaved={refreshMailProfiles}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPolicyView && mailModuleInstalled && !MailProfilePolicyEditor && (
|
||||
<DismissibleAlert tone="warning" dismissible={false}>The Mail module did not expose profile-management UI capabilities.</DismissibleAlert>
|
||||
)}
|
||||
|
||||
{!isPolicyView && mailModuleInstalled && (
|
||||
<Card
|
||||
title="Reusable mail profile"
|
||||
actions={
|
||||
@@ -356,45 +424,44 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
{selectedProfile && (
|
||||
<p className="muted small-note">Using {selectedProfile.name} ({profileScopeLabel(selectedProfile)}). SMTP credentials: {smtpCredentialsInherited ? "inherited from profile" : "local credentials required"}. IMAP credentials: {selectedProfile.imap ? (imapCredentialsInherited ? "inherited from profile" : "local credentials required") : "not configured"}.</p>
|
||||
)}
|
||||
{selectedProfileNeedsLocalCredentials && (
|
||||
<p className="muted small-note">The selected profile supplies the server settings. Enter the required campaign-local credentials in the mail server settings panel below.</p>
|
||||
)}
|
||||
{profileMessage && <DismissibleAlert tone="success" resetKey={profileMessage} floating>{profileMessage}</DismissibleAlert>}
|
||||
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} floating>{profileError}</DismissibleAlert>}
|
||||
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} dismissStorageKey={`campaign:${campaignId}:mail-settings:profile-error`} floating>{profileError}</DismissibleAlert>}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="Mail server settings">
|
||||
{!isPolicyView && (
|
||||
<Card title="Mail server settings" collapsible>
|
||||
{inlinePolicyMessages.length > 0 && (
|
||||
<DismissibleAlert tone="warning" resetKey={inlinePolicyMessages.map((item) => `${item.key}:${item.value}`).join("|")} dismissible={false}>
|
||||
<strong>Effective mail policy blocks the current inline settings.</strong>
|
||||
<ul>{inlinePolicyMessages.map((item) => <li key={`${item.key}:${item.value}`}>{item.message}</li>)}</ul>
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
<MailServerSettingsPanel
|
||||
smtp={{
|
||||
host: getText(smtp, "host"),
|
||||
port: getNumber(smtp, "port", 587),
|
||||
username: getText(smtp, "username"),
|
||||
password: getText(smtp, "password"),
|
||||
security: getText(smtp, "security", "starttls"),
|
||||
timeout_seconds: getNumber(smtp, "timeout_seconds", 30)
|
||||
}}
|
||||
imap={{
|
||||
enabled: imapEnabled,
|
||||
host: getText(imap, "host"),
|
||||
port: getNumber(imap, "port", 993),
|
||||
username: getText(imap, "username"),
|
||||
password: getText(imap, "password"),
|
||||
security: getText(imap, "security", "tls"),
|
||||
sent_folder: getText(imap, "sent_folder", "auto"),
|
||||
timeout_seconds: getNumber(imap, "timeout_seconds", 30)
|
||||
}}
|
||||
smtp={displayedSmtp}
|
||||
imap={displayedImap}
|
||||
onSmtpChange={patchSmtpSettings}
|
||||
onImapChange={patchImapSettings}
|
||||
onImapEnabledChange={toggleImap}
|
||||
smtpCredentials={{ username: displayedSmtp.username, password: displayedSmtp.password }}
|
||||
imapCredentials={{ username: displayedImap.username, password: displayedImap.password }}
|
||||
onSmtpCredentialsChange={patchSmtpCredentials}
|
||||
onImapCredentialsChange={patchImapCredentials}
|
||||
smtpDisabled={smtpDisabled}
|
||||
smtpCredentialDisabled={smtpCredentialDisabled}
|
||||
smtpActionDisabled={locked || inlineMailSettingsBlocked}
|
||||
imapToggleDisabled={locked || usingMailProfile || inlineMailSettingsBlocked}
|
||||
smtpPasswordSaved={Boolean(usingMailProfile && smtpCredentialsInherited && selectedProfile?.smtp_password_configured)}
|
||||
smtpActionDisabled={locked || inlineMailSettingsBlocked || !mailModuleInstalled}
|
||||
imapServerDisabled={imapServerDisabled}
|
||||
imapCredentialDisabled={imapCredentialDisabled}
|
||||
imapActionDisabled={imapDisabled}
|
||||
imapPasswordSaved={Boolean(usingMailProfile && imapCredentialsInherited && selectedProfile?.imap_password_configured)}
|
||||
imapActionDisabled={imapDisabled || !mailModuleInstalled}
|
||||
append={{
|
||||
enabled: getBool(imapAppend, "enabled"),
|
||||
enabled: imapAppendEnabled,
|
||||
folder: getText(imapAppend, "folder", getText(imap, "sent_folder", "auto")),
|
||||
disabled: imapDisabled,
|
||||
folderDisabled: imapDisabled || !getBool(imapAppend, "enabled"),
|
||||
folderDisabled: appendTargetFolderDisabled,
|
||||
onEnabledChange: (checked) => patch(["delivery", "imap_append_sent", "enabled"], checked),
|
||||
onFolderChange: (folder) => patch(["delivery", "imap_append_sent", "folder"], folder)
|
||||
}}
|
||||
@@ -408,17 +475,44 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
imapTestResult={imapTestResult}
|
||||
folderLookupResult={folderResult}
|
||||
onUseDetectedFolder={useDetectedSentFolder}
|
||||
useDetectedFolderDisabled={imapDisabled}
|
||||
useDetectedFolderDisabled={appendTargetFolderDisabled}
|
||||
floatingResults
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="button-row page-bottom-actions">
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || inlineMailSettingsBlocked}>Save</Button>
|
||||
</div>
|
||||
</>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function stringOrEmpty(value: unknown): string {
|
||||
return value === null || value === undefined ? "" : String(value);
|
||||
}
|
||||
|
||||
function firstAddressEmail(value: unknown): string {
|
||||
return addressesFromValue(value)[0]?.email ?? "";
|
||||
}
|
||||
|
||||
function collectRecipientDomains(draft: Record<string, unknown>): string[] {
|
||||
const domains = new Set<string>();
|
||||
const recipients = asRecord(draft.recipients);
|
||||
for (const key of ["to", "cc", "bcc"]) addAddressDomains(domains, recipients[key]);
|
||||
|
||||
const entries = asArray(asRecord(draft.entries).inline).map(asRecord);
|
||||
for (const entry of entries) {
|
||||
for (const key of ["to", "cc", "bcc"]) addAddressDomains(domains, entry[key]);
|
||||
addAddressDomains(domains, entry.recipient);
|
||||
addAddressDomains(domains, entry);
|
||||
}
|
||||
return [...domains].sort();
|
||||
}
|
||||
|
||||
function addAddressDomains(domains: Set<string>, value: unknown) {
|
||||
for (const address of addressesFromValue(value)) {
|
||||
const domain = address.email.split("@").pop()?.trim().toLowerCase();
|
||||
if (domain) domains.add(domain);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
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 { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import 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 { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { EmailAddressInput } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
@@ -19,9 +19,8 @@ import {
|
||||
addressesFromValue,
|
||||
collectCampaignAddressSuggestions,
|
||||
type MailboxAddress
|
||||
} from "../../utils/emailAddresses";
|
||||
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
|
||||
|
||||
} from "@govoplan/core-webui";
|
||||
import { insertAfter, moveArrayItem } from "@govoplan/core-webui";
|
||||
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." },
|
||||
@@ -169,7 +168,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||
<>
|
||||
<Card title="Campaign sender">
|
||||
<Card title="Campaign sender" collapsible>
|
||||
<div className="campaign-header-stack">
|
||||
<div className="campaign-header-grid">
|
||||
<FormField label="Default From address">
|
||||
@@ -217,7 +216,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Global recipient headers">
|
||||
<Card title="Global recipient headers" collapsible>
|
||||
<div className="campaign-header-stack">
|
||||
{recipientHeaderRows.map((row) => (
|
||||
<div className="campaign-header-grid" key={row.key}>
|
||||
@@ -245,11 +244,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<section className="recipient-profiles-section">
|
||||
<div className="subsection-heading split">
|
||||
<h3>Recipient profiles</h3>
|
||||
<Button disabled>Import</Button>
|
||||
</div>
|
||||
<Card title="Recipient profiles" actions={<Button disabled>Import</Button>}>
|
||||
{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>
|
||||
)}
|
||||
@@ -266,11 +261,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="button-row page-bottom-actions">
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { usePlatformModuleInstalled } from "@govoplan/core-webui";
|
||||
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 { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
@@ -14,12 +15,13 @@ 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 { addressesFromValue } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
|
||||
|
||||
export default function RecipientDetailsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
|
||||
const version = data.currentVersion;
|
||||
@@ -94,20 +96,18 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
||||
<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"
|
||||
/>
|
||||
<div className="admin-table-surface recipient-data-table-surface">
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-recipient-data`}
|
||||
rows={inlineEntries.slice(0, 100)}
|
||||
columns={recipientDataColumns({ settings, campaignId, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField })}
|
||||
getRowKey={(entry, index) => String(entry.id || index)}
|
||||
emptyText="No recipient data found."
|
||||
className="recipient-table-wrap recipient-data-table-wrap recipient-data-table"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="button-row page-bottom-actions">
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
|
||||
</div>
|
||||
</>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
@@ -118,6 +118,7 @@ type RecipientDataColumnContext = {
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
locked: boolean;
|
||||
filesModuleInstalled: boolean;
|
||||
fieldDefinitions: ReturnType<typeof getDraftFields>;
|
||||
individualAttachmentBasePaths: ReturnType<typeof getIndividualAttachmentBasePaths>;
|
||||
zipConfig: AttachmentZipCollection;
|
||||
@@ -125,7 +126,7 @@ type RecipientDataColumnContext = {
|
||||
updateEntryField: (index: number, field: string, value: unknown) => void;
|
||||
};
|
||||
|
||||
function recipientDataColumns({ settings, campaignId, locked, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
||||
function recipientDataColumns({ settings, campaignId, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
||||
return [
|
||||
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small recipient-index-cell">{index + 1}</span>, value: (_entry, index) => index + 1 },
|
||||
{
|
||||
@@ -161,6 +162,7 @@ function recipientDataColumns({ settings, campaignId, locked, fieldDefinitions,
|
||||
buttonLabel={`entries: ${attachments.length}`}
|
||||
basePaths={individualAttachmentBasePaths}
|
||||
zipConfig={zipConfig}
|
||||
filesModuleInstalled={filesModuleInstalled}
|
||||
onChange={(rules) => updateEntryAttachments(index, rules)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
appendSent,
|
||||
buildVersion,
|
||||
getCampaignJobs,
|
||||
getCampaignJobDetail,
|
||||
@@ -26,16 +27,16 @@ import {
|
||||
type CampaignVersionDetail,
|
||||
} from "../../api/campaigns";
|
||||
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
|
||||
import Button from "../../components/Button";
|
||||
import { Button, usePlatformUiCapability, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
|
||||
import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import InlineHelp from "../../components/help/InlineHelp";
|
||||
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { InlineHelp } from "@govoplan/core-webui";
|
||||
import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
@@ -77,7 +78,7 @@ type FlowStageDefinition = {
|
||||
lockReason?: string;
|
||||
};
|
||||
|
||||
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "";
|
||||
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "imap" | "";
|
||||
|
||||
const stateColors: Record<FlowState, string> = {
|
||||
complete: "var(--green)",
|
||||
@@ -103,6 +104,12 @@ const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "ex
|
||||
|
||||
export default function ReviewSendPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const navigate = useNavigate();
|
||||
const devMailboxCapability = usePlatformUiCapability<MailDevMailboxUiCapability>("mail.devMailbox");
|
||||
const mockWorkflowAvailable = devMailboxCapability?.enabled === true;
|
||||
const [mockVerificationRequired, setMockVerificationRequired] = useState(true);
|
||||
const [mockMailboxPreviewEnabled, setMockMailboxPreviewEnabled] = useState(true);
|
||||
const mockWorkflowRequired = mockWorkflowAvailable && mockVerificationRequired;
|
||||
const mockMailboxPreviewActive = mockWorkflowAvailable && mockMailboxPreviewEnabled;
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||
const [liveSummary, setLiveSummary] = useState<CampaignSummary | null>(null);
|
||||
const [queueStatusLoading, setQueueStatusLoading] = useState(false);
|
||||
@@ -140,6 +147,9 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
const [dryRun, setDryRun] = useState(false);
|
||||
const [sendConfirmOpen, setSendConfirmOpen] = useState(false);
|
||||
const [sendResult, setSendResult] = useState<Record<string, unknown> | null>(null);
|
||||
const [imapAppendResult, setImapAppendResult] = useState<Record<string, unknown> | null>(null);
|
||||
const [imapDiagnostics, setImapDiagnostics] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
|
||||
const [selectedDeliveryJobDetail, setSelectedDeliveryJobDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const persistedReview = storedMessageReviewState(version);
|
||||
const persistedReviewKey = `${persistedReview.buildToken}|${persistedReview.inspectionComplete ? "1" : "0"}|${persistedReview.reviewedMessageKeys.join(",")}`;
|
||||
|
||||
@@ -153,6 +163,9 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
setMockResult(null);
|
||||
setSelectedMockMessage(null);
|
||||
setSendResult(null);
|
||||
setImapAppendResult(null);
|
||||
setImapDiagnostics(emptyCampaignJobsResponse());
|
||||
setSelectedDeliveryJobDetail(null);
|
||||
setSendConfirmOpen(false);
|
||||
setLiveSummary(null);
|
||||
}, [version?.id]);
|
||||
@@ -162,10 +175,16 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
setReviewedMessageKeys(new Set(persistedReview.reviewedMessageKeys));
|
||||
}, [version?.id, persistedReviewKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mockMailboxPreviewActive) setSelectedMockMessage(null);
|
||||
}, [mockMailboxPreviewActive]);
|
||||
|
||||
const validationPresent = Object.keys(validation).length > 0;
|
||||
const validationOk = validation.ok === true;
|
||||
const validationErrors = numberFrom(validation, ["error_count", "errors", "blocked"]);
|
||||
const validationWarnings = numberFrom(validation, ["warning_count", "warnings"]);
|
||||
const validationIssues = asArray(validation.issues).map(asRecord);
|
||||
const visibleValidationIssues = validationIssues.slice(0, 10);
|
||||
const readyForDelivery = isVersionReadyForDelivery(version);
|
||||
const validationStale = validationOk && !readyForDelivery;
|
||||
|
||||
@@ -185,6 +204,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
|
||||
const statusCounts = asRecord(summary?.status_counts);
|
||||
const sendStatusCounts = asRecord(statusCounts.send);
|
||||
const imapStatusCounts = asRecord(statusCounts.imap);
|
||||
const attempts = asRecord(summary?.attempts);
|
||||
const summaryDelivery = asRecord(summary?.delivery);
|
||||
const queuedSendCount = numberFrom(sendStatusCounts, ["queued"]);
|
||||
@@ -204,6 +224,8 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
const failedCount = cards?.failed ?? 0;
|
||||
const imapAppended = cards?.imap_appended ?? 0;
|
||||
const imapFailed = cards?.imap_failed ?? 0;
|
||||
const imapPending = numberFrom(imapStatusCounts, ["pending"]);
|
||||
const imapSkipped = numberFrom(imapStatusCounts, ["skipped"]);
|
||||
const deliveryHasTerminalOutcome = sentCount + failedCount + outcomeUnknownCount + cancelledCount > 0;
|
||||
const currentWorkflowState = (version?.workflow_state ?? "").toLowerCase();
|
||||
const deliverySending = activeSendCount > 0 || (currentWorkflowState === "sending" && queuedOrActiveCount > 0);
|
||||
@@ -294,6 +316,13 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
const mockMailbox = asRecord(mockResult?.mailbox);
|
||||
const mockMailboxMessages = asArray(mockMailbox.messages).map(asRecord);
|
||||
const sendResultRows = asArray(sendResult?.results).map(asRecord);
|
||||
const imapAppendResultRows = asArray(imapAppendResult?.results).map(asRecord);
|
||||
const imapDiagnosticRows = imapDiagnostics.jobs.map(asRecord);
|
||||
const imapDiagnosticsPending = imapDiagnosticRows.filter((job) => String(job.imap_status ?? "").toLowerCase() === "pending").length;
|
||||
const imapDiagnosticsFailed = imapDiagnosticRows.filter((job) => String(job.imap_status ?? "").toLowerCase() === "failed").length;
|
||||
const imapPendingForDisplay = Math.max(imapPending, imapDiagnosticsPending);
|
||||
const imapFailedForDisplay = Math.max(imapFailed, imapDiagnosticsFailed);
|
||||
const canAppendPendingImap = Boolean(imapAppend.enabled) && imapPendingForDisplay > 0 && !historicalVersion && !userLockedVersion;
|
||||
|
||||
const validationReviewState: FlowState = busy === "validate"
|
||||
? "running"
|
||||
@@ -322,21 +351,29 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
? "complete"
|
||||
: "active";
|
||||
|
||||
const mockState: FlowState = !inspectionSatisfied
|
||||
const mockState: FlowState = !mockWorkflowAvailable
|
||||
? "locked"
|
||||
: busy === "mock" || busy === "mailbox"
|
||||
? "running"
|
||||
: mockPartial
|
||||
? "partial"
|
||||
: mockFailed > 0
|
||||
? "danger"
|
||||
: mockComplete
|
||||
? "complete"
|
||||
: downstreamDeliveryActivity
|
||||
? "warning"
|
||||
: "active";
|
||||
: !inspectionSatisfied
|
||||
? "locked"
|
||||
: busy === "mock" || busy === "mailbox"
|
||||
? "running"
|
||||
: mockPartial
|
||||
? "partial"
|
||||
: mockFailed > 0
|
||||
? "danger"
|
||||
: mockComplete
|
||||
? "complete"
|
||||
: downstreamDeliveryActivity
|
||||
? "warning"
|
||||
: "active";
|
||||
|
||||
const mockGateSatisfied = mockComplete || downstreamDeliveryActivity;
|
||||
const mockStateDisplayLabel = !mockWorkflowAvailable ? "Unavailable" : !mockVerificationRequired ? "Optional" : stateLabel(mockState);
|
||||
const mockGateSatisfied = inspectionSatisfied && (!mockWorkflowRequired || mockComplete || downstreamDeliveryActivity);
|
||||
const sendLockReason = !inspectionSatisfied
|
||||
? "Build and complete the required message review first."
|
||||
: mockWorkflowRequired
|
||||
? "Complete a successful mock delivery first."
|
||||
: "Build the exact queue first.";
|
||||
const sendState: FlowState = !mockGateSatisfied
|
||||
? "locked"
|
||||
: busy === "send" || deliverySending
|
||||
@@ -391,11 +428,11 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
id: "workflow-mock-verify",
|
||||
title: "Mock send and verify",
|
||||
shortTitle: "Mock send",
|
||||
description: "Exercise the delivery path and verify recipient outcomes and captured MIME messages without contacting the real servers.",
|
||||
description: "Exercise the delivery path and verify recipient outcomes and captured MIME messages without contacting the real servers. This dev workflow can be optional before real sending.",
|
||||
icon: FlaskConical,
|
||||
state: mockState,
|
||||
stateLabel: stateLabel(mockState),
|
||||
lockReason: "Build and complete the required message review first.",
|
||||
stateLabel: mockStateDisplayLabel,
|
||||
lockReason: mockWorkflowAvailable ? "Build and complete the required message review first." : "Enable the Mail dev mailbox capability to run mock delivery.",
|
||||
},
|
||||
{
|
||||
id: "workflow-send",
|
||||
@@ -405,7 +442,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
icon: Send,
|
||||
state: sendState,
|
||||
stateLabel: stateLabel(sendState),
|
||||
lockReason: "Complete a successful mock delivery first.",
|
||||
lockReason: sendLockReason,
|
||||
},
|
||||
{
|
||||
id: "workflow-results",
|
||||
@@ -424,6 +461,9 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
sendState,
|
||||
resultState,
|
||||
validationErrors,
|
||||
mockStateDisplayLabel,
|
||||
mockWorkflowAvailable,
|
||||
sendLockReason,
|
||||
]);
|
||||
|
||||
async function runValidation() {
|
||||
@@ -502,7 +542,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
}
|
||||
|
||||
async function runMockSend() {
|
||||
if (!version || busy || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted) return;
|
||||
if (!version || busy || !mockWorkflowAvailable || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted) return;
|
||||
setBusy("mock");
|
||||
setMessage("Running the complete mock-delivery flow…");
|
||||
setError("");
|
||||
@@ -571,6 +611,73 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
}
|
||||
}
|
||||
|
||||
async function runAppendSent() {
|
||||
if (!version || busy || !canAppendPendingImap) return;
|
||||
const runInline = !backgroundWorkersEnabled;
|
||||
setBusy("imap");
|
||||
setError("");
|
||||
setMessage(runInline ? "Appending pending Sent copies via IMAP..." : "Queueing pending IMAP append jobs...");
|
||||
try {
|
||||
const response = await appendSent(settings, campaignId, {
|
||||
enqueue_celery: backgroundWorkersEnabled,
|
||||
run_inline: runInline,
|
||||
dry_run: false,
|
||||
});
|
||||
const result = asRecord(response.result ?? response);
|
||||
setImapAppendResult(result);
|
||||
const appended = numberFrom(result, ["appended_count"]);
|
||||
const failed = numberFrom(result, ["failed_count"]);
|
||||
const enqueued = numberFrom(result, ["enqueued_count"]);
|
||||
const pending = numberFrom(result, ["pending_count"]);
|
||||
setMessage(runInline
|
||||
? `IMAP append processed ${pending} pending job(s): appended ${appended}, failed ${failed}.`
|
||||
: `Queued ${enqueued} pending IMAP append job(s).`);
|
||||
await refreshQueueStatus(true);
|
||||
await loadImapDiagnostics(true);
|
||||
} catch (err) {
|
||||
setMessage("");
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadImapDiagnostics(silent = false) {
|
||||
if (!version?.id) return;
|
||||
setBusy("inspect");
|
||||
if (!silent) setMessage("Loading IMAP diagnostics...");
|
||||
setError("");
|
||||
try {
|
||||
const result = await getCampaignJobs(settings, campaignId, {
|
||||
versionId: version.id,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
imapStatus: ["pending", "failed"],
|
||||
});
|
||||
setImapDiagnostics(result);
|
||||
if (!silent) setMessage(`Loaded ${result.total} pending/failed IMAP job(s).`);
|
||||
} catch (err) {
|
||||
if (!silent) setMessage("");
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function openDeliveryJobDetail(jobId: string) {
|
||||
if (!jobId || busy) return;
|
||||
setBusy("inspect");
|
||||
setError("");
|
||||
try {
|
||||
const detail = await getCampaignJobDetail(settings, campaignId, jobId);
|
||||
setSelectedDeliveryJobDetail(detail as unknown as Record<string, unknown>);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function completeInspection(_acceptBulk = false) {
|
||||
if (!version || busy || readOnlyVersion || automaticInspectionComplete || !canCompleteInspection || downstreamDeliveryActivity) return;
|
||||
setBusy("inspect");
|
||||
@@ -605,7 +712,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
}
|
||||
|
||||
async function openMockMessage(id: string) {
|
||||
if (!id || busy === "mailbox") return;
|
||||
if (!id || busy === "mailbox" || !mockMailboxPreviewActive) return;
|
||||
setBusy("mailbox");
|
||||
setError("");
|
||||
try {
|
||||
@@ -724,6 +831,24 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
{validationErrors > 0 && (
|
||||
<p className="review-flow-inline-note is-danger">Resolve the blocking entries, then validate again.</p>
|
||||
)}
|
||||
{visibleValidationIssues.length > 0 && (
|
||||
<div className="review-flow-data-section">
|
||||
<h3>Validation details</h3>
|
||||
<dl className="detail-list">
|
||||
{visibleValidationIssues.map((issue, index) => (
|
||||
<div key={`${String(issue.code ?? "issue")}:${index}`}>
|
||||
<dt>{humanize(String(issue.severity ?? "issue"))}</dt>
|
||||
<dd>
|
||||
<strong>{String(issue.message ?? issue.code ?? "Validation issue")}</strong>
|
||||
{issue.path ? <span className="muted"> · {String(issue.path)}</span> : null}
|
||||
{issue.code ? <span className="muted"> · {String(issue.code)}</span> : null}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{validationIssues.length > visibleValidationIssues.length && <p className="muted small-note">Showing {visibleValidationIssues.length} of {validationIssues.length} validation issue(s).</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className="button-row compact-actions review-flow-stage-actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -808,17 +933,22 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
<WorkflowFact label="Captured SMTP" value={mockResult ? mockSent : "—"} />
|
||||
<WorkflowFact label="Mock failures" value={mockResult ? mockFailed : "—"} />
|
||||
<WorkflowFact label="Skipped" value={mockResult ? mockSkipped : "—"} />
|
||||
<WorkflowFact label="Captured messages" value={mockResult ? mockMailboxMessages.length : "—"} />
|
||||
<WorkflowFact label="Captured messages" value={!mockWorkflowAvailable ? "Unavailable" : mockResult ? mockMailboxMessages.length : "—"} />
|
||||
</div>
|
||||
<div className="button-row compact-actions review-flow-stage-actions">
|
||||
<Button variant="primary" onClick={() => void runMockSend()} disabled={!version || Boolean(busy) || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted}>
|
||||
<Button variant="primary" onClick={() => void runMockSend()} disabled={!version || Boolean(busy) || !mockWorkflowAvailable || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted}>
|
||||
{busy === "mock" ? "Running mock delivery…" : mockResult ? "Run mock delivery again" : "Run mock delivery"}
|
||||
</Button>
|
||||
<Button onClick={() => navigate("../mail-settings")}>Review server settings</Button>
|
||||
</div>
|
||||
{!mockWorkflowAvailable && (
|
||||
<p className="review-flow-inline-note is-stale">Mock delivery uses the Mail module development mailbox API. Enable that capability to run and inspect captured messages.</p>
|
||||
)}
|
||||
<div className="toggle-row mock-send-options">
|
||||
<ToggleSwitch label="Clear mock mailbox first" checked={mockClearFirst} disabled={Boolean(busy)} onChange={setMockClearFirst} />
|
||||
<ToggleSwitch label="Append mock Sent copy" checked={mockAppendSent} disabled={Boolean(busy)} onChange={setMockAppendSent} />
|
||||
<ToggleSwitch label="Require mock before real send" checked={mockVerificationRequired} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockVerificationRequired} />
|
||||
<ToggleSwitch label="Show captured mock mailbox" checked={mockMailboxPreviewEnabled && mockWorkflowAvailable} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockMailboxPreviewEnabled} />
|
||||
<ToggleSwitch label="Clear mock mailbox first" checked={mockClearFirst} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockClearFirst} />
|
||||
<ToggleSwitch label="Append mock Sent copy" checked={mockAppendSent} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockAppendSent} />
|
||||
</div>
|
||||
{mockResult && (
|
||||
<div className="review-flow-data-stack">
|
||||
@@ -833,17 +963,24 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
className="data-table-wrap data-table compact-table"
|
||||
/>
|
||||
</section>
|
||||
<section className="review-flow-data-section" aria-labelledby="workflow-mock-mailbox-title">
|
||||
<h3 id="workflow-mock-mailbox-title">Captured mock messages</h3>
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-workflow-mock-mailbox`}
|
||||
rows={mockMailboxMessages}
|
||||
columns={mockMailboxColumns(openMockMessage)}
|
||||
getRowKey={(row, index) => String(row.id ?? index)}
|
||||
emptyText="No mock messages were captured in this run."
|
||||
className="data-table-wrap data-table compact-table"
|
||||
/>
|
||||
</section>
|
||||
{mockMailboxPreviewActive ? (
|
||||
<section className="review-flow-data-section" aria-labelledby="workflow-mock-mailbox-title">
|
||||
<h3 id="workflow-mock-mailbox-title">Captured mock messages</h3>
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-workflow-mock-mailbox`}
|
||||
rows={mockMailboxMessages}
|
||||
columns={mockMailboxColumns(openMockMessage)}
|
||||
getRowKey={(row, index) => String(row.id ?? index)}
|
||||
emptyText="No mock messages were captured in this run."
|
||||
className="data-table-wrap data-table compact-table"
|
||||
/>
|
||||
</section>
|
||||
) : (
|
||||
<section className="review-flow-data-section" aria-labelledby="workflow-mock-mailbox-title">
|
||||
<h3 id="workflow-mock-mailbox-title">Captured mock messages</h3>
|
||||
<p className="muted small-note">{mockWorkflowAvailable ? "Captured mailbox preview is disabled for this run. Recipient outcomes remain available." : "Captured mailbox preview requires the Mail development mailbox API."}</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</WorkflowStage>
|
||||
@@ -916,12 +1053,59 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
<WorkflowFact label="Queued / active" value={queuedOrActiveCount} />
|
||||
<WorkflowFact label="Outcome unknown" value={outcomeUnknownCount} />
|
||||
<WorkflowFact label="IMAP appended" value={imapAppended} />
|
||||
<WorkflowFact label="IMAP failed" value={imapFailed} />
|
||||
<WorkflowFact label="IMAP pending" value={imapPendingForDisplay} />
|
||||
<WorkflowFact label="IMAP failed" value={imapFailedForDisplay} />
|
||||
<WorkflowFact label="IMAP skipped" value={imapSkipped} />
|
||||
</div>
|
||||
<div className="review-flow-result-line">
|
||||
<StatusBadge status={deliveryDisplayStatus} />
|
||||
<span>{deliveryStarted ? "Delivery activity is available in the report and audit views." : "No real delivery has started for this campaign version."}</span>
|
||||
</div>
|
||||
{Boolean(imapAppend.enabled) && imapPendingForDisplay > 0 && (
|
||||
<p className="review-flow-inline-note is-stale">IMAP Sent append is still pending for {imapPendingForDisplay} job(s). Pending with no IMAP attempt usually means the append worker was not queued or is not running.</p>
|
||||
)}
|
||||
{Boolean(imapAppend.enabled) && imapFailedForDisplay > 0 && (
|
||||
<p className="review-flow-inline-note is-danger">{imapFailedForDisplay} IMAP append job(s) failed. Load diagnostics to inspect the last error per job.</p>
|
||||
)}
|
||||
{Boolean(imapAppend.enabled) && (imapPendingForDisplay > 0 || imapFailedForDisplay > 0) && (
|
||||
<div className="button-row compact-actions review-flow-stage-actions">
|
||||
<Button onClick={() => void loadImapDiagnostics(false)} disabled={Boolean(busy)}>Load IMAP diagnostics</Button>
|
||||
{imapPendingForDisplay > 0 && (
|
||||
<Button variant="primary" onClick={() => void runAppendSent()} disabled={Boolean(busy) || !canAppendPendingImap}>
|
||||
{busy === "imap" ? "Appending IMAP..." : backgroundWorkersEnabled ? "Queue pending IMAP append" : "Append pending IMAP now"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{imapAppendResult && (
|
||||
<div className="review-flow-data-section">
|
||||
<h3>IMAP append result</h3>
|
||||
<p className="muted small-note">Pending {String(imapAppendResult.pending_count ?? "0")}, enqueued {String(imapAppendResult.enqueued_count ?? "0")}, processed {String(imapAppendResult.processed_count ?? "0")}, appended {String(imapAppendResult.appended_count ?? "0")}, failed {String(imapAppendResult.failed_count ?? "0")}.</p>
|
||||
{imapAppendResultRows.length > 0 && (
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-workflow-imap-append-results`}
|
||||
rows={imapAppendResultRows}
|
||||
columns={imapAppendResultColumns()}
|
||||
getRowKey={(row, index) => String(row.job_id ?? index)}
|
||||
emptyText="No IMAP append result rows returned."
|
||||
className="data-table-wrap data-table compact-table"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{imapDiagnostics.total > 0 && (
|
||||
<div className="review-flow-data-section">
|
||||
<h3>IMAP diagnostics</h3>
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-workflow-imap-diagnostics`}
|
||||
rows={imapDiagnosticRows}
|
||||
columns={imapDiagnosticColumns(openDeliveryJobDetail)}
|
||||
getRowKey={(row, index) => String(row.id ?? index)}
|
||||
emptyText="No pending or failed IMAP jobs found."
|
||||
className="data-table-wrap data-table compact-table"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{recentFailures.length > 0 && (
|
||||
<div className="review-flow-data-section">
|
||||
<h3>Recent delivery failures</h3>
|
||||
@@ -979,8 +1163,12 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
|
||||
onConfirm={() => void runSendNow()}
|
||||
/>
|
||||
|
||||
{selectedMockMessage && (
|
||||
<MessagePreviewOverlay
|
||||
{selectedDeliveryJobDetail && (
|
||||
<DeliveryJobDetailOverlay detail={selectedDeliveryJobDetail} onClose={() => setSelectedDeliveryJobDetail(null)} />
|
||||
)}
|
||||
|
||||
{selectedMockMessage && mockMailboxPreviewActive && (
|
||||
<CampaignMessagePreviewOverlay
|
||||
title="Captured mock mail"
|
||||
subject={selectedMockMessage.subject || "Mock message"}
|
||||
bodyMode="text"
|
||||
@@ -1134,7 +1322,7 @@ function BuiltMessagePreview({
|
||||
const resolvedRecipients = asRecord(row.resolved_recipients);
|
||||
|
||||
return (
|
||||
<MessagePreviewOverlay
|
||||
<CampaignMessagePreviewOverlay
|
||||
title="Built message review"
|
||||
subject={subject}
|
||||
bodyMode={html.trim() ? "html" : "text"}
|
||||
@@ -1157,6 +1345,59 @@ function BuiltMessagePreview({
|
||||
);
|
||||
}
|
||||
|
||||
function DeliveryJobDetailOverlay({ detail, onClose }: { detail: Record<string, unknown>; onClose: () => void }) {
|
||||
const job = asRecord(detail.job);
|
||||
const attempts = asRecord(detail.attempts);
|
||||
const smtpAttempts = asArray(attempts.smtp).map(asRecord);
|
||||
const imapAttempts = asArray(attempts.imap).map(asRecord);
|
||||
const issues = asArray(job.issues).map(asRecord);
|
||||
return (
|
||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="delivery-job-detail-title">
|
||||
<div className="modal-panel template-preview-modal message-preview-modal">
|
||||
<header className="modal-header">
|
||||
<h2 id="delivery-job-detail-title">Delivery job details</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</header>
|
||||
<div className="modal-body">
|
||||
<dl className="detail-list">
|
||||
<div><dt>Recipient</dt><dd>{formatAddressList(asRecord(job.resolved_recipients).to) || String(job.recipient_email ?? "-")}</dd></div>
|
||||
<div><dt>Subject</dt><dd>{String(job.subject ?? "-")}</dd></div>
|
||||
<div><dt>SMTP status</dt><dd>{humanize(String(job.send_status ?? "-"))}</dd></div>
|
||||
<div><dt>IMAP status</dt><dd>{humanize(String(job.imap_status ?? "-"))}</dd></div>
|
||||
{job.last_error ? <div><dt>Last error</dt><dd>{String(job.last_error)}</dd></div> : null}
|
||||
</dl>
|
||||
{issues.length > 0 && <AttemptList title="Message issues" rows={issues} />}
|
||||
<AttemptList title="SMTP attempts" rows={smtpAttempts} emptyText="No SMTP attempts were recorded." />
|
||||
<AttemptList title="IMAP attempts" rows={imapAttempts} emptyText="No IMAP append attempts were recorded. If status is pending, append has not run yet." />
|
||||
</div>
|
||||
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AttemptList({ title, rows, emptyText = "No rows." }: { title: string; rows: Record<string, unknown>[]; emptyText?: string }) {
|
||||
return (
|
||||
<section className="review-flow-data-section">
|
||||
<h3>{title}</h3>
|
||||
{rows.length === 0 ? <p className="muted small-note">{emptyText}</p> : (
|
||||
<dl className="detail-list">
|
||||
{rows.map((row, index) => (
|
||||
<div key={`${String(row.id ?? row.code ?? index)}:${index}`}>
|
||||
<dt>{String(row.status ?? row.severity ?? row.code ?? `#${index + 1}`)}</dt>
|
||||
<dd>
|
||||
<strong>{String(row.message ?? row.error_message ?? row.smtp_response ?? row.folder ?? row.path ?? "-")}</strong>
|
||||
{row.started_at || row.created_at ? <span className="muted"> · {formatDateTime(String(row.started_at ?? row.created_at))}</span> : null}
|
||||
{row.finished_at || row.updated_at ? <span className="muted"> → {formatDateTime(String(row.finished_at ?? row.updated_at))}</span> : null}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowFact({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="review-flow-fact">
|
||||
@@ -1224,6 +1465,26 @@ function sendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
|
||||
];
|
||||
}
|
||||
|
||||
function imapAppendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
|
||||
return [
|
||||
{ id: "status", header: "Status", width: 150, sticky: "start", sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.status ?? "info")} />, value: (row) => String(row.status ?? "info") },
|
||||
{ id: "job", header: "Job", width: 180, sortable: true, filterable: true, value: (row) => String(row.job_id ?? "-") },
|
||||
{ id: "folder", header: "Folder", width: 190, sortable: true, filterable: true, value: (row) => String(row.folder ?? "-") },
|
||||
{ id: "message", header: "Message", width: "minmax(300px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.message ?? stringifyPreview(row, 180)) },
|
||||
];
|
||||
}
|
||||
|
||||
function imapDiagnosticColumns(openDetail: (jobId: string) => Promise<void>): DataGridColumn<Record<string, unknown>>[] {
|
||||
return [
|
||||
{ id: "recipient", header: "Recipient", width: 240, sticky: "start", resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "-") },
|
||||
{ id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "-") },
|
||||
{ id: "send", header: "SMTP", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.send_status ?? "info")} />, value: (row) => String(row.send_status ?? "-") },
|
||||
{ id: "imap", header: "IMAP", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "info")} />, value: (row) => String(row.imap_status ?? "-") },
|
||||
{ id: "error", header: "Last error", width: "minmax(260px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "-") },
|
||||
{ id: "actions", header: "Actions", width: 110, sticky: "end", render: (row) => <Button onClick={() => void openDetail(String(row.id ?? ""))}>Details</Button> },
|
||||
];
|
||||
}
|
||||
|
||||
function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGridColumn<Record<string, unknown>>[] {
|
||||
const options: DataGridListOption[] = [
|
||||
{ value: "smtp", label: "SMTP" },
|
||||
@@ -1243,16 +1504,17 @@ function builtMessageMetaItems(row: Record<string, unknown>) {
|
||||
return [
|
||||
{ label: "From", value: formatSingleAddress(recipients.from) || "—" },
|
||||
{ label: "To", value: formatAddressList(recipients.to) || String(row.recipient_email ?? "—") },
|
||||
{ label: "CC", value: formatAddressList(recipients.cc) || "—" },
|
||||
{ label: "BCC", value: formatAddressList(recipients.bcc) || "—" },
|
||||
{ label: "CC", value: formatAddressList(recipients.cc) || null },
|
||||
{ label: "BCC", value: formatAddressList(recipients.bcc) || null },
|
||||
{ label: "Validation", value: String(row.validation_status ?? "—") },
|
||||
{ label: "MIME size", value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—" },
|
||||
];
|
||||
}
|
||||
|
||||
function builtMessageAttachments(row: Record<string, unknown>): MessagePreviewAttachment[] {
|
||||
function builtMessageAttachments(row: Record<string, unknown>): CampaignMessagePreviewAttachment[] {
|
||||
return asArray(row.attachments).flatMap((value, index) => {
|
||||
const attachment = asRecord(value);
|
||||
const zipProtection = zipProtectionFromBuiltAttachment(attachment);
|
||||
const managedMatches = asArray(attachment.managed_matches).map(asRecord);
|
||||
if (managedMatches.length > 0) {
|
||||
return managedMatches.map((match, matchIndex) => ({
|
||||
@@ -1262,6 +1524,8 @@ function builtMessageAttachments(row: Record<string, unknown>): MessagePreviewAt
|
||||
sizeBytes: numberOrUndefined(match.size_bytes),
|
||||
archiveGroup: stringOrUndefined(attachment.zip_filename),
|
||||
archiveLabel: stringOrUndefined(attachment.zip_filename),
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note,
|
||||
}));
|
||||
}
|
||||
const matches = asArray(attachment.matches).filter((match): match is string => typeof match === "string" && Boolean(match.trim()));
|
||||
@@ -1273,6 +1537,8 @@ function builtMessageAttachments(row: Record<string, unknown>): MessagePreviewAt
|
||||
sizeBytes: numberOrUndefined(attachment.size_bytes),
|
||||
archiveGroup: stringOrUndefined(attachment.zip_filename),
|
||||
archiveLabel: stringOrUndefined(attachment.zip_filename),
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note,
|
||||
}));
|
||||
}
|
||||
return [{
|
||||
@@ -1282,14 +1548,36 @@ function builtMessageAttachments(row: Record<string, unknown>): MessagePreviewAt
|
||||
sizeBytes: numberOrUndefined(attachment.size_bytes),
|
||||
archiveGroup: stringOrUndefined(attachment.zip_filename),
|
||||
archiveLabel: stringOrUndefined(attachment.zip_filename),
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function zipProtectionFromBuiltAttachment(attachment: Record<string, unknown>): { protected: boolean; note: string | null } {
|
||||
const zipFilename = stringOrUndefined(attachment.zip_filename);
|
||||
if (!zipFilename) return { protected: false, note: null };
|
||||
const legacyMode = String(attachment.password_mode ?? attachment.zip_password_mode ?? "").trim();
|
||||
const protectedArchive = getBool(attachment, "password_enabled", getBool(attachment, "zip_password_protected", getBool(attachment, "zip_protected", ["direct", "field", "template"].includes(legacyMode))));
|
||||
if (!protectedArchive) return { protected: false, note: null };
|
||||
const field = stringOrUndefined(attachment.password_field) ?? stringOrUndefined(attachment.zip_password_field);
|
||||
const rawScope = String(attachment.password_scope ?? attachment.zip_password_scope ?? "local");
|
||||
const scope = rawScope === "global" ? "global" : "local";
|
||||
const method = String(attachment.method ?? attachment.zip_method ?? "aes") === "zip_standard" ? "ZipCrypto" : "AES";
|
||||
const source = field ? `${humanizeScope(scope)} field "${field}"` : "";
|
||||
return { protected: true, note: [source, `Encryption: ${method}`].filter(Boolean).join(" · ") };
|
||||
}
|
||||
|
||||
function humanizeScope(scope: string): string {
|
||||
return scope === "global" ? "Global" : "Local";
|
||||
}
|
||||
|
||||
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: "Cc", value: message.cc_header || null },
|
||||
{ label: "Bcc", value: message.bcc_header || null },
|
||||
{ label: "Kind", value: message.kind || "—" },
|
||||
{ label: "Folder", value: message.folder || "—" },
|
||||
{ label: "Message-ID", value: message.message_id || "—" },
|
||||
@@ -1297,7 +1585,7 @@ function mockMessageMetaItems(message: MockMailboxMessage) {
|
||||
];
|
||||
}
|
||||
|
||||
function mockMessageAttachments(message: MockMailboxMessage): MessagePreviewAttachment[] {
|
||||
function mockMessageAttachments(message: MockMailboxMessage): CampaignMessagePreviewAttachment[] {
|
||||
return (message.attachments ?? []).map((attachment, index) => ({
|
||||
filename: attachment.filename || `Attachment ${index + 1}`,
|
||||
contentType: attachment.content_type || undefined,
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
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 { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
||||
import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
||||
import { TemplateFieldChipList, UndefinedPlaceholderDecisionDialog, UndefinedPlaceholderList } from "./components/TemplatePlaceholderControls";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asArray, asRecord, formatDateTime, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import { cloneJson, getBool, getText } from "./utils/draftEditor";
|
||||
import { humanizeFieldName } from "./utils/fieldDefinitions";
|
||||
import { campaignJsonForAttachmentPreview } from "./utils/templatePreviewDraft";
|
||||
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
|
||||
|
||||
type BodyMode = "text" | "html";
|
||||
type TemplateBodyMode = "text" | "html" | "both";
|
||||
type BodyEditorMode = "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 [activeBodyEditor, setActiveBodyEditor] = useState<BodyEditorMode>("text");
|
||||
const [activeEditor, setActiveEditor] = useState<EditorTarget>("text");
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewIndex, setPreviewIndex] = useState(0);
|
||||
@@ -51,6 +53,8 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
onLoaded: () => setPreviewIndex(0)
|
||||
});
|
||||
const template = asRecord(displayDraft.template);
|
||||
const templateBodyMode = normalizeTemplateBodyMode(getText(template, "body_mode", "both"));
|
||||
const visibleBodyEditor: BodyEditorMode = templateBodyMode === "html" ? "html" : templateBodyMode === "text" ? "text" : activeBodyEditor;
|
||||
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]);
|
||||
@@ -73,7 +77,11 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
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 templateText = [
|
||||
getText(template, "subject"),
|
||||
templateBodyMode !== "html" ? getText(template, "text") : "",
|
||||
templateBodyMode !== "text" ? getText(template, "html") : ""
|
||||
].join("\n");
|
||||
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]);
|
||||
const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]);
|
||||
const undefinedPlaceholders = useMemo(
|
||||
@@ -90,8 +98,8 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
[attachmentPreviewRules, selectedPreviewEntryIndex]
|
||||
);
|
||||
const previewAttachments = useMemo(
|
||||
() => mapResolvedAttachmentsToPreviewBoxes(previewAttachmentRules, attachmentPreviewLoading, attachmentPreviewError),
|
||||
[attachmentPreviewError, attachmentPreviewLoading, previewAttachmentRules]
|
||||
() => mapResolvedAttachmentsToPreviewBoxes(previewAttachmentRules, attachmentPreviewLoading, attachmentPreviewError, displayDraft),
|
||||
[attachmentPreviewError, attachmentPreviewLoading, displayDraft, previewAttachmentRules]
|
||||
);
|
||||
|
||||
|
||||
@@ -99,6 +107,12 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
if (previewIndex >= previewEntries.length) setPreviewIndex(Math.max(0, previewEntries.length - 1));
|
||||
}, [previewIndex, previewEntries.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (templateBodyMode === "text" && activeBodyEditor !== "text") setActiveBodyEditor("text");
|
||||
if (templateBodyMode === "html" && activeBodyEditor !== "html") setActiveBodyEditor("html");
|
||||
if (activeEditor !== "subject" && activeEditor !== visibleBodyEditor) setActiveEditor(visibleBodyEditor);
|
||||
}, [activeBodyEditor, activeEditor, templateBodyMode, visibleBodyEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewOpen || !version?.id || !draft) return;
|
||||
let cancelled = false;
|
||||
@@ -107,7 +121,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
const handle = window.setTimeout(() => {
|
||||
void previewCampaignAttachments(settings, campaignId, version.id, {
|
||||
include_unmatched: false,
|
||||
campaign_json: displayDraft
|
||||
campaign_json: campaignJsonForAttachmentPreview(displayDraft)
|
||||
})
|
||||
.then((response) => {
|
||||
if (!cancelled) setAttachmentPreviewRules(response.rules);
|
||||
@@ -133,9 +147,18 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
patch(["template", target], value);
|
||||
}
|
||||
|
||||
function patchTemplateBodyMode(mode: TemplateBodyMode) {
|
||||
if (locked) return;
|
||||
patch(["template", "body_mode"], mode);
|
||||
if (mode !== "both") {
|
||||
setActiveBodyEditor(mode);
|
||||
if (activeEditor !== "subject") setActiveEditor(mode);
|
||||
}
|
||||
}
|
||||
|
||||
function insertPlaceholder(namespace: TemplateNamespace, name: string) {
|
||||
if (locked) return;
|
||||
const target = bodyMode === "html" && activeEditor !== "subject" ? "html" : activeEditor;
|
||||
const target = activeEditor === "subject" ? "subject" : visibleBodyEditor;
|
||||
const element = target === "subject" ? subjectRef.current : target === "html" ? htmlRef.current : textRef.current;
|
||||
const token = `{{${namespace}:${name}}}`;
|
||||
const currentText = getText(template, target);
|
||||
@@ -217,30 +240,39 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
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="Message body format">
|
||||
<div className="template-body-mode" role="tablist" aria-label="Message body format">
|
||||
<button type="button" className={templateBodyMode === "text" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("text")}>Text only</button>
|
||||
<button type="button" className={templateBodyMode === "html" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("html")}>HTML only</button>
|
||||
<button type="button" className={templateBodyMode === "both" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("both")}>Both</button>
|
||||
</div>
|
||||
</FormField>
|
||||
{templateBodyMode === "both" && (
|
||||
<div className="template-body-mode template-editor-mode" role="tablist" aria-label="Body editor">
|
||||
<button type="button" className={visibleBodyEditor === "text" ? "active" : ""} onClick={() => { setActiveBodyEditor("text"); setActiveEditor("text"); }}>Plain text</button>
|
||||
<button type="button" className={visibleBodyEditor === "html" ? "active" : ""} onClick={() => { setActiveBodyEditor("html"); setActiveEditor("html"); }}>HTML</button>
|
||||
</div>
|
||||
)}
|
||||
{visibleBodyEditor === "text" && (
|
||||
<FormField label="Plain text body">
|
||||
<textarea
|
||||
ref={textRef}
|
||||
rows={16}
|
||||
value={getText(template, "text")}
|
||||
disabled={locked}
|
||||
onFocus={() => setActiveEditor("text")}
|
||||
onFocus={() => { setActiveBodyEditor("text"); setActiveEditor("text"); }}
|
||||
onChange={(event) => patchTemplateText("text", event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
{bodyMode === "html" && (
|
||||
{visibleBodyEditor === "html" && (
|
||||
<FormField label="HTML body">
|
||||
<textarea
|
||||
ref={htmlRef}
|
||||
rows={16}
|
||||
value={getText(template, "html")}
|
||||
disabled={locked}
|
||||
onFocus={() => setActiveEditor("html")}
|
||||
onFocus={() => { setActiveBodyEditor("html"); setActiveEditor("html"); }}
|
||||
onChange={(event) => patchTemplateText("html", event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -285,19 +317,17 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
</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
|
||||
<CampaignMessagePreviewOverlay
|
||||
title="Template preview"
|
||||
bodyMode={bodyMode}
|
||||
bodyMode={visibleBodyEditor}
|
||||
subject={previewSubject}
|
||||
text={previewText}
|
||||
html={previewHtml}
|
||||
text={templateBodyMode === "html" ? null : previewText}
|
||||
html={templateBodyMode === "text" ? null : previewHtml}
|
||||
metaItems={templatePreviewMetaItems(previewContext)}
|
||||
recipientLabel={activePreviewEntries.length > 0 ? recipientLabel(previewEntry, previewSelection.sourceIndex - 1) : "Global preview"}
|
||||
recipientNote={activePreviewEntries.length > 0 ? `${Math.min(previewIndex, previewEntries.length - 1) + 1} of ${previewEntries.length}` : inlineEntries.length > 0 ? "No recipient preview is available." : "No inline recipients are available yet."}
|
||||
attachments={previewAttachments}
|
||||
@@ -328,8 +358,9 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
function mapResolvedAttachmentsToPreviewBoxes(
|
||||
rules: CampaignAttachmentPreviewRule[],
|
||||
loading: boolean,
|
||||
error: string
|
||||
): MessagePreviewAttachment[] {
|
||||
error: string,
|
||||
draft: Record<string, unknown>
|
||||
): CampaignMessagePreviewAttachment[] {
|
||||
if (loading) {
|
||||
return [{ filename: "Resolving attachment patterns…", detail: "Managed files are being checked for this recipient." }];
|
||||
}
|
||||
@@ -337,6 +368,7 @@ function mapResolvedAttachmentsToPreviewBoxes(
|
||||
return [{ filename: "Attachment preview unavailable", detail: error }];
|
||||
}
|
||||
return rules.flatMap((rule) => {
|
||||
const zipProtection = zipProtectionForRule(rule, draft);
|
||||
const detailParts = [
|
||||
rule.source === "global" ? "Global" : "Recipient",
|
||||
rule.label,
|
||||
@@ -352,7 +384,9 @@ function mapResolvedAttachmentsToPreviewBoxes(
|
||||
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
|
||||
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null,
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note
|
||||
}));
|
||||
}
|
||||
return [{
|
||||
@@ -360,12 +394,46 @@ function mapResolvedAttachmentsToPreviewBoxes(
|
||||
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
|
||||
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null,
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function templatePreviewMetaItems(context: Record<string, string>) {
|
||||
return [
|
||||
{ label: "From", value: context["local:from"] || null },
|
||||
{ label: "To", value: context["local:all_to"] || null },
|
||||
{ label: "Reply-To", value: context["local:all_reply_to"] || null },
|
||||
{ label: "Cc", value: context["local:all_cc"] || null },
|
||||
{ label: "Bcc", value: context["local:all_bcc"] || null }
|
||||
];
|
||||
}
|
||||
|
||||
function zipProtectionForRule(rule: CampaignAttachmentPreviewRule, draft: Record<string, unknown>): { protected: boolean; note: string | null } {
|
||||
if (!rule.zip_included) return { protected: false, note: null };
|
||||
const zipConfig = asRecord(asRecord(draft.attachments).zip);
|
||||
const archives = asArray(zipConfig.archives).map(asRecord);
|
||||
const archive = archives.find((item) => {
|
||||
const id = getText(item, "id");
|
||||
const name = getText(item, "name", getText(item, "filename_template"));
|
||||
return (rule.zip_archive_id && id === rule.zip_archive_id) || (rule.zip_filename && name === rule.zip_filename);
|
||||
}) ?? archives.find((item) => getBool(item, "standard")) ?? archives[0];
|
||||
if (!archive) return { protected: false, note: null };
|
||||
const legacyMode = getText(archive, "password_mode");
|
||||
const protectedArchive = getBool(archive, "password_enabled", ["direct", "field", "template"].includes(legacyMode));
|
||||
if (!protectedArchive) return { protected: false, note: null };
|
||||
const field = getText(archive, "password_field");
|
||||
const scope = getText(archive, "password_scope") === "global" ? "global" : "local";
|
||||
const method = getText(archive, "method", "aes") === "zip_standard" ? "ZipCrypto" : "AES";
|
||||
const source = field ? `${humanizeScope(scope)} field "${field}"` : "";
|
||||
return { protected: true, note: [source, `Encryption: ${method}`].filter(Boolean).join(" · ") };
|
||||
}
|
||||
|
||||
function humanizeScope(scope: string): string {
|
||||
return scope === "global" ? "Global" : "Local";
|
||||
}
|
||||
|
||||
function recipientLabel(entry: Record<string, unknown>, index: number): string {
|
||||
const name = valueToPreview(entry.name).trim();
|
||||
@@ -376,6 +444,11 @@ function recipientLabel(entry: Record<string, unknown>, index: number): string {
|
||||
return `Recipient ${index + 1}`;
|
||||
}
|
||||
|
||||
function normalizeTemplateBodyMode(value: string): TemplateBodyMode {
|
||||
if (value === "text" || value === "html" || value === "both") return value;
|
||||
return "both";
|
||||
}
|
||||
|
||||
function uniqueSorted(values: string[]): string[] {
|
||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import Button from "../../../components/Button";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid";
|
||||
import ToggleSwitch from "../../../components/ToggleSwitch";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { getBool, getText } from "../utils/draftEditor";
|
||||
import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments";
|
||||
import ManagedFileChooser, { type ManagedAttachmentSelection } from "./ManagedFileChooser";
|
||||
import { insertAfter, moveArrayItem } from "../../../utils/arrayOrder";
|
||||
import { insertAfter, moveArrayItem } from "@govoplan/core-webui";
|
||||
import { asRecord } from "../utils/campaignView";
|
||||
|
||||
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
|
||||
@@ -22,6 +22,7 @@ type AttachmentRulesOverlayProps = {
|
||||
emptyText?: string;
|
||||
basePaths?: AttachmentBasePath[];
|
||||
zipConfig?: AttachmentZipCollection;
|
||||
filesModuleInstalled?: boolean;
|
||||
onChange: (rules: AttachmentRule[]) => void;
|
||||
};
|
||||
|
||||
@@ -33,9 +34,9 @@ type AttachmentRulesTableProps = {
|
||||
emptyText?: string;
|
||||
basePaths?: AttachmentBasePath[];
|
||||
id?: string;
|
||||
showAddButton?: boolean;
|
||||
activeChooserRuleIndex?: number | null;
|
||||
zipConfig?: AttachmentZipCollection;
|
||||
filesModuleInstalled?: boolean;
|
||||
onOpenFileChooser?: (ruleIndex: number) => void;
|
||||
onChange: (rules: AttachmentRule[]) => void;
|
||||
};
|
||||
@@ -55,6 +56,7 @@ export default function AttachmentRulesOverlay({
|
||||
emptyText = "No attachment files or matching rules configured yet.",
|
||||
basePaths = [],
|
||||
zipConfig = { enabled: false, archives: [] },
|
||||
filesModuleInstalled = false,
|
||||
onChange
|
||||
}: AttachmentRulesOverlayProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -85,7 +87,7 @@ export default function AttachmentRulesOverlay({
|
||||
<button className="modal-close" aria-label="Cancel attachment changes" onClick={cancelOverlay}>×</button>
|
||||
</header>
|
||||
<div className="modal-body attachment-rules-body">
|
||||
<AttachmentRulesDataGrid
|
||||
<AttachmentRulesTable
|
||||
rules={draftRules}
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
@@ -93,6 +95,7 @@ export default function AttachmentRulesOverlay({
|
||||
emptyText={emptyText}
|
||||
basePaths={basePaths}
|
||||
zipConfig={zipConfig}
|
||||
filesModuleInstalled={filesModuleInstalled}
|
||||
activeChooserRuleIndex={null}
|
||||
onChange={setDraftRules}
|
||||
/>
|
||||
@@ -124,26 +127,13 @@ function cloneAttachmentRules(rules: AttachmentRule[]): AttachmentRule[] {
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
@@ -159,6 +149,7 @@ export function AttachmentRulesDataGrid({
|
||||
id = "attachment-rules",
|
||||
activeChooserRuleIndex = null,
|
||||
zipConfig = { enabled: false, archives: [] },
|
||||
filesModuleInstalled = false,
|
||||
onOpenFileChooser,
|
||||
onChange
|
||||
}: AttachmentRulesTableProps) {
|
||||
@@ -186,6 +177,7 @@ export function AttachmentRulesDataGrid({
|
||||
}
|
||||
|
||||
function openFileChooser(ruleIndex: number) {
|
||||
if (!filesModuleInstalled) return;
|
||||
if (onOpenFileChooser) {
|
||||
onOpenFileChooser(ruleIndex);
|
||||
return;
|
||||
@@ -221,14 +213,14 @@ export function AttachmentRulesDataGrid({
|
||||
<DataGrid
|
||||
id={id}
|
||||
rows={rules}
|
||||
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
|
||||
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled, 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 && (
|
||||
{fileChooser && filesModuleInstalled && (
|
||||
<ManagedFileChooser
|
||||
open
|
||||
settings={settings}
|
||||
@@ -251,6 +243,7 @@ type AttachmentRuleColumnContext = {
|
||||
rules: AttachmentRule[];
|
||||
basePaths: AttachmentBasePath[];
|
||||
zipConfig: AttachmentZipCollection;
|
||||
filesModuleInstalled: boolean;
|
||||
activeChooserRuleIndex: number | null;
|
||||
patchRule: (index: number, patch: Partial<AttachmentRule>) => void;
|
||||
addRule: (afterIndex?: number) => void;
|
||||
@@ -259,7 +252,7 @@ type AttachmentRuleColumnContext = {
|
||||
removeRule: (index: number) => void;
|
||||
};
|
||||
|
||||
function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, activeChooserRuleIndex: _activeChooserRuleIndex, patchRule, addRule, moveRule, openFileChooser, removeRule }: AttachmentRuleColumnContext): DataGridColumn<AttachmentRule>[] {
|
||||
function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled, activeChooserRuleIndex: _activeChooserRuleIndex, patchRule, addRule, moveRule, openFileChooser, removeRule }: AttachmentRuleColumnContext): DataGridColumn<AttachmentRule>[] {
|
||||
return [
|
||||
{ id: "label", header: "Label", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (rule, index) => <input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} />, value: (rule) => getText(rule, "label") },
|
||||
{
|
||||
@@ -308,18 +301,21 @@ function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, activeCh
|
||||
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)}
|
||||
readOnly={filesModuleInstalled}
|
||||
tabIndex={filesModuleInstalled ? -1 : undefined}
|
||||
placeholder={filesModuleInstalled ? "Choose a managed file or pattern" : "file.pdf or **/*.pdf"}
|
||||
onChange={(event) => {
|
||||
if (!filesModuleInstalled) patchRule(index, { file_filter: event.target.value });
|
||||
}}
|
||||
onClick={() => filesModuleInstalled && !disabled && openFileChooser(index)}
|
||||
onKeyDown={(event) => {
|
||||
if (!disabled && (event.key === "Enter" || event.key === " ")) {
|
||||
if (filesModuleInstalled && !disabled && (event.key === "Enter" || event.key === " ")) {
|
||||
event.preventDefault();
|
||||
openFileChooser(index);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>Choose</Button>
|
||||
{filesModuleInstalled && <Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>Choose</Button>}
|
||||
</div>
|
||||
),
|
||||
value: (rule) => getText(rule, "file_filter")
|
||||
|
||||
@@ -9,14 +9,13 @@ import {
|
||||
type CampaignShare,
|
||||
type CampaignShareTargets
|
||||
} from "../../../api/campaigns";
|
||||
import Button from "../../../components/Button";
|
||||
import Card from "../../../components/Card";
|
||||
import ConfirmDialog from "../../../components/ConfirmDialog";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
|
||||
import Dialog from "../../../components/Dialog";
|
||||
import FormField from "../../../components/FormField";
|
||||
import StatusBadge from "../../../components/StatusBadge";
|
||||
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
type TargetType = "user" | "group";
|
||||
|
||||
export default function CampaignAccessCard({
|
||||
@@ -143,12 +142,12 @@ export default function CampaignAccessCard({
|
||||
<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></>}>
|
||||
<Dialog open={ownerOpen} className="campaign-access-dialog" title="Change campaign owner" onClose={() => !busy && setOwnerOpen(false)} footer={<><Button onClick={() => setOwnerOpen(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId}>Save owner</Button></>}>
|
||||
<FormField label="Owner type"><select value={ownerType} onChange={(event) => { const next = event.target.value as TargetType; setOwnerType(next); setOwnerId((next === "user" ? targets.users : targets.groups)[0]?.id || ""); }}><option value="user">User</option><option value="group">Group</option></select></FormField>
|
||||
<FormField label="Owner"><select value={ownerId} onChange={(event) => setOwnerId(event.target.value)}>{targetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? ` · ${item.secondary}` : ""}</option>)}</select></FormField>
|
||||
</Dialog>
|
||||
|
||||
<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></>}>
|
||||
<Dialog open={shareOpen} className="campaign-access-dialog" title="Share campaign" onClose={() => !busy && setShareOpen(false)} footer={<><Button onClick={() => setShareOpen(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveShare()} disabled={busy || !shareTargetId}>Save share</Button></>}>
|
||||
<FormField label="Target type"><select value={shareType} onChange={(event) => { const next = event.target.value as TargetType; setShareType(next); setShareTargetId((next === "user" ? targets.users : targets.groups)[0]?.id || ""); }}><option value="user">User</option><option value="group">Group</option></select></FormField>
|
||||
<FormField label="User or group"><select value={shareTargetId} onChange={(event) => setShareTargetId(event.target.value)}><option value="">Select…</option>{shareTargetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? ` · ${item.secondary}` : ""}</option>)}</select></FormField>
|
||||
<FormField label="Access"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">Can view</option><option value="write">Can edit and operate</option></select></FormField>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { Button } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import {
|
||||
forkCampaignVersion,
|
||||
lockCampaignVersionPermanently,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { usePlatformUiCapability, type FilesFileExplorerUiCapability } from "@govoplan/core-webui";
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import {
|
||||
listFileSpaces,
|
||||
@@ -12,10 +13,10 @@ import {
|
||||
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 { Button } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { FolderTree } from "../../files/components/FileManagerComponents";
|
||||
import { useFileTreeState } from "../../files/hooks/useFileTreeState";
|
||||
import type { FolderNode, SortColumn, SortDirection } from "../../files/types";
|
||||
@@ -84,6 +85,8 @@ export default function ManagedFileChooser({
|
||||
onSelectFolder,
|
||||
onSelectAttachment
|
||||
}: ManagedFileChooserProps) {
|
||||
const filesExplorerUi = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
|
||||
const FolderTreeComponent = filesExplorerUi?.FolderTree ?? FolderTree;
|
||||
const parsedSource = useMemo(() => parseManagedAttachmentSource(source), [source]);
|
||||
const normalizedBasePath = normalizeManagedBasePath(basePath);
|
||||
const storageKey = useMemo(
|
||||
@@ -345,7 +348,7 @@ export default function ManagedFileChooser({
|
||||
<span>{space.label}</span>
|
||||
</button>
|
||||
{selected && (
|
||||
<FolderTree
|
||||
<FolderTreeComponent
|
||||
nodes={treeNodes}
|
||||
activeSpaceId={selectedSpaceId}
|
||||
spaceId={space.id}
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
|
||||
import Button from "../../../components/Button";
|
||||
import { Button, MessageDisplayPanel, type MessageDisplayAttachment } from "@govoplan/core-webui";
|
||||
|
||||
export type MessagePreviewAttachment = {
|
||||
filename?: string | null;
|
||||
// Campaign review/template/mock previews need recipient navigation, review notes,
|
||||
// raw MIME inspection and attachment grouping. Generic mailbox reading uses
|
||||
// core MessageDisplayPanel instead.
|
||||
export type CampaignMessagePreviewAttachment = MessageDisplayAttachment & {
|
||||
label?: string | null;
|
||||
detail?: string | null;
|
||||
contentType?: string | null;
|
||||
sizeBytes?: number | null;
|
||||
archiveGroup?: string | null;
|
||||
archiveLabel?: string | null;
|
||||
};
|
||||
|
||||
export type MessagePreviewMetaItem = {
|
||||
export type CampaignMessagePreviewMetaItem = {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
value: ReactNode;
|
||||
};
|
||||
|
||||
export type MessagePreviewNavigation = {
|
||||
export type CampaignMessagePreviewNavigation = {
|
||||
index: number;
|
||||
total: number;
|
||||
onFirst: () => void;
|
||||
@@ -25,24 +23,24 @@ export type MessagePreviewNavigation = {
|
||||
onLast: () => void;
|
||||
};
|
||||
|
||||
export type MessagePreviewOverlayProps = {
|
||||
export type CampaignMessagePreviewOverlayProps = {
|
||||
title?: string;
|
||||
subject?: string | null;
|
||||
bodyMode?: "text" | "html";
|
||||
text?: string | null;
|
||||
html?: string | null;
|
||||
recipientLabel?: React.ReactNode;
|
||||
recipientNote?: React.ReactNode;
|
||||
metaItems?: MessagePreviewMetaItem[];
|
||||
attachments?: MessagePreviewAttachment[];
|
||||
recipientLabel?: ReactNode;
|
||||
recipientNote?: ReactNode;
|
||||
metaItems?: CampaignMessagePreviewMetaItem[];
|
||||
attachments?: CampaignMessagePreviewAttachment[];
|
||||
raw?: string | null;
|
||||
rawLabel?: string;
|
||||
navigation?: MessagePreviewNavigation;
|
||||
navigation?: CampaignMessagePreviewNavigation;
|
||||
closeLabel?: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function MessagePreviewOverlay({
|
||||
export default function CampaignMessagePreviewOverlay({
|
||||
title = "Message preview",
|
||||
subject,
|
||||
bodyMode = "text",
|
||||
@@ -57,10 +55,9 @@ export default function MessagePreviewOverlay({
|
||||
navigation,
|
||||
closeLabel = "Close",
|
||||
onClose
|
||||
}: MessagePreviewOverlayProps) {
|
||||
}: CampaignMessagePreviewOverlayProps) {
|
||||
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>";
|
||||
const fields = metaItems.map((item) => ({ label: item.label, value: item.value }));
|
||||
|
||||
return (
|
||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="message-preview-title">
|
||||
@@ -88,24 +85,16 @@ export default function MessagePreviewOverlay({
|
||||
</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} />
|
||||
<MessageDisplayPanel
|
||||
title={shownSubject}
|
||||
fields={fields}
|
||||
bodyText={text}
|
||||
bodyHtml={html}
|
||||
preferredBodyMode={bodyMode}
|
||||
deriveTextFromHtml={false}
|
||||
attachments={attachments}
|
||||
emptyText="No message content is available."
|
||||
/>
|
||||
|
||||
{raw && (
|
||||
<details className="message-preview-raw">
|
||||
@@ -120,46 +109,8 @@ export default function MessagePreviewOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
export type MessagePreviewAttachment = CampaignMessagePreviewAttachment;
|
||||
export type MessagePreviewMetaItem = CampaignMessagePreviewMetaItem;
|
||||
export type MessagePreviewNavigation = CampaignMessagePreviewNavigation;
|
||||
export type MessagePreviewOverlayProps = CampaignMessagePreviewOverlayProps;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { Button } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import {
|
||||
buildUndefinedPlaceholders,
|
||||
extractTemplatePlaceholders,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Button from "../../../components/Button";
|
||||
import Dialog from "../../../components/Dialog";
|
||||
import DismissibleAlert from "../../../components/DismissibleAlert";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import type { TemplateNamespace, TemplatePlaceholder, UndefinedPlaceholder } from "../utils/templatePlaceholders";
|
||||
|
||||
export type TemplateFieldOption = {
|
||||
|
||||
@@ -1,180 +1,9 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
|
||||
hasUnsavedChanges: false,
|
||||
registerUnsavedChanges: () => () => undefined,
|
||||
requestNavigation: (action) => action()
|
||||
};
|
||||
|
||||
export function useCampaignUnsavedChanges() {
|
||||
return useContext(UnsavedChangesContext) ?? fallbackUnsavedChangesContext;
|
||||
}
|
||||
|
||||
export function useRegisterCampaignUnsavedChanges(registration: UnsavedChangesRegistration | null) {
|
||||
const { registerUnsavedChanges } = useCampaignUnsavedChanges();
|
||||
|
||||
useEffect(() => {
|
||||
return registerUnsavedChanges(registration);
|
||||
}, [registerUnsavedChanges, registration]);
|
||||
}
|
||||
export {
|
||||
UnsavedChangesProvider as CampaignUnsavedChangesProvider,
|
||||
useRegisterUnsavedChanges as useRegisterCampaignUnsavedChanges,
|
||||
useUnsavedChanges as useCampaignUnsavedChanges
|
||||
} from "@govoplan/core-webui";
|
||||
export type {
|
||||
UnsavedChangesRegistration,
|
||||
UnsavedNavigationAction
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import { autosaveCampaignVersion, type CampaignVersionDetail, type CampaignVersionUpdatePayload } from "../../../api/campaigns";
|
||||
import { formatDateTime, getCampaignJson } from "../utils/campaignView";
|
||||
@@ -119,12 +119,14 @@ export function useCampaignDraftEditor({
|
||||
}
|
||||
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, setError, settings, version, workflowState]);
|
||||
|
||||
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
|
||||
const unsavedRegistration = useMemo(() => dirty && !locked ? {
|
||||
title: unsavedTitle,
|
||||
message: unsavedMessage,
|
||||
onSave: () => saveDraft("manual"),
|
||||
onDiscard: () => setDirty(false)
|
||||
} : null);
|
||||
} : null, [dirty, locked, saveDraft, unsavedMessage, unsavedTitle]);
|
||||
|
||||
useRegisterCampaignUnsavedChanges(unsavedRegistration);
|
||||
|
||||
return {
|
||||
draft,
|
||||
|
||||
@@ -2,12 +2,7 @@ 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
|
||||
getCampaignWorkspace
|
||||
} from "../../../api/campaigns";
|
||||
import type { CampaignWorkspaceData } from "../utils/campaignView";
|
||||
|
||||
@@ -45,24 +40,19 @@ export function useCampaignWorkspaceData(
|
||||
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)
|
||||
]);
|
||||
const response = await getCampaignWorkspace(settings, campaignId, {
|
||||
versionId: selectedVersionId,
|
||||
includeCurrentVersion,
|
||||
includeSummary,
|
||||
includeVersions: shouldLoadVersions,
|
||||
});
|
||||
|
||||
setData({
|
||||
campaign,
|
||||
versions,
|
||||
currentVersion: versionResult.status === "fulfilled" ? (versionResult.value as CampaignVersionDetail | null) : null,
|
||||
summary: summaryResult.status === "fulfilled" ? (summaryResult.value as CampaignSummary | null) : null
|
||||
campaign: response.campaign,
|
||||
versions: response.versions,
|
||||
currentVersion: response.current_version,
|
||||
summary: response.summary
|
||||
});
|
||||
} catch (err) {
|
||||
setData(initialData);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { asArray, asRecord } from "./campaignView";
|
||||
import { getBool } from "./draftEditor";
|
||||
import { addressesFromValue, type MailboxAddress } from "../../../utils/emailAddresses";
|
||||
|
||||
import { addressesFromValue, type MailboxAddress } from "@govoplan/core-webui";
|
||||
export type TemplateNamespace = "global" | "local";
|
||||
|
||||
export const RECIPIENT_ADDRESS_FIELD_IDS = ["from", "to", "reply_to", "cc", "bcc"] as const;
|
||||
|
||||
85
webui/src/features/campaigns/utils/templatePreviewDraft.ts
Normal file
85
webui/src/features/campaigns/utils/templatePreviewDraft.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
const addressArrayKeys = [
|
||||
"from",
|
||||
"to",
|
||||
"cc",
|
||||
"bcc",
|
||||
"reply_to",
|
||||
"bounce_to",
|
||||
"disposition_notification_to"
|
||||
];
|
||||
|
||||
export function campaignJsonForAttachmentPreview(draft: Record<string, unknown>): Record<string, unknown> {
|
||||
const next = cloneJson(draft);
|
||||
const entries = asRecord(next.entries);
|
||||
if (Array.isArray(entries.inline)) {
|
||||
entries.inline = entries.inline.map((entry) => normalizePreviewEntry(entry));
|
||||
}
|
||||
if (isRecord(entries.defaults)) {
|
||||
entries.defaults = normalizePreviewEntry(entries.defaults);
|
||||
}
|
||||
next.entries = entries;
|
||||
if (isRecord(next.recipients)) {
|
||||
next.recipients = normalizeAddressContainer(next.recipients);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizePreviewEntry(value: unknown): Record<string, unknown> {
|
||||
const entry = { ...asRecord(value) };
|
||||
if (typeof entry.email === "string" && !entry.email.trim()) delete entry.email;
|
||||
if (typeof entry.name === "string" && !entry.name.trim()) delete entry.name;
|
||||
|
||||
for (const key of addressArrayKeys) {
|
||||
const current = entry[key];
|
||||
if (Array.isArray(current)) {
|
||||
const filtered = current.map(normalizeRecipient).filter((item): item is Record<string, unknown> => Boolean(item));
|
||||
entry[key] = filtered;
|
||||
continue;
|
||||
}
|
||||
if (isRecord(current)) {
|
||||
const normalized = normalizeRecipient(current);
|
||||
if (normalized) entry[key] = normalized;
|
||||
else delete entry[key];
|
||||
}
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
function normalizeAddressContainer(value: Record<string, unknown>): Record<string, unknown> {
|
||||
const container = { ...value };
|
||||
for (const key of addressArrayKeys) {
|
||||
const current = container[key];
|
||||
if (Array.isArray(current)) {
|
||||
container[key] = current.map(normalizeRecipient).filter((item): item is Record<string, unknown> => Boolean(item));
|
||||
continue;
|
||||
}
|
||||
if (isRecord(current)) {
|
||||
const normalized = normalizeRecipient(current);
|
||||
if (normalized) container[key] = normalized;
|
||||
else delete container[key];
|
||||
}
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
function normalizeRecipient(value: unknown): Record<string, unknown> | null {
|
||||
const recipient = { ...asRecord(value) };
|
||||
const email = typeof recipient.email === "string" ? recipient.email.trim() : "";
|
||||
if (!email) return null;
|
||||
recipient.email = email;
|
||||
if (typeof recipient.name === "string" && !recipient.name.trim()) delete recipient.name;
|
||||
return recipient;
|
||||
}
|
||||
|
||||
function cloneJson<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value ?? {})) as T;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
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 { Stepper } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import LockedVersionNotice from "../components/LockedVersionNotice";
|
||||
import { validatePartial } from "../../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "../hooks/useCampaignWorkspaceData";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Card from "../../../components/Card";
|
||||
import MetricCard from "../../../components/MetricCard";
|
||||
import Button from "../../../components/Button";
|
||||
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { MetricCard } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
export default function ReviewWizard() {
|
||||
return (
|
||||
<div className="content-pad">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Card from "../../../components/Card";
|
||||
import Button from "../../../components/Button";
|
||||
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
export default function SendWizard() {
|
||||
return (
|
||||
<div className="content-pad">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { Button } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { EmailAddressInput } from "@govoplan/core-webui";
|
||||
import { MetricCard } from "@govoplan/core-webui";
|
||||
import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "@govoplan/core-webui";
|
||||
import { asArray, asRecord, stringifyPreview, summaryValue } from "../../utils/campaignView";
|
||||
import { getBool, getNumber, getText, parseJsonTextarea, stringifyJson } from "../../utils/draftEditor";
|
||||
|
||||
|
||||
@@ -1 +1,79 @@
|
||||
export { FolderTree } from "@govoplan/files-webui";
|
||||
import type { DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent } from "react";
|
||||
import { ExplorerTree, type ExplorerTreeNodeContext } from "@govoplan/core-webui";
|
||||
import type { FolderNode } from "../types";
|
||||
import { normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils";
|
||||
|
||||
type FileActionTarget = { spaceId: string; folderPath: string };
|
||||
|
||||
export function FolderTree({
|
||||
nodes,
|
||||
activeSpaceId,
|
||||
spaceId,
|
||||
currentFolder,
|
||||
dropTargetKey = "",
|
||||
expandedKeys,
|
||||
onOpen,
|
||||
onToggle,
|
||||
onContextMenu,
|
||||
onDragOverTarget,
|
||||
onDropOnTarget,
|
||||
onClearDropState,
|
||||
onRequestDragExpand,
|
||||
onDragStartFolder,
|
||||
onDragEndFolder,
|
||||
dragDropEnabled = true,
|
||||
contextMenuEnabled = true,
|
||||
disabled,
|
||||
depth = 1
|
||||
}: {
|
||||
nodes: FolderNode[];
|
||||
activeSpaceId: string;
|
||||
spaceId: string;
|
||||
currentFolder: string;
|
||||
dropTargetKey?: string;
|
||||
expandedKeys: Set<string>;
|
||||
onOpen: (spaceId: string, path: string) => void;
|
||||
onToggle: (spaceId: string, path: string) => void;
|
||||
onContextMenu?: (event: ReactMouseEvent<HTMLElement>, spaceId: string, path: string) => void;
|
||||
onDragOverTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => void;
|
||||
onDropOnTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => Promise<void>;
|
||||
onClearDropState?: () => void;
|
||||
onRequestDragExpand?: (spaceId: string, path: string) => void;
|
||||
onDragStartFolder?: (spaceId: string, path: string, event: ReactDragEvent<HTMLElement>) => void;
|
||||
onDragEndFolder?: () => void;
|
||||
dragDropEnabled?: boolean;
|
||||
contextMenuEnabled?: boolean;
|
||||
disabled?: boolean;
|
||||
depth?: number;
|
||||
}) {
|
||||
return (
|
||||
<ExplorerTree
|
||||
nodes={nodes}
|
||||
getNodeId={(node) => treeNodeKey(spaceId, node.path)}
|
||||
getNodeLabel={(node) => node.name}
|
||||
getNodeChildren={(node) => node.children}
|
||||
activeId={activeSpaceId === spaceId ? treeNodeKey(spaceId, currentFolder) : ""}
|
||||
expandedIds={expandedKeys}
|
||||
onOpen={(node) => onOpen(spaceId, node.path)}
|
||||
onToggle={(node) => onToggle(spaceId, node.path)}
|
||||
disabled={disabled}
|
||||
depth={depth}
|
||||
getNodeWrapClassName={(node) => {
|
||||
const target = { spaceId, folderPath: node.path };
|
||||
return dragDropEnabled && dropTargetKey === `${target.spaceId}:${normalizeFolder(target.folderPath)}` ? "is-drop-target" : undefined;
|
||||
}}
|
||||
getNodeWrapStyle={(_node, context: ExplorerTreeNodeContext) => ({ paddingLeft: `${Math.min(context.depth * 14, 56)}px` })}
|
||||
getNodeDraggable={() => dragDropEnabled && !disabled}
|
||||
onContextMenu={contextMenuEnabled && onContextMenu ? (event, node) => onContextMenu(event, spaceId, node.path) : undefined}
|
||||
onDragStart={dragDropEnabled && onDragStartFolder ? (event, node) => onDragStartFolder(spaceId, node.path, event) : undefined}
|
||||
onDragEnd={dragDropEnabled && onDragEndFolder ? () => onDragEndFolder() : undefined}
|
||||
onDragOver={dragDropEnabled && onDragOverTarget ? (event, node, context) => {
|
||||
const target = { spaceId, folderPath: node.path };
|
||||
onDragOverTarget(event, target);
|
||||
if (context.hasChildren && !context.expanded) onRequestDragExpand?.(spaceId, node.path);
|
||||
} : undefined}
|
||||
onDragLeave={dragDropEnabled && onClearDropState ? () => onClearDropState() : undefined}
|
||||
onDrop={dragDropEnabled && onDropOnTarget ? (event, node) => void onDropOnTarget(event, { spaceId, folderPath: node.path }) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,87 @@
|
||||
export { useFileTreeState } from "@govoplan/files-webui";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { folderAncestorPaths, isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils";
|
||||
|
||||
export function useFileTreeState({
|
||||
activeSpaceId,
|
||||
currentFolder,
|
||||
onOpenFolder
|
||||
}: {
|
||||
activeSpaceId: string;
|
||||
currentFolder: string;
|
||||
onOpenFolder: (spaceId: string, path: string) => void;
|
||||
}) {
|
||||
const [expandedTreeNodes, setExpandedTreeNodes] = useState<Set<string>>(() => new Set());
|
||||
const dragExpandTimerRef = useRef<number | null>(null);
|
||||
const dragExpandKeyRef = useRef<string | null>(null);
|
||||
const suppressTreeAutoExpandKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSpaceId) return;
|
||||
const ancestors = folderAncestorPaths(currentFolder, { includeSelf: true });
|
||||
if (ancestors.length === 0) return;
|
||||
const suppressedKey = suppressTreeAutoExpandKeyRef.current;
|
||||
suppressTreeAutoExpandKeyRef.current = null;
|
||||
setExpandedTreeNodes((current) => {
|
||||
const next = new Set(current);
|
||||
ancestors.forEach((path) => {
|
||||
const key = treeNodeKey(activeSpaceId, path);
|
||||
if (key !== suppressedKey) next.add(key);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, [activeSpaceId, currentFolder]);
|
||||
|
||||
function cancelTreeDragExpand() {
|
||||
if (dragExpandTimerRef.current !== null) {
|
||||
window.clearTimeout(dragExpandTimerRef.current);
|
||||
dragExpandTimerRef.current = null;
|
||||
}
|
||||
dragExpandKeyRef.current = null;
|
||||
}
|
||||
|
||||
function expandTreeNode(spaceId: string, folderPath: string) {
|
||||
const key = treeNodeKey(spaceId, folderPath);
|
||||
setExpandedTreeNodes((current) => {
|
||||
if (current.has(key)) return current;
|
||||
const next = new Set(current);
|
||||
next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleTreeDragExpand(spaceId: string, folderPath: string) {
|
||||
const key = treeNodeKey(spaceId, folderPath);
|
||||
if (expandedTreeNodes.has(key) || dragExpandKeyRef.current === key) return;
|
||||
cancelTreeDragExpand();
|
||||
dragExpandKeyRef.current = key;
|
||||
dragExpandTimerRef.current = window.setTimeout(() => {
|
||||
expandTreeNode(spaceId, folderPath);
|
||||
dragExpandTimerRef.current = null;
|
||||
dragExpandKeyRef.current = null;
|
||||
}, 750);
|
||||
}
|
||||
|
||||
function toggleTreeFolder(spaceId: string, folderPath: string) {
|
||||
const normalizedPath = normalizeFolder(folderPath);
|
||||
const key = treeNodeKey(spaceId, normalizedPath);
|
||||
const isExpanded = expandedTreeNodes.has(key);
|
||||
if (isExpanded && spaceId === activeSpaceId && isPathUnderOrSame(currentFolder, normalizedPath)) {
|
||||
suppressTreeAutoExpandKeyRef.current = key;
|
||||
onOpenFolder(spaceId, normalizedPath);
|
||||
}
|
||||
setExpandedTreeNodes((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
expandedTreeNodes,
|
||||
setExpandedTreeNodes,
|
||||
cancelTreeDragExpand,
|
||||
scheduleTreeDragExpand,
|
||||
toggleTreeFolder
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1 +1,32 @@
|
||||
export type { FolderNode, SortColumn, SortDirection } from "@govoplan/files-webui";
|
||||
import type { ManagedFile } from "../../api/files";
|
||||
|
||||
export type SortColumn = "name" | "size" | "modified";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
export type FolderNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
children: FolderNode[];
|
||||
fileCount: number;
|
||||
persisted: boolean;
|
||||
};
|
||||
|
||||
export type FolderEntry = {
|
||||
kind: "folder";
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
fileCount: number;
|
||||
folderCount: number;
|
||||
totalSize: number;
|
||||
updatedAt: string;
|
||||
persisted: boolean;
|
||||
};
|
||||
|
||||
export type FileEntry = {
|
||||
kind: "file";
|
||||
id: string;
|
||||
file: ManagedFile;
|
||||
};
|
||||
|
||||
export type ExplorerEntry = FolderEntry | FileEntry;
|
||||
|
||||
@@ -1 +1,214 @@
|
||||
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";
|
||||
import { formatDateTime as formatPlatformDateTime } from "@govoplan/core-webui";
|
||||
import type { FileFolder, ManagedFile } from "../../../api/files";
|
||||
import type { ExplorerEntry, FileEntry, FolderEntry, FolderNode, SortColumn, SortDirection } from "../types";
|
||||
|
||||
export function buildFolderTree(files: ManagedFile[], folders: FileFolder[]): FolderNode[] {
|
||||
const byPath = new Map<string, FolderNode>();
|
||||
const ensureNode = (path: string, persisted: boolean): FolderNode | null => {
|
||||
const normalized = normalizeFolder(path);
|
||||
if (!normalized) return null;
|
||||
const existing = byPath.get(normalized);
|
||||
if (existing) {
|
||||
existing.persisted = existing.persisted || persisted;
|
||||
return existing;
|
||||
}
|
||||
const parts = normalized.split("/");
|
||||
const node: FolderNode = {
|
||||
name: parts[parts.length - 1],
|
||||
path: normalized,
|
||||
children: [],
|
||||
fileCount: 0,
|
||||
persisted
|
||||
};
|
||||
byPath.set(normalized, node);
|
||||
const parentPath = parts.slice(0, -1).join("/");
|
||||
const parent = ensureNode(parentPath, false);
|
||||
if (parent) parent.children.push(node);
|
||||
return node;
|
||||
};
|
||||
for (const folder of folders) ensureNode(folder.path, true);
|
||||
for (const file of files) {
|
||||
const parts = normalizeFilePath(file.display_path).split("/").filter(Boolean);
|
||||
for (let index = 0; index < parts.length - 1; index += 1) {
|
||||
const node = ensureNode(parts.slice(0, index + 1).join("/"), false);
|
||||
if (node) node.fileCount += 1;
|
||||
}
|
||||
}
|
||||
const roots = Array.from(byPath.values()).filter((node) => !node.path.includes("/"));
|
||||
sortFolderNodes(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
export function sortFolderNodes(nodes: FolderNode[]) {
|
||||
nodes.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const node of nodes) sortFolderNodes(node.children);
|
||||
}
|
||||
|
||||
export function treeNodeKey(spaceId: string, folderPath: string): string {
|
||||
return `${spaceId}:${normalizeFolder(folderPath)}`;
|
||||
}
|
||||
|
||||
export function folderAncestorPaths(folderPath: string, options: { includeSelf?: boolean } = {}): string[] {
|
||||
const parts = normalizeFolder(folderPath).split("/").filter(Boolean);
|
||||
const limit = options.includeSelf ? parts.length : Math.max(parts.length - 1, 0);
|
||||
const result: string[] = [];
|
||||
for (let index = 1; index <= limit; index += 1) {
|
||||
result.push(parts.slice(0, index).join("/"));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isPathUnderOrSame(path: string, root: string): boolean {
|
||||
const normalizedPath = normalizeFolder(path);
|
||||
const normalizedRoot = normalizeFolder(root);
|
||||
if (!normalizedRoot) return true;
|
||||
return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
|
||||
}
|
||||
|
||||
export function buildExplorerEntries(files: ManagedFile[], folders: FileFolder[], currentFolder: string, searchActive: boolean): ExplorerEntry[] {
|
||||
if (searchActive) return files.map((file) => ({ kind: "file", id: file.id, file }));
|
||||
const folder = normalizeFolder(currentFolder);
|
||||
const prefix = folder ? `${folder}/` : "";
|
||||
const folderMap = new Map<string, FolderEntry>();
|
||||
const directFiles: FileEntry[] = [];
|
||||
|
||||
for (const persisted of folders) {
|
||||
const path = normalizeFolder(persisted.path);
|
||||
if (!path.startsWith(prefix) || path === folder) continue;
|
||||
const relative = prefix ? path.slice(prefix.length) : path;
|
||||
if (!relative || relative.includes("/")) continue;
|
||||
folderMap.set(path, {
|
||||
kind: "folder",
|
||||
id: `folder:${path}`,
|
||||
name: relative,
|
||||
path,
|
||||
fileCount: 0,
|
||||
folderCount: 0,
|
||||
totalSize: 0,
|
||||
updatedAt: persisted.updated_at,
|
||||
persisted: true
|
||||
});
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const path = normalizeFilePath(file.display_path || file.filename);
|
||||
if (prefix && !path.startsWith(prefix)) continue;
|
||||
const relativePath = prefix ? path.slice(prefix.length) : path;
|
||||
if (!relativePath) continue;
|
||||
const slashIndex = relativePath.indexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
directFiles.push({ kind: "file", id: file.id, file });
|
||||
continue;
|
||||
}
|
||||
const folderName = relativePath.slice(0, slashIndex);
|
||||
const folderPath = prefix ? `${folder}/${folderName}` : folderName;
|
||||
const existing = folderMap.get(folderPath);
|
||||
if (existing) {
|
||||
existing.totalSize += file.size_bytes;
|
||||
if (file.updated_at > existing.updatedAt) existing.updatedAt = file.updated_at;
|
||||
} else {
|
||||
folderMap.set(folderPath, {
|
||||
kind: "folder",
|
||||
id: `folder:${folderPath}`,
|
||||
name: folderName,
|
||||
path: folderPath,
|
||||
fileCount: 0,
|
||||
folderCount: 0,
|
||||
totalSize: file.size_bytes,
|
||||
updatedAt: file.updated_at,
|
||||
persisted: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of folderMap.values()) {
|
||||
const counts = directFolderCounts(files, folders, entry.path);
|
||||
entry.fileCount = counts.files;
|
||||
entry.folderCount = counts.folders;
|
||||
}
|
||||
|
||||
return [...Array.from(folderMap.values()), ...directFiles];
|
||||
}
|
||||
|
||||
export function sortExplorerEntries(entries: ExplorerEntry[], column: SortColumn, direction: SortDirection): ExplorerEntry[] {
|
||||
const factor = direction === "asc" ? 1 : -1;
|
||||
return [...entries].sort((a, b) => {
|
||||
if (column === "name") {
|
||||
if (a.kind !== b.kind) return a.kind === "folder" ? (direction === "asc" ? -1 : 1) : (direction === "asc" ? 1 : -1);
|
||||
return factor * entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
if (column === "size") {
|
||||
const delta = entrySize(a) - entrySize(b);
|
||||
if (delta !== 0) return factor * delta;
|
||||
return entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
const timeA = entryModifiedTime(a);
|
||||
const timeB = entryModifiedTime(b);
|
||||
if (timeA !== timeB) return factor * (timeA - timeB);
|
||||
return entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" });
|
||||
});
|
||||
}
|
||||
|
||||
export function entryName(entry: ExplorerEntry): string {
|
||||
return entry.kind === "folder" ? entry.name : entry.file.filename;
|
||||
}
|
||||
|
||||
export function entrySize(entry: ExplorerEntry): number {
|
||||
return entry.kind === "folder" ? entry.totalSize : entry.file.size_bytes;
|
||||
}
|
||||
|
||||
export function entryModifiedTime(entry: ExplorerEntry): number {
|
||||
const raw = entry.kind === "folder" ? entry.updatedAt : entry.file.updated_at;
|
||||
const parsed = new Date(raw).getTime();
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
export function directFolderCounts(files: ManagedFile[], folders: FileFolder[], folderPath: string): { files: number; folders: number } {
|
||||
const normalized = normalizeFolder(folderPath);
|
||||
const prefix = normalized ? `${normalized}/` : "";
|
||||
const directFiles = files.filter((file) => parentFolderPath(file.display_path) === normalized).length;
|
||||
const childFolders = new Set<string>();
|
||||
for (const folder of folders) {
|
||||
const path = normalizeFolder(folder.path);
|
||||
if (!path.startsWith(prefix) || path === normalized) continue;
|
||||
const relative = prefix ? path.slice(prefix.length) : path;
|
||||
if (!relative) continue;
|
||||
childFolders.add(relative.split("/")[0]);
|
||||
}
|
||||
for (const file of files) {
|
||||
const path = normalizeFilePath(file.display_path);
|
||||
if (!path.startsWith(prefix)) continue;
|
||||
const relative = prefix ? path.slice(prefix.length) : path;
|
||||
if (!relative || !relative.includes("/")) continue;
|
||||
childFolders.add(relative.split("/")[0]);
|
||||
}
|
||||
return { files: directFiles, folders: childFolders.size };
|
||||
}
|
||||
|
||||
export function parentFolderPath(path: string): string {
|
||||
const parts = normalizeFilePath(path).split("/").filter(Boolean);
|
||||
return normalizeFolder(parts.slice(0, -1).join("/"));
|
||||
}
|
||||
|
||||
export function normalizeFolder(value: string): string {
|
||||
return value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").replace(/\/+/g, "/").trim();
|
||||
}
|
||||
|
||||
export function normalizeFilePath(value: string): string {
|
||||
return value.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+/g, "/").trim();
|
||||
}
|
||||
|
||||
export function formatBytes(value: number): string {
|
||||
if (value < 1024) return `${value} B`;
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let size = value / 1024;
|
||||
for (const unit of units) {
|
||||
if (size < 1024) return `${size.toFixed(size >= 10 ? 0 : 1)} ${unit}`;
|
||||
size /= 1024;
|
||||
}
|
||||
return `${size.toFixed(1)} PB`;
|
||||
}
|
||||
|
||||
export function formatDate(value: string): string {
|
||||
return formatPlatformDateTime(value);
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { MailProfileScopeManager } from "@govoplan/mail-webui";
|
||||
|
||||
export { MailProfilePolicyEditor, MailProfileScopeManager } from "@govoplan/mail-webui";
|
||||
export default MailProfileScopeManager;
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { ApiSettings, CampaignListItem } from "../../types";
|
||||
import { getCampaignSummary, listCampaigns, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
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 { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { MetricCard } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { asRecord, formatDateTime, humanize } from "../campaigns/utils/campaignView";
|
||||
|
||||
type OperatorRow = {
|
||||
@@ -92,11 +94,13 @@ export default function OperatorQueuePage({ settings }: { settings: ApiSettings
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 320,
|
||||
width: 270,
|
||||
sticky: "end",
|
||||
render: (row) => (
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => navigate(`/campaigns/${row.campaign.id}/report`)}>Open queue</Button>
|
||||
<Button className="admin-icon-button" onClick={() => navigate(`/campaigns/${row.campaign.id}/report`)} aria-label={`Open queue for ${row.campaign.name}`} title={`Open queue for ${row.campaign.name}`}>
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
<Button onClick={() => void runAction(row, "retry")} disabled={row.failed <= 0 || Boolean(busy)}>Retry</Button>
|
||||
<Button onClick={() => void runAction(row, "unattempted")} disabled={row.notAttempted <= 0 || Boolean(busy)}>Queue unsent</Button>
|
||||
</div>
|
||||
@@ -119,15 +123,16 @@ export default function OperatorQueuePage({ settings }: { settings: ApiSettings
|
||||
{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>
|
||||
<section className="queue-pressure-section" aria-labelledby="operator-queue-pressure-title">
|
||||
<h2 id="operator-queue-pressure-title" className="queue-pressure-heading">Queue pressure</h2>
|
||||
<QueuePressureGrid items={[
|
||||
{ label: "Failed", value: totals.failed, tone: "danger" },
|
||||
{ label: "Outcome unknown", value: totals.outcomeUnknown, tone: "warning" },
|
||||
{ label: "Unattempted", value: totals.notAttempted, tone: "info" },
|
||||
{ label: "Queued/active", value: totals.queuedOrActive, tone: "neutral" },
|
||||
{ label: "IMAP failed", value: totals.imapFailed, tone: "warning" },
|
||||
]} />
|
||||
</section>
|
||||
|
||||
<LoadingFrame loading={loading} label="Loading operator queue…">
|
||||
<Card title="Campaign queues">
|
||||
@@ -164,14 +169,11 @@ function numberValue(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function MetricGrid({ items }: { items: Array<[string, number]> }) {
|
||||
function QueuePressureGrid({ items }: { items: Array<{ label: string; value: number; tone: "neutral" | "good" | "warning" | "danger" | "info" }> }) {
|
||||
return (
|
||||
<div className="status-grid">
|
||||
{items.map(([label, value]) => (
|
||||
<div className="status-card" key={label}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
<div className="metric-grid queue-pressure-grid">
|
||||
{items.map((item) => (
|
||||
<MetricCard key={item.label} label={item.label} value={item.value} tone={item.tone} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 { ExternalLink } from "lucide-react";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { FieldLabel } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
|
||||
@@ -89,7 +90,18 @@ function templateLibraryColumns(openTemplate: (templateId: string) => void): Dat
|
||||
{ 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> }
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 70,
|
||||
sticky: "end",
|
||||
align: "right",
|
||||
render: (template) => (
|
||||
<Button className="admin-icon-button" onClick={() => openTemplate(template.id)} aria-label={`Open ${template.name}`} title={`Open ${template.name}`}>
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,17 @@ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
|
||||
{
|
||||
title: "SETTINGS",
|
||||
items: [
|
||||
{ id: "mail-settings", label: "Server settings" },
|
||||
{ id: "mail-settings", label: "Mail settings" },
|
||||
{ id: "global-settings", label: "Campaign settings" }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "POLICIES",
|
||||
items: [
|
||||
{ id: "mail-policy", label: "Mail policy" },
|
||||
{ id: "policies", label: "Campaign policies" }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "SEND",
|
||||
items: [
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
/* Campaign workspace data interfaces. Kept separate from layout.css so local sticky/table tweaks stay untouched. */
|
||||
.workspace-data-page .card { margin-bottom: 18px; }
|
||||
.workspace-data-page > .workspace-heading {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 75;
|
||||
background: var(--bg, #f8f7f4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
box-shadow: 0 8px 18px rgba(65, 60, 52, .08);
|
||||
margin: -28px -34px 22px;
|
||||
padding: 18px 34px 16px;
|
||||
}
|
||||
.workspace-heading .mono-small { margin-top: 8px; }
|
||||
.alert.success { background: var(--success-bg); color: var(--success-text); margin-bottom: 12px; }
|
||||
.alert.info { background: var(--info-bg); color: var(--info-text); margin-bottom: 12px; }
|
||||
@@ -95,10 +105,6 @@
|
||||
color: #4d4944;
|
||||
}
|
||||
.danger-text { color: var(--danger-text); }
|
||||
.page-bottom-actions {
|
||||
justify-content: flex-end;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.section-mini-heading {
|
||||
margin: 18px 0 8px;
|
||||
font-size: 12px;
|
||||
@@ -375,6 +381,28 @@
|
||||
.settings-dashboard-grid {
|
||||
align-items: start;
|
||||
}
|
||||
.queue-pressure-section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.queue-pressure-heading {
|
||||
margin: 0 0 12px;
|
||||
color: var(--text-strong);
|
||||
font-size: 16px;
|
||||
}
|
||||
.queue-pressure-grid {
|
||||
grid-template-columns: repeat(5, minmax(112px, 1fr));
|
||||
margin: 0;
|
||||
}
|
||||
.queue-pressure-grid .metric-card {
|
||||
min-width: 0;
|
||||
}
|
||||
.queue-pressure-grid .metric-label {
|
||||
min-height: 2.4em;
|
||||
}
|
||||
|
||||
@media (max-width: 1150px) {
|
||||
.queue-pressure-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
|
||||
}
|
||||
.module-table th,
|
||||
.module-table td {
|
||||
white-space: nowrap;
|
||||
@@ -605,6 +633,13 @@
|
||||
color: var(--text-strong);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.12);
|
||||
}
|
||||
.template-body-mode button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .58;
|
||||
}
|
||||
.template-editor-mode {
|
||||
margin-top: -2px;
|
||||
}
|
||||
.template-editor-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -812,23 +847,32 @@
|
||||
margin-top: 12px;
|
||||
}
|
||||
.attachment-rules-modal {
|
||||
width: min(1120px, 100%);
|
||||
width: min(1500px, 100%);
|
||||
}
|
||||
.attachment-rules-body {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.attachment-rules-body > .data-grid-shell:only-child {
|
||||
margin: -22px;
|
||||
width: calc(100% + 44px);
|
||||
.attachment-rules-body .attachment-rules-editor,
|
||||
.attachment-rules-body .attachment-rules-main {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.attachment-rules-body > .data-grid-shell:only-child,
|
||||
.attachment-rules-body .attachment-rules-main > .data-grid-shell:only-child {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.attachment-rules-body > .data-grid-shell:only-child .data-grid-container {
|
||||
.attachment-rules-body > .data-grid-shell:only-child .data-grid-container,
|
||||
.attachment-rules-body .attachment-rules-main > .data-grid-shell:only-child .data-grid-container {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.attachment-rules-body > .data-grid-shell:only-child .data-grid-container .data-grid {
|
||||
.attachment-rules-body > .data-grid-shell:only-child .data-grid-container .data-grid,
|
||||
.attachment-rules-body .attachment-rules-main > .data-grid-shell:only-child .data-grid-container .data-grid {
|
||||
min-height: 0;
|
||||
}
|
||||
.attachment-rules-empty {
|
||||
@@ -854,15 +898,17 @@
|
||||
.recipient-address-stack { min-width: 260px; }
|
||||
}
|
||||
.attachment-rules-table th:nth-child(1),
|
||||
.attachment-rules-table td:nth-child(1) { min-width: 160px; }
|
||||
.attachment-rules-table td:nth-child(1) { min-width: 180px; }
|
||||
.attachment-rules-table th:nth-child(2),
|
||||
.attachment-rules-table td:nth-child(2) { width: 280px; }
|
||||
.attachment-rules-table td:nth-child(2) { width: 240px; }
|
||||
.attachment-rules-table th:nth-child(3),
|
||||
.attachment-rules-table td:nth-child(3) { min-width: 230px; }
|
||||
.attachment-rules-table td:nth-child(3) { min-width: 300px; }
|
||||
.attachment-rules-table th:nth-child(4),
|
||||
.attachment-rules-table td:nth-child(4) { width: 175px; }
|
||||
.attachment-rules-table td:nth-child(4) { width: 220px; }
|
||||
.attachment-rules-table th:nth-child(5),
|
||||
.attachment-rules-table td:nth-child(5) { width: 160px; }
|
||||
.attachment-rules-table th:last-child,
|
||||
.attachment-rules-table td:last-child { width: 145px; }
|
||||
.attachment-rules-table td:last-child { width: 150px; }
|
||||
.attachment-rules-table .data-grid-body-cell:last-child .btn {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -883,9 +929,6 @@
|
||||
.attachment-rules-table tr.is-choosing-file td {
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
.attachment-rules-footer-actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.attachment-file-browser-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -1192,6 +1235,12 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.message-preview-modal .message-display-body,
|
||||
.message-preview-modal .message-display-html-frame {
|
||||
height: clamp(280px, 45vh, 520px);
|
||||
max-height: clamp(280px, 45vh, 520px);
|
||||
}
|
||||
|
||||
.message-preview-meta {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1573,10 +1622,10 @@
|
||||
|
||||
.review-flow-navigation {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
top: 85px;
|
||||
z-index: 35;
|
||||
margin: 0 0 24px;
|
||||
padding: 10px;
|
||||
padding: 15px 10px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(247, 246, 244, .96);
|
||||
@@ -2488,6 +2537,20 @@
|
||||
min-width: 0;
|
||||
box-shadow: var(--shadow-card, 0 2px 10px rgba(0,0,0,.06));
|
||||
}
|
||||
.card-body > .admin-table-surface:only-child {
|
||||
margin: -22px -24px;
|
||||
width: calc(100% + 48px);
|
||||
max-width: inherit;
|
||||
}
|
||||
.card-body > .admin-table-surface:only-child > .data-grid-shell {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.campaigns-page .card-body > .admin-table-surface:only-child {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.admin-icon-actions {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
@@ -2593,6 +2656,16 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
.campaign-access-dialog > .dialog-header,
|
||||
.campaign-access-dialog > .dialog-footer {
|
||||
background: var(--surface);
|
||||
}
|
||||
.campaign-access-dialog > .dialog-body {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Administration scope separation and ownership safeguards ---------------- */
|
||||
.admin-overview-section-label {
|
||||
margin: 2px 0 14px;
|
||||
@@ -2613,3 +2686,18 @@
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* Campaign policy tables. */
|
||||
.campaign-policy-row {
|
||||
grid-template-columns: minmax(190px, .9fr) minmax(180px, .7fr) minmax(220px, 1fr);
|
||||
}
|
||||
.campaign-policy-control .toggle-switch {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.campaign-policy-row {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { insertAfter, moveArrayItem } from "@govoplan/core-webui";
|
||||
@@ -1,101 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui";
|
||||
54
webui/tests/template-preview-draft.test.ts
Normal file
54
webui/tests/template-preview-draft.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { campaignJsonForAttachmentPreview } from "../src/features/campaigns/utils/templatePreviewDraft";
|
||||
|
||||
function assert(condition: unknown, message = "assertion failed"): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
const draft = {
|
||||
recipients: {
|
||||
from: { name: "", email: "" },
|
||||
to: [{ name: "Bob", email: " bob@example.org " }, { name: "", email: "" }]
|
||||
},
|
||||
entries: {
|
||||
inline: [{
|
||||
id: "recipient-1",
|
||||
active: true,
|
||||
name: "",
|
||||
email: "",
|
||||
from: [],
|
||||
to: [{ name: "", email: "" }, { name: "Alice", email: " alice@example.org " }],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
merge_to: false
|
||||
}],
|
||||
defaults: {
|
||||
name: "",
|
||||
email: "",
|
||||
to: [{ name: "", email: "" }],
|
||||
attachments: []
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const normalized = campaignJsonForAttachmentPreview(draft);
|
||||
const entries = asRecord(normalized.entries);
|
||||
const inline = entries.inline as Record<string, unknown>[];
|
||||
const entry = inline[0];
|
||||
const to = entry.to as Record<string, unknown>[];
|
||||
const defaults = asRecord(entries.defaults);
|
||||
const recipients = asRecord(normalized.recipients);
|
||||
const globalTo = recipients.to as Record<string, unknown>[];
|
||||
|
||||
assert(recipients.from === undefined, "empty global from object is stripped");
|
||||
assert(globalTo.length === 1 && globalTo[0].email === "bob@example.org", "global recipient arrays are sanitized");
|
||||
assert(entry.email === undefined, "empty legacy entry email is stripped");
|
||||
assert(entry.name === undefined, "empty legacy entry name is stripped");
|
||||
assert(Array.isArray(entry.from) && (entry.from as unknown[]).length === 0, "empty address arrays remain arrays");
|
||||
assert(to.length === 1 && to[0].email === "alice@example.org", "empty recipient objects are removed and valid addresses are trimmed");
|
||||
assert(defaults.email === undefined, "empty defaults email is stripped");
|
||||
assert(Array.isArray(defaults.to) && (defaults.to as unknown[]).length === 0, "empty defaults recipients are removed");
|
||||
assert((asRecord(draft.entries).inline as Record<string, unknown>[])[0].email === "", "original draft is not mutated");
|
||||
23
webui/tsconfig.template-preview-tests.json
Normal file
23
webui/tsconfig.template-preview-tests.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM"
|
||||
],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"noEmit": false,
|
||||
"outDir": ".template-preview-test-build",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"tests/template-preview-draft.test.ts",
|
||||
"src/features/campaigns/utils/templatePreviewDraft.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user