intermittent commit
This commit is contained in:
@@ -162,6 +162,59 @@ export type RecipientImportMappingProfileListResponse = {
|
||||
profiles: RecipientImportMappingProfile[];
|
||||
};
|
||||
|
||||
export type CampaignAddressLookupCandidate = {
|
||||
contact_id: string;
|
||||
address_book_id: string;
|
||||
display_name: string;
|
||||
email?: string | null;
|
||||
email_label?: string | null;
|
||||
organization?: string | null;
|
||||
role_title?: string | null;
|
||||
tags: string[];
|
||||
source_kind: string;
|
||||
source_ref?: string | null;
|
||||
source_revision?: string | null;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignAddressLookupResponse = {
|
||||
available: boolean;
|
||||
candidates: CampaignAddressLookupCandidate[];
|
||||
};
|
||||
|
||||
export type CampaignRecipientAddressSource = {
|
||||
source_id: string;
|
||||
source_label: string;
|
||||
source_kind: string;
|
||||
source_revision: string;
|
||||
recipient_count: number;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignRecipientAddressSourcesResponse = {
|
||||
available: boolean;
|
||||
sources: CampaignRecipientAddressSource[];
|
||||
};
|
||||
|
||||
export type CampaignRecipientSnapshotItem = {
|
||||
contact_id: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
email_label?: string | null;
|
||||
fields: Record<string, unknown>;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignRecipientAddressSourceSnapshot = {
|
||||
source_id: string;
|
||||
source_label: string;
|
||||
source_kind: string;
|
||||
source_revision: string;
|
||||
generated_at: string;
|
||||
recipients: CampaignRecipientSnapshotItem[];
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignVersionUpdatePayload = {
|
||||
campaign_json?: Record<string, unknown> | null;
|
||||
current_flow?: string | null;
|
||||
@@ -419,6 +472,34 @@ export async function deleteRecipientImportMappingProfile(settings: ApiSettings,
|
||||
await apiFetch<void>(settings, `/api/v1/campaigns/recipient-import/mapping-profiles/${encodeURIComponent(profileId)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function lookupCampaignAddresses(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
query: string,
|
||||
limit = 25)
|
||||
: Promise<CampaignAddressLookupResponse> {
|
||||
const params = new URLSearchParams({ query, limit: String(limit) });
|
||||
return apiFetch<CampaignAddressLookupResponse>(settings, `/api/v1/campaigns/${campaignId}/address-lookup?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function listCampaignRecipientAddressSources(
|
||||
settings: ApiSettings,
|
||||
campaignId: string)
|
||||
: Promise<CampaignRecipientAddressSourcesResponse> {
|
||||
return apiFetch<CampaignRecipientAddressSourcesResponse>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources`);
|
||||
}
|
||||
|
||||
export async function snapshotCampaignRecipientAddressSource(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
sourceId: string)
|
||||
: Promise<CampaignRecipientAddressSourceSnapshot> {
|
||||
return apiFetch<CampaignRecipientAddressSourceSnapshot>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources/snapshot`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ source_id: sourceId })
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCampaign(settings: ApiSettings, campaignId: string): Promise<CampaignListItem> {
|
||||
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { apiFetch, apiUrl, authHeaders, csrfToken, apiDownload } from "@govoplan/core-webui";
|
||||
export { apiDownload, apiFetch, apiGetList, apiPath, apiPost, apiPostJson, apiQuery, apiUrl, authHeaders, csrfToken } from "@govoplan/core-webui";
|
||||
|
||||
@@ -1,142 +1,55 @@
|
||||
import type { ApiSettings } from "../types";
|
||||
import { apiFetch } from "./client";
|
||||
import type {
|
||||
ApiSettings,
|
||||
MailConnectionTestResponse,
|
||||
MailImapFolderListResponse,
|
||||
MailImapTestPayload,
|
||||
MailProfilePolicy,
|
||||
MailProfilePolicyResponse,
|
||||
MailProfileScope,
|
||||
MailSecurity,
|
||||
MailServerProfile,
|
||||
MailServerProfilePayload,
|
||||
MailSmtpTestPayload,
|
||||
MockMailboxMessageResponse
|
||||
} from "@govoplan/core-webui";
|
||||
import { apiFetch, apiGetList, apiPath, apiPost, apiPostJson } from "./client";
|
||||
|
||||
export type MailSecurity = "plain" | "tls" | "starttls";
|
||||
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign";
|
||||
const profileActionEndpoints = {
|
||||
smtp: "test-smtp",
|
||||
imap: "test-imap",
|
||||
folders: "list-imap-folders"
|
||||
} as const;
|
||||
|
||||
export type MailSmtpTestPayload = {
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
security?: MailSecurity;
|
||||
timeout_seconds?: number;
|
||||
};
|
||||
const rawSettingsEndpoints = {
|
||||
smtp: "/api/v1/mail/test-smtp",
|
||||
imap: "/api/v1/mail/test-imap",
|
||||
folders: "/api/v1/mail/list-imap-folders"
|
||||
} as const;
|
||||
|
||||
export type MailImapTestPayload = MailSmtpTestPayload & {
|
||||
sent_folder?: string | null;
|
||||
};
|
||||
function runProfileAction<TResponse>(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
action: keyof typeof profileActionEndpoints
|
||||
): Promise<TResponse> {
|
||||
return apiPost<TResponse>(
|
||||
settings,
|
||||
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/${profileActionEndpoints[action]}`
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
function runRawSettingsAction<TResponse, TPayload>(
|
||||
settings: ApiSettings,
|
||||
payload: TPayload,
|
||||
action: keyof typeof rawSettingsEndpoints
|
||||
): Promise<TResponse> {
|
||||
return apiPostJson<TResponse>(settings, rawSettingsEndpoints[action], payload);
|
||||
}
|
||||
|
||||
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 ?? [];
|
||||
return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
|
||||
include_inactive: includeInactive ? true : undefined,
|
||||
campaign_id: campaignId
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> {
|
||||
@@ -152,71 +65,40 @@ export async function getMailProfilePolicy(
|
||||
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}`);
|
||||
return apiFetch<MailProfilePolicyResponse>(settings, apiPath(`/api/v1/mail/policies/${encodeURIComponent(scopeType)}`, {
|
||||
scope_id: scopeId,
|
||||
campaign_id: campaignId
|
||||
}));
|
||||
}
|
||||
|
||||
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
||||
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, { method: "POST" });
|
||||
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp");
|
||||
}
|
||||
|
||||
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
||||
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, { method: "POST" });
|
||||
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap");
|
||||
}
|
||||
|
||||
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" });
|
||||
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders");
|
||||
}
|
||||
|
||||
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)
|
||||
});
|
||||
return runRawSettingsAction<MailConnectionTestResponse, MailSmtpTestPayload>(settings, payload, "smtp");
|
||||
}
|
||||
|
||||
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)
|
||||
});
|
||||
return runRawSettingsAction<MailConnectionTestResponse, MailImapTestPayload>(settings, payload, "imap");
|
||||
}
|
||||
|
||||
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)
|
||||
});
|
||||
return runRawSettingsAction<MailImapFolderListResponse, MailImapTestPayload>(settings, payload, "folders");
|
||||
}
|
||||
|
||||
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)}`);
|
||||
}
|
||||
|
||||
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "@govoplan/core-webui";
|
||||
export type { MailConnectionTestResponse, MailCredentialPolicy, MailImapFolderListResponse, MailImapFolderResponse, MailImapTestPayload, MailProfilePatternKey, MailProfilePatternRules, MailProfilePolicy, MailProfilePolicyLimitKey, MailProfilePolicyLimitPermissions, MailProfilePolicyResponse, MailProfileScope, MailSecurity, MailServerProfile, MailServerProfileCredentialsPayload, MailServerProfileListResponse, MailServerProfilePayload, MailSmtpTestPayload, MailTransportCredentialsPayload, MockMailboxMessage, MockMailboxMessageResponse } from "@govoplan/core-webui";
|
||||
export type { MailPolicySourceStep as PolicySourceStep } from "@govoplan/core-webui";
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
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 = [
|
||||
{ name: "i18n:govoplan-campaign.ada_lovelace.a69a9f8a", email: "ada@example.local", source: "Personal", tags: "i18n:govoplan-campaign.used_recently.af75cea7" },
|
||||
{ name: "i18n:govoplan-campaign.grace_hopper.d97e6939", email: "grace@example.local", source: "Personal", tags: "i18n:govoplan-campaign.favorite.6b90b6a1" }];
|
||||
|
||||
|
||||
const groupContacts = [
|
||||
{ name: "i18n:govoplan-campaign.project_office.c35aa9ca", email: "project-office@example.local", source: "Group", tags: "i18n:govoplan-campaign.shared.50d0d8dd" },
|
||||
{ name: "i18n:govoplan-campaign.finance_team.ff353e5c", email: "finance@example.local", source: "Group", tags: "i18n:govoplan-campaign.shared_list.b3c94b39" }];
|
||||
|
||||
|
||||
const tenantContacts = [
|
||||
{ name: "i18n:govoplan-campaign.helpdesk.191815bf", email: "helpdesk@example.local", source: "Tenant", tags: "i18n:govoplan-campaign.directory.4b892fe0" },
|
||||
{ name: "i18n:govoplan-campaign.data_protection.06e87cd3", email: "privacy@example.local", source: "Tenant", tags: "i18n:govoplan-campaign.directory.4b892fe0" }];
|
||||
|
||||
|
||||
function contactColumns(): DataGridColumn<{name: string;email: string;source: string;tags: string;}>[] {
|
||||
return [
|
||||
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (contact) => <strong>{contact.name}</strong>, value: (contact) => contact.name },
|
||||
{ id: "email", header: "i18n:govoplan-campaign.email.84add5b2", width: 240, sortable: true, filterable: true, value: (contact) => contact.email },
|
||||
{ id: "scope", header: "i18n:govoplan-campaign.scope.4651a34e", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Personal", label: "i18n:govoplan-campaign.personal.40f07323" }, { value: "Group", label: "i18n:govoplan-campaign.group.171a0606" }, { value: "Tenant", label: "i18n:govoplan-campaign.tenant.3ca93c78" }] }, value: (contact) => contact.source },
|
||||
{ id: "tags", header: "i18n:govoplan-campaign.tags.848eed0f", width: 180, sortable: true, filterable: true, value: (contact) => contact.tags },
|
||||
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "mock", label: "i18n:govoplan-campaign.mock.3bba2a47" }], display: "pill" }, render: () => <StatusBadge status="mock" />, value: () => "mock" }];
|
||||
|
||||
}
|
||||
|
||||
export default function AddressBookPage() {
|
||||
const allContacts = [...personalContacts, ...groupContacts, ...tenantContacts];
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page module-entry-page address-book-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>i18n:govoplan-campaign.address_book.f6327f59</PageTitle>
|
||||
<p>i18n:govoplan-campaign.mock_workspace_for_personal_group_and_tenant_add.ce99f4d4</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>i18n:govoplan-campaign.import.d6fbc9d2</Button>
|
||||
<Button variant="primary" disabled>i18n:govoplan-campaign.add_contact.6da0b4b8</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metric-grid">
|
||||
<Card title="i18n:govoplan-campaign.personal.40f07323"><strong className="module-big-number">{personalContacts.length}</strong><p className="muted">i18n:govoplan-campaign.private_contacts_and_remembered_addresses.2bd71556</p></Card>
|
||||
<Card title="i18n:govoplan-campaign.group.171a0606"><strong className="module-big-number">{groupContacts.length}</strong><p className="muted">i18n:govoplan-campaign.shared_group_address_books_and_lists.12bac69d</p></Card>
|
||||
<Card title="i18n:govoplan-campaign.tenant.3ca93c78"><strong className="module-big-number">{tenantContacts.length}</strong><p className="muted">i18n:govoplan-campaign.tenant_directory_and_approved_shared_contacts.fa671f1b</p></Card>
|
||||
<Card title="i18n:govoplan-campaign.sync.905f6309"><strong className="module-big-number">i18n:govoplan-campaign.mock.3bba2a47</strong><p className="muted">i18n:govoplan-campaign.carddav_ldap_import_connectors_can_be_added_late.0471f513</p></Card>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="i18n:govoplan-campaign.address_book_scopes.b0d0efde">
|
||||
<div className="address-book-scope-list">
|
||||
<AddressBookScope title="i18n:govoplan-campaign.personal_address_book.e240066d" description="i18n:govoplan-campaign.private_contacts_remembered_recipients_and_perso.685cca95" status="Local" />
|
||||
<AddressBookScope title="i18n:govoplan-campaign.group_address_books.aed5a7c0" description="i18n:govoplan-campaign.shared_contact_sets_for_teams_departments_or_cam.4408edd7" status="Shared" />
|
||||
<AddressBookScope title="i18n:govoplan-campaign.tenant_directory.11b0e09c" description="i18n:govoplan-campaign.tenant_wide_contacts_functional_mailboxes_and_ap.c437a8b9" status="Directory" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="i18n:govoplan-campaign.planned_address_actions.1d4a056a">
|
||||
<div className="placeholder-stack">
|
||||
<span>i18n:govoplan-campaign.choose_addresses_from_personal_group_or_tenant_s.3d6dc0e4</span>
|
||||
<span>i18n:govoplan-campaign.remember_addresses_used_in_campaigns_after_opt_i.6f8b2529</span>
|
||||
<span>i18n:govoplan-campaign.share_selected_contacts_with_a_group.604d1464</span>
|
||||
<span>i18n:govoplan-campaign.use_contacts_in_to_cc_bcc_sender_and_reply_to_fi.79f3ea6a</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.contacts.b0dd615c" actions={<Button disabled>i18n:govoplan-campaign.manage_sources.ec758de0</Button>}>
|
||||
<DataGrid
|
||||
id="address-book-contacts"
|
||||
rows={allContacts}
|
||||
columns={contactColumns()}
|
||||
getRowKey={(contact) => contact.source + "-" + contact.email}
|
||||
emptyText="i18n:govoplan-campaign.no_contacts_found.ad977b09"
|
||||
className="compact-table-wrap module-table" />
|
||||
|
||||
</Card>
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function AddressBookScope({ title, description, status }: {title: string;description: string;status: string;}) {
|
||||
return (
|
||||
<div className="address-book-scope-card">
|
||||
<div>
|
||||
<strong>{title}</strong>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -1,24 +1,28 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
createRecipientImportMappingProfile,
|
||||
listCampaignRecipientAddressSources,
|
||||
listRecipientImportMappingProfiles,
|
||||
lookupCampaignAddresses,
|
||||
snapshotCampaignRecipientAddressSource,
|
||||
updateRecipientImportMappingProfile,
|
||||
type CampaignAddressLookupCandidate,
|
||||
type CampaignRecipientAddressSource,
|
||||
type CampaignRecipientAddressSourceSnapshot,
|
||||
type RecipientImportMappingProfilePayload } from
|
||||
"../../api/campaigns";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { FileDropZone } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { usePlatformUiCapability, type FilesFileExplorerUiCapability } from "@govoplan/core-webui";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { EmailAddressInput } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { SegmentedControl } from "@govoplan/core-webui";
|
||||
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
@@ -34,17 +38,18 @@ import {
|
||||
createRecipientImportProvenance,
|
||||
createRecipientMappingProfile as buildRecipientMappingProfile,
|
||||
defaultColumnMappings,
|
||||
importedRowsNeedAttachmentSource,
|
||||
matchRecipientMappingProfiles,
|
||||
materializeRecipientImport,
|
||||
materializeRecipientImportWithAttachmentDefaults,
|
||||
recipientImportHeaderFingerprints,
|
||||
xlsxSheetsFromArrayBuffer,
|
||||
type CsvDelimiter,
|
||||
type ImportedAddress,
|
||||
type ImportedRecipientRow,
|
||||
type RecipientColumnKind,
|
||||
type RecipientColumnMapping,
|
||||
type RecipientImportMode,
|
||||
type RecipientImportPreview,
|
||||
type RecipientImportTable,
|
||||
type RecipientImportProvenance,
|
||||
type RecipientImportSourceType,
|
||||
type RecipientImportSpreadsheetSheet,
|
||||
@@ -61,9 +66,10 @@ import {
|
||||
import {
|
||||
addressesFromValue,
|
||||
collectCampaignAddressSuggestions,
|
||||
dedupeAddresses,
|
||||
type MailboxAddress } from
|
||||
"@govoplan/core-webui";
|
||||
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
||||
import { insertAfter, moveArrayItem, i18nMessage, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui";
|
||||
const recipientHeaderRows = [
|
||||
{ key: "to", label: "i18n:govoplan-campaign.to.ae79ea1e", toggleKey: "allow_individual_to", toggleLabel: "i18n:govoplan-campaign.allow_individual_to.966cb33e", addLabel: "i18n:govoplan-campaign.add_recipient.a989d1f1", emptyText: "i18n:govoplan-campaign.no_global_recipients_configured.d4e20e92" },
|
||||
{ key: "cc", label: "i18n:govoplan-campaign.cc.c5a976de", toggleKey: "allow_individual_cc", toggleLabel: "i18n:govoplan-campaign.allow_individual_cc.0457c0e2", addLabel: "i18n:govoplan-campaign.add_cc.bcb39ea3", emptyText: "i18n:govoplan-campaign.no_global_cc_recipients_configured.8afb23c1" },
|
||||
@@ -71,6 +77,7 @@ const recipientHeaderRows = [
|
||||
const;
|
||||
|
||||
type RecipientAddressKey = "to" | "cc" | "bcc";
|
||||
type AddressSourceFilter = "all" | "books" | "lists";
|
||||
|
||||
type EntryAddressColumn = {
|
||||
key: RecipientAddressKey | "from" | "reply_to";
|
||||
@@ -82,8 +89,17 @@ type EntryAddressColumn = {
|
||||
};
|
||||
|
||||
export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [addressSourceImportOpen, setAddressSourceImportOpen] = useState(false);
|
||||
const [addressSourceImportInitialId, setAddressSourceImportInitialId] = useState("");
|
||||
const [addressSourcesAvailable, setAddressSourcesAvailable] = useState(false);
|
||||
const [addressSourcesLoading, setAddressSourcesLoading] = useState(false);
|
||||
const [addressSources, setAddressSources] = useState<CampaignRecipientAddressSource[]>([]);
|
||||
const [addressLookupQuery, setAddressLookupQuery] = useState("");
|
||||
const [addressLookupSuggestions, setAddressLookupSuggestions] = useState<MailboxAddress[]>([]);
|
||||
const [addressLookupCache, setAddressLookupCache] = useState<Record<string, MailboxAddress[]>>({});
|
||||
const [recipientProfilesPage, setRecipientProfilesPage] = useState(1);
|
||||
const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10);
|
||||
|
||||
@@ -110,6 +126,35 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
const importBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments, true), [attachments]);
|
||||
const defaultImportBasePath = useMemo(() => importBasePaths.find((basePath) => basePath.allow_individual) ?? importBasePaths[0] ?? null, [importBasePaths]);
|
||||
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
|
||||
const cachedAddressSuggestions = useMemo(
|
||||
() => dedupeAddresses(Object.values(addressLookupCache).flat()),
|
||||
[addressLookupCache]
|
||||
);
|
||||
const combinedAddressSuggestions = useMemo(
|
||||
() => dedupeAddresses([...addressSuggestions, ...cachedAddressSuggestions, ...addressLookupSuggestions]),
|
||||
[addressLookupSuggestions, addressSuggestions, cachedAddressSuggestions]
|
||||
);
|
||||
const addressSourceRevisionById = useMemo(
|
||||
() => new Map(addressSources.map((addressSource) => [addressSource.source_id, addressSource.source_revision])),
|
||||
[addressSources]
|
||||
);
|
||||
const staleAddressImports = useMemo(() => {
|
||||
return asArray(entries.imports).
|
||||
map(asRecord).
|
||||
filter((record) => record.source_type === "addresses").
|
||||
map((record) => {
|
||||
const sourceId = typeof record.source_id === "string" ? record.source_id : "";
|
||||
const importedRevision = typeof record.source_revision === "string" ? record.source_revision : "";
|
||||
const currentRevision = sourceId ? addressSourceRevisionById.get(sourceId) ?? "" : "";
|
||||
return {
|
||||
sourceId,
|
||||
sourceLabel: typeof record.source_label === "string" ? record.source_label : sourceId,
|
||||
importedRevision,
|
||||
currentRevision
|
||||
};
|
||||
}).
|
||||
filter((record) => record.sourceId && record.currentRevision && record.importedRevision && record.currentRevision !== record.importedRevision);
|
||||
}, [addressSourceRevisionById, entries.imports]);
|
||||
const defaultFrom = addressesFromValue(recipientsSection.from).slice(0, 1);
|
||||
const globalReplyTo = addressesFromValue(recipientsSection.reply_to);
|
||||
const globalRecipientValues: Record<string, MailboxAddress[]> = {
|
||||
@@ -133,6 +178,75 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
return columns;
|
||||
}, [recipientsSection]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setAddressSourcesLoading(true);
|
||||
void listCampaignRecipientAddressSources(settings, campaignId).
|
||||
then((response) => {
|
||||
if (cancelled) return;
|
||||
setAddressSourcesAvailable(response.available);
|
||||
setAddressSources(response.available ? response.sources ?? [] : []);
|
||||
}).
|
||||
catch(() => {
|
||||
if (cancelled) return;
|
||||
setAddressSourcesAvailable(false);
|
||||
setAddressSources([]);
|
||||
}).
|
||||
finally(() => {
|
||||
if (!cancelled) setAddressSourcesLoading(false);
|
||||
});
|
||||
return () => {cancelled = true;};
|
||||
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const query = addressLookupQuery.trim();
|
||||
if (query.length < 2) {
|
||||
setAddressLookupSuggestions(addressLookupCache[""] ?? []);
|
||||
return;
|
||||
}
|
||||
const cached = addressLookupCache[query.toLowerCase()];
|
||||
if (cached) {
|
||||
setAddressLookupSuggestions(cached);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
void lookupCampaignAddresses(settings, campaignId, query).
|
||||
then((response) => {
|
||||
if (cancelled) return;
|
||||
if (!response.available) {
|
||||
setAddressLookupSuggestions([]);
|
||||
return;
|
||||
}
|
||||
const nextSuggestions = mailboxAddressesFromLookupCandidates(response.candidates);
|
||||
setAddressLookupCache((current) => ({ ...current, [query.toLowerCase()]: nextSuggestions }));
|
||||
setAddressLookupSuggestions(nextSuggestions);
|
||||
}).
|
||||
catch(() => {
|
||||
if (!cancelled) setAddressLookupSuggestions([]);
|
||||
});
|
||||
}, 100);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [addressLookupCache, addressLookupQuery, campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void lookupCampaignAddresses(settings, campaignId, "", 100).
|
||||
then((response) => {
|
||||
if (cancelled || !response.available) return;
|
||||
setAddressLookupCache((current) => ({ ...current, "": mailboxAddressesFromLookupCandidates(response.candidates) }));
|
||||
}).
|
||||
catch(() => undefined);
|
||||
return () => {cancelled = true;};
|
||||
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
const handleAddressSuggestionQueryChange = useCallback((query: string) => {
|
||||
setAddressLookupQuery(query);
|
||||
}, []);
|
||||
|
||||
|
||||
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
|
||||
patch(["entries", "inline"], nextEntries);
|
||||
@@ -200,63 +314,47 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
|
||||
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) {
|
||||
if (locked || !draft) return;
|
||||
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
|
||||
const currentAttachments = asRecord(draft.attachments);
|
||||
let nextBasePaths = normalizeAttachmentBasePaths(currentAttachments.base_paths, currentAttachments, true);
|
||||
let attachmentBasePath: AttachmentBasePath | null = null;
|
||||
|
||||
if (needsAttachmentSource) {
|
||||
if (nextBasePaths.length === 0) {
|
||||
nextBasePaths = [{ id: "base-path-campaign", name: "Campaign files", path: ".", allow_individual: true, unsent_warning: false }];
|
||||
}
|
||||
const selectedIndex = Math.max(0, nextBasePaths.findIndex((basePath) => basePath.allow_individual));
|
||||
nextBasePaths = nextBasePaths.map((basePath, index) => index === selectedIndex ? { ...basePath, allow_individual: true } : basePath);
|
||||
attachmentBasePath = nextBasePaths[selectedIndex] ?? null;
|
||||
}
|
||||
|
||||
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath, provenance });
|
||||
const nextDraft = needsAttachmentSource ?
|
||||
{
|
||||
...importedDraft,
|
||||
attachments: {
|
||||
...asRecord(importedDraft.attachments),
|
||||
base_paths: nextBasePaths,
|
||||
base_path: nextBasePaths[0]?.path || "."
|
||||
}
|
||||
} :
|
||||
importedDraft;
|
||||
setDraft(nextDraft);
|
||||
setDraft(materializeRecipientImportWithAttachmentDefaults(draft, preview, { mode, provenance }));
|
||||
markDirty();
|
||||
setImportOpen(false);
|
||||
}
|
||||
|
||||
function applyAddressSourceImport(snapshot: CampaignRecipientAddressSourceSnapshot, mode: RecipientImportMode) {
|
||||
if (locked || !draft) return;
|
||||
const preview = buildAddressSourceImportPreview(snapshot, inlineEntries);
|
||||
const provenance = createAddressSourceImportProvenance(snapshot, preview, mode);
|
||||
applyRecipientImport(preview, mode, provenance);
|
||||
setAddressSourceImportOpen(false);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>i18n:govoplan-campaign.sender_recipients.922c6d24</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<CampaignDraftPageScaffold
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
title="i18n:govoplan-campaign.sender_recipients.922c6d24"
|
||||
loading={loading}
|
||||
version={version}
|
||||
versions={data.versions}
|
||||
saveState={saveState}
|
||||
onReload={reload}
|
||||
onSave={() => saveDraft("manual")}
|
||||
dirty={dirty}
|
||||
locked={locked}
|
||||
draft={draft}
|
||||
error={error}
|
||||
localError={localError}
|
||||
currentVersionId={data.campaign?.current_version_id}>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||
<>
|
||||
<Card title="i18n:govoplan-campaign.campaign_sender.b12e8ae2" collapsible>
|
||||
<div className="campaign-header-stack">
|
||||
<div className="campaign-header-grid">
|
||||
<FormField label="i18n:govoplan-campaign.default_from_address.b9ee6d77">
|
||||
<EmailAddressInput
|
||||
value={defaultFrom}
|
||||
suggestions={addressSuggestions}
|
||||
suggestions={combinedAddressSuggestions}
|
||||
onSuggestionQueryChange={handleAddressSuggestionQueryChange}
|
||||
allowMultiple={false}
|
||||
disabled={locked}
|
||||
addLabel="i18n:govoplan-campaign.set_from.11fe7396"
|
||||
@@ -278,7 +376,8 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
<FormField label="i18n:govoplan-campaign.global_reply_to_address.bec1a89b">
|
||||
<EmailAddressInput
|
||||
value={globalReplyTo}
|
||||
suggestions={addressSuggestions}
|
||||
suggestions={combinedAddressSuggestions}
|
||||
onSuggestionQueryChange={handleAddressSuggestionQueryChange}
|
||||
allowMultiple
|
||||
disabled={locked}
|
||||
addLabel="i18n:govoplan-campaign.add_reply_to.84b195f4"
|
||||
@@ -305,7 +404,8 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
<FormField label={row.label}>
|
||||
<EmailAddressInput
|
||||
value={globalRecipientValues[row.key] ?? []}
|
||||
suggestions={addressSuggestions}
|
||||
suggestions={combinedAddressSuggestions}
|
||||
onSuggestionQueryChange={handleAddressSuggestionQueryChange}
|
||||
allowMultiple
|
||||
disabled={locked}
|
||||
addLabel={row.addLabel}
|
||||
@@ -326,16 +426,44 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.recipient_profiles.3dc9671c" actions={<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>}>
|
||||
<Card
|
||||
title="i18n:govoplan-campaign.recipient_profiles.3dc9671c"
|
||||
actions={
|
||||
<div className="button-row compact-actions">
|
||||
{(addressSourcesAvailable || addressSourcesLoading) &&
|
||||
<Button disabled={locked || addressSourcesLoading} onClick={() => {setAddressSourceImportInitialId("");setAddressSourceImportOpen(true);}}>
|
||||
Import address book/list
|
||||
</Button>
|
||||
}
|
||||
<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>
|
||||
</div>
|
||||
}>
|
||||
{inlineEntries.length === 0 && Boolean(source.type) &&
|
||||
<DismissibleAlert tone="info">i18n:govoplan-campaign.this_campaign_references_an_external_recipient_s.bdae9fb4</DismissibleAlert>
|
||||
}
|
||||
{staleAddressImports.length > 0 &&
|
||||
<DismissibleAlert tone="warning" dismissible={false}>
|
||||
<div className="stale-address-import-warning">
|
||||
<span>Address imports are older than their source: {staleAddressImports.map((item) => item.sourceLabel || item.sourceId).join(", ")}.</span>
|
||||
<div className="button-row compact-actions">
|
||||
{staleAddressImports.map((item) =>
|
||||
<Button
|
||||
key={item.sourceId}
|
||||
type="button"
|
||||
onClick={() => {setAddressSourceImportInitialId(item.sourceId);setAddressSourceImportOpen(true);}}>
|
||||
Re-import {item.sourceLabel || "source"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DismissibleAlert>
|
||||
}
|
||||
{!source.type &&
|
||||
<div className="admin-table-surface recipient-profiles-table-surface">
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-recipient-profiles`}
|
||||
rows={inlineEntries}
|
||||
columns={recipientProfileColumns({ locked, entries: inlineEntries, entryDefaults, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry })}
|
||||
columns={recipientProfileColumns({ locked, entries: inlineEntries, entryDefaults, entryAddressColumns, addressSuggestions: combinedAddressSuggestions, onAddressSuggestionQueryChange: handleAddressSuggestionQueryChange, translateText, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry })}
|
||||
getRowKey={(entry, index) => String(entry.id || index)}
|
||||
emptyText="i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.478b8026"
|
||||
emptyAction={<DataGridEmptyAction onAdd={() => addRecipient(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_recipient.e540d52a" />}
|
||||
@@ -354,9 +482,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
</div>
|
||||
}
|
||||
</Card>
|
||||
</>
|
||||
</LoadingFrame>
|
||||
|
||||
</CampaignDraftPageScaffold>
|
||||
{importOpen &&
|
||||
<RecipientImportDialog
|
||||
settings={settings}
|
||||
@@ -368,8 +494,263 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
onImport={applyRecipientImport} />
|
||||
|
||||
}
|
||||
</div>);
|
||||
{addressSourceImportOpen &&
|
||||
<AddressSourceImportDialog
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
sources={addressSources}
|
||||
initialSourceId={addressSourceImportInitialId}
|
||||
onCancel={() => setAddressSourceImportOpen(false)}
|
||||
onImport={applyAddressSourceImport} />
|
||||
|
||||
}
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
type AddressSourceImportDialogProps = {
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
sources: CampaignRecipientAddressSource[];
|
||||
initialSourceId?: string;
|
||||
onCancel: () => void;
|
||||
onImport: (snapshot: CampaignRecipientAddressSourceSnapshot, mode: RecipientImportMode) => void;
|
||||
};
|
||||
|
||||
function addressSourceType(source: CampaignRecipientAddressSource): "book" | "list" {
|
||||
return source.source_id.startsWith("addresses:address_list:") || source.source_kind === "address_list" ? "list" : "book";
|
||||
}
|
||||
|
||||
function addressSourceTypeLabel(source: CampaignRecipientAddressSource): string {
|
||||
if (addressSourceType(source) === "list") return "Address list";
|
||||
if (source.source_kind && source.source_kind !== "local") return `${source.source_kind} address book`;
|
||||
return "Address book";
|
||||
}
|
||||
|
||||
function addressSourceScopeLabel(source: CampaignRecipientAddressSource): string {
|
||||
const provenance = asRecord(source.provenance);
|
||||
const scopeType = String(provenance.scope_type ?? "").trim();
|
||||
const scopeId = String(provenance.scope_id ?? "").trim();
|
||||
if (!scopeType) return "Unknown scope";
|
||||
const label = scopeType.charAt(0).toUpperCase() + scopeType.slice(1);
|
||||
return scopeId && scopeId !== scopeType ? `${label} - ${scopeId}` : label;
|
||||
}
|
||||
|
||||
function addressSourceSearchText(source: CampaignRecipientAddressSource): string {
|
||||
const provenance = asRecord(source.provenance);
|
||||
return [
|
||||
source.source_label,
|
||||
source.source_kind,
|
||||
source.source_id,
|
||||
addressSourceTypeLabel(source),
|
||||
addressSourceScopeLabel(source),
|
||||
provenance.address_book_id,
|
||||
provenance.address_list_id,
|
||||
provenance.scope_type,
|
||||
provenance.scope_id,
|
||||
provenance.tenant_id
|
||||
].map((value) => String(value ?? "").toLowerCase()).join(" ");
|
||||
}
|
||||
|
||||
function shortSourceRevision(value: string): string {
|
||||
if (!value) return "no revision";
|
||||
if (value.length <= 24) return value;
|
||||
return `${value.slice(0, 19)}...`;
|
||||
}
|
||||
|
||||
function AddressSourceImportDialog({ settings, campaignId, sources, initialSourceId = "", onCancel, onImport }: AddressSourceImportDialogProps) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const [selectedSourceId, setSelectedSourceId] = useState(initialSourceId || sources[0]?.source_id || "");
|
||||
const [sourceFilter, setSourceFilter] = useState<AddressSourceFilter>("all");
|
||||
const [sourceQuery, setSourceQuery] = useState("");
|
||||
const [mode, setMode] = useState<RecipientImportMode>("append");
|
||||
const [snapshot, setSnapshot] = useState<CampaignRecipientAddressSourceSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const sourceCounts = useMemo(() => {
|
||||
return sources.reduce(
|
||||
(counts, source) => {
|
||||
counts[addressSourceType(source)] += 1;
|
||||
return counts;
|
||||
},
|
||||
{ book: 0, list: 0 }
|
||||
);
|
||||
}, [sources]);
|
||||
const filteredSources = useMemo(() => {
|
||||
const query = sourceQuery.trim().toLowerCase();
|
||||
return sources.filter((source) => {
|
||||
const type = addressSourceType(source);
|
||||
if (sourceFilter === "books" && type !== "book") return false;
|
||||
if (sourceFilter === "lists" && type !== "list") return false;
|
||||
if (!query) return true;
|
||||
return addressSourceSearchText(source).includes(query);
|
||||
});
|
||||
}, [sourceFilter, sourceQuery, sources]);
|
||||
const selectedSource = useMemo(
|
||||
() => sources.find((source) => source.source_id === selectedSourceId) ?? null,
|
||||
[selectedSourceId, sources]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredSources.some((source) => source.source_id === selectedSourceId)) return;
|
||||
setSelectedSourceId(filteredSources[0]?.source_id ?? "");
|
||||
}, [filteredSources, selectedSourceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialSourceId && sources.some((source) => source.source_id === initialSourceId)) {
|
||||
setSourceFilter("all");
|
||||
setSourceQuery("");
|
||||
setSelectedSourceId(initialSourceId);
|
||||
}
|
||||
}, [initialSourceId, sources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSourceId) {
|
||||
setSnapshot(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
void snapshotCampaignRecipientAddressSource(settings, campaignId, selectedSourceId).
|
||||
then((nextSnapshot) => {
|
||||
if (!cancelled) setSnapshot(nextSnapshot);
|
||||
}).
|
||||
catch((err) => {
|
||||
if (cancelled) return;
|
||||
setSnapshot(null);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}).
|
||||
finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {cancelled = true;};
|
||||
}, [campaignId, selectedSourceId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
function openAddressBookModule() {
|
||||
onCancel();
|
||||
navigate("/address-book");
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="Import recipients from addresses"
|
||||
className="recipient-import-modal"
|
||||
bodyClassName="recipient-import-body"
|
||||
closeDisabled={loading}
|
||||
closeOnBackdrop={!loading}
|
||||
onClose={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onCancel} disabled={loading}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
||||
<Button variant="primary" disabled={loading || !snapshot || snapshot.recipients.length === 0} onClick={() => snapshot && onImport(snapshot, mode)}>
|
||||
Import recipients
|
||||
</Button>
|
||||
</>
|
||||
}>
|
||||
|
||||
<div className="address-source-import-controls">
|
||||
<div>
|
||||
<SegmentedControl
|
||||
ariaLabel="Address source type"
|
||||
value={sourceFilter}
|
||||
onChange={setSourceFilter}
|
||||
size="content"
|
||||
width="inline"
|
||||
disabled={loading || sources.length === 0}
|
||||
options={[
|
||||
{ id: "all", label: `All (${sources.length})` },
|
||||
{ id: "books", label: `Books (${sourceCounts.book})` },
|
||||
{ id: "lists", label: `Lists (${sourceCounts.list})` }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
value={sourceQuery}
|
||||
disabled={loading || sources.length === 0}
|
||||
placeholder="Search address sources"
|
||||
aria-label="Search address sources"
|
||||
onChange={(event) => setSourceQuery(event.target.value)}
|
||||
/>
|
||||
<Button type="button" onClick={openAddressBookModule}>
|
||||
Manage address books
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="campaign-header-grid recipient-import-upload-grid">
|
||||
<div className="address-source-picker" role="radiogroup" aria-label="Address source">
|
||||
{filteredSources.map((source) =>
|
||||
<button
|
||||
type="button"
|
||||
key={source.source_id}
|
||||
className={`address-source-option ${source.source_id === selectedSourceId ? "is-selected" : ""}`}
|
||||
disabled={loading}
|
||||
role="radio"
|
||||
aria-checked={source.source_id === selectedSourceId}
|
||||
onClick={() => setSelectedSourceId(source.source_id)}>
|
||||
<span className="address-source-option-main">
|
||||
<strong>{source.source_label}</strong>
|
||||
<span>{addressSourceTypeLabel(source)} - {addressSourceScopeLabel(source)}</span>
|
||||
</span>
|
||||
<span className="address-source-option-meta">
|
||||
<span>{source.recipient_count} recipients</span>
|
||||
<code>{shortSourceRevision(source.source_revision)}</code>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{sources.length > 0 && filteredSources.length === 0 &&
|
||||
<div className="empty-state compact-empty">No address sources match the current filter.</div>
|
||||
}
|
||||
</div>
|
||||
<FormField label="Import mode">
|
||||
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
|
||||
<option value="append">i18n:govoplan-campaign.append_to_current_profiles.0b434d6f</option>
|
||||
<option value="replace">i18n:govoplan-campaign.replace_current_profiles.3b3be5e2</option>
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert>}
|
||||
{loading && <DismissibleAlert tone="info" compact dismissible={false}>Loading address recipients...</DismissibleAlert>}
|
||||
{!loading && sources.length === 0 && <DismissibleAlert tone="info" dismissible={false}>No address sources are available to this campaign. Create an address book or address list in the addresses module first.</DismissibleAlert>}
|
||||
|
||||
{snapshot &&
|
||||
<>
|
||||
<dl className="detail-list recipient-import-summary">
|
||||
<div><dt>Source</dt><dd>{snapshot.source_label}</dd></div>
|
||||
<div><dt>Type</dt><dd>{selectedSource ? addressSourceTypeLabel(selectedSource) : "Address source"}</dd></div>
|
||||
<div><dt>Scope</dt><dd>{selectedSource ? addressSourceScopeLabel(selectedSource) : "Unknown"}</dd></div>
|
||||
<div><dt>Recipients</dt><dd>{snapshot.recipients.length}</dd></div>
|
||||
<div><dt>Revision</dt><dd className="mono-small">{snapshot.source_revision}</dd></div>
|
||||
</dl>
|
||||
<div className="admin-table-surface recipient-import-preview-surface">
|
||||
<table className="recipient-import-preview-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Fields</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{snapshot.recipients.slice(0, 20).map((recipient, index) =>
|
||||
<tr key={`${recipient.contact_id}-${recipient.email}`}>
|
||||
<td>{index + 1}</td>
|
||||
<td>{recipient.display_name}</td>
|
||||
<td>{recipient.email}</td>
|
||||
<td>{Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{snapshot.recipients.length > 20 && <p className="muted small-note">{snapshot.recipients.length - 20} more recipients will be imported.</p>}
|
||||
</>
|
||||
}
|
||||
</Dialog>);
|
||||
}
|
||||
|
||||
type RecipientImportDialogProps = {
|
||||
@@ -1188,18 +1569,162 @@ function formatImportBytes(value?: number | null): string {
|
||||
return i18nMessage("i18n:govoplan-campaign.bytes_mb", { value0: (value / 1024 / 1024).toFixed(1) });
|
||||
}
|
||||
|
||||
function buildAddressSourceImportPreview(snapshot: CampaignRecipientAddressSourceSnapshot, existingEntries: Record<string, unknown>[]): RecipientImportPreview {
|
||||
const headers = ["display_name", "email", "given_name", "family_name", "organization", "role_title", "phone", "tags"];
|
||||
const tableRows = [
|
||||
headers,
|
||||
...snapshot.recipients.map((recipient) => headers.map((header) => {
|
||||
if (header === "display_name") return recipient.display_name;
|
||||
if (header === "email") return recipient.email;
|
||||
return fieldValueToString((recipient.fields ?? {})[header]);
|
||||
}))];
|
||||
|
||||
const table: RecipientImportTable = {
|
||||
delimiter: ",",
|
||||
headers,
|
||||
headerRows: [headers],
|
||||
rows: tableRows,
|
||||
dataRows: tableRows.slice(1),
|
||||
firstDataRowNumber: 2
|
||||
};
|
||||
const usedIds = new Set(existingEntries.map((entry) => String(entry.id || "")).filter(Boolean));
|
||||
const fieldNamesToCreate = new Set<string>();
|
||||
const rows: ImportedRecipientRow[] = snapshot.recipients.map((recipient, index) => {
|
||||
const email = recipient.email.trim();
|
||||
const name = recipient.display_name.trim();
|
||||
const fields = stringFieldsFromAddressSource(recipient.fields);
|
||||
Object.keys(fields).forEach((fieldName) => fieldNamesToCreate.add(fieldName));
|
||||
const id = uniqueImportId(
|
||||
`address-${recipient.contact_id || idFragmentFromEmail(email) || index + 1}`,
|
||||
usedIds
|
||||
);
|
||||
const issues: string[] = [];
|
||||
if (!email.includes("@")) issues.push(`${email || "email"} must contain @`);
|
||||
return {
|
||||
rowNumber: table.firstDataRowNumber + index,
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
active: true,
|
||||
addresses: {
|
||||
from: [],
|
||||
to: email ? [{ name, email }] : [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
reply_to: []
|
||||
},
|
||||
fields,
|
||||
patterns: [],
|
||||
issues
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
table,
|
||||
rows,
|
||||
fieldNamesToCreate: [...fieldNamesToCreate].sort(),
|
||||
validCount: rows.filter((row) => row.issues.length === 0).length,
|
||||
invalidCount: rows.filter((row) => row.issues.length > 0).length,
|
||||
patternCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
function createAddressSourceImportProvenance(
|
||||
snapshot: CampaignRecipientAddressSourceSnapshot,
|
||||
preview: RecipientImportPreview,
|
||||
mode: RecipientImportMode)
|
||||
: RecipientImportProvenance {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: `recipient-import-addresses-${safeImportIdFragment(snapshot.source_id)}-${Date.now().toString(36)}`,
|
||||
imported_at: now,
|
||||
mode,
|
||||
source_type: "addresses",
|
||||
source_id: snapshot.source_id,
|
||||
source_label: snapshot.source_label,
|
||||
source_revision: snapshot.source_revision,
|
||||
source_provenance: snapshot.provenance,
|
||||
filename: null,
|
||||
sheet_name: null,
|
||||
encoding: null,
|
||||
delimiter: null,
|
||||
header_rows: 0,
|
||||
quoted: null,
|
||||
value_separators: null,
|
||||
rows_total: preview.rows.length,
|
||||
valid_rows: preview.validCount,
|
||||
invalid_rows: preview.invalidCount,
|
||||
imported_rows: preview.validCount,
|
||||
field_names_created: preview.fieldNamesToCreate.slice(),
|
||||
attachment_patterns: 0,
|
||||
mapping: []
|
||||
};
|
||||
}
|
||||
|
||||
function stringFieldsFromAddressSource(value: Record<string, unknown> | undefined): Record<string, string> {
|
||||
const fields: Record<string, string> = {};
|
||||
for (const [key, rawValue] of Object.entries(value ?? {})) {
|
||||
const fieldValue = fieldValueToString(rawValue);
|
||||
if (fieldValue) fields[key] = fieldValue;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function fieldValueToString(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (Array.isArray(value)) return value.map(fieldValueToString).filter(Boolean).join(", ");
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function idFragmentFromEmail(value: string): string {
|
||||
return safeImportIdFragment(value.split("@")[0] ?? "");
|
||||
}
|
||||
|
||||
function safeImportIdFragment(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "source";
|
||||
}
|
||||
|
||||
function uniqueImportId(preferred: string, usedIds: Set<string>): string {
|
||||
const base = safeImportIdFragment(preferred) || "recipient";
|
||||
let candidate = base;
|
||||
let counter = 2;
|
||||
while (usedIds.has(candidate)) {
|
||||
candidate = `${base}-${counter}`;
|
||||
counter += 1;
|
||||
}
|
||||
usedIds.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
|
||||
function suggestImportFieldName(value: string): string {
|
||||
const cleaned = value.trim().replace(/^fields[._-]+/i, "").replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "");
|
||||
return cleaned || "field";
|
||||
}
|
||||
|
||||
function mailboxAddressesFromLookupCandidates(candidates: CampaignAddressLookupCandidate[]): MailboxAddress[] {
|
||||
return dedupeAddresses(
|
||||
candidates.
|
||||
map((candidate) => candidate.email ? { name: candidate.display_name || undefined, email: candidate.email } : null).
|
||||
filter((address): address is MailboxAddress => Boolean(address?.email))
|
||||
);
|
||||
}
|
||||
|
||||
type RecipientProfileColumnContext = {
|
||||
locked: boolean;
|
||||
entries: Record<string, unknown>[];
|
||||
entryDefaults: Record<string, unknown>;
|
||||
entryAddressColumns: EntryAddressColumn[];
|
||||
addressSuggestions: MailboxAddress[];
|
||||
onAddressSuggestionQueryChange: (query: string) => void;
|
||||
translateText: (value: string) => string;
|
||||
updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void;
|
||||
updateEntryMerge: (index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) => void;
|
||||
updateEntry: (index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) => void;
|
||||
@@ -1208,7 +1733,7 @@ type RecipientProfileColumnContext = {
|
||||
removeEntry: (index: number) => void;
|
||||
};
|
||||
|
||||
function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
||||
function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressColumns, addressSuggestions, onAddressSuggestionQueryChange, translateText, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
||||
return [
|
||||
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small">{index + 1}</span>, value: (_entry, index) => index + 1 },
|
||||
...entryAddressColumns.map((column): DataGridColumn<Record<string, unknown>> => ({
|
||||
@@ -1222,6 +1747,7 @@ function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressC
|
||||
<EmailAddressInput
|
||||
value={getEntryAddresses(entry, column.key)}
|
||||
suggestions={addressSuggestions}
|
||||
onSuggestionQueryChange={onAddressSuggestionQueryChange}
|
||||
allowMultiple={column.allowMultiple}
|
||||
compact
|
||||
disabled={locked}
|
||||
@@ -1237,7 +1763,7 @@ function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressC
|
||||
disabled={locked}
|
||||
onChange={(checked) => updateEntryMerge(index, column.mergeKey!, checked)} />
|
||||
|
||||
<span className="muted">i18n:govoplan-campaign.merge_with_global.ba230098 {column.label}</span>
|
||||
<span className="muted">{translateText("i18n:govoplan-campaign.merge_with_global.ba230098")} {translateText(column.label)}</span>
|
||||
</div>
|
||||
}
|
||||
</div>,
|
||||
|
||||
@@ -4,18 +4,15 @@ import { usePlatformModuleInstalled } from "@govoplan/core-webui";
|
||||
import type { ApiSettings } from "../../types";
|
||||
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 CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import FieldValueInput from "./components/FieldValueInput";
|
||||
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
|
||||
import { getDraftFields } from "./utils/fieldDefinitions";
|
||||
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
|
||||
import { importedRowsNeedAttachmentSource, materializeRecipientImport, type RecipientImportMode, type RecipientImportPreview } from "./utils/bulkImport";
|
||||
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
|
||||
import { materializeRecipientImportWithAttachmentDefaults, type RecipientImportMode, type RecipientImportPreview } from "./utils/bulkImport";
|
||||
import { buildTemplatePreviewContext } from "./utils/templatePlaceholders";
|
||||
import { RecipientImportDialog } from "./RecipientDataPage";
|
||||
import { addressesFromValue } from "@govoplan/core-webui";
|
||||
@@ -80,56 +77,31 @@ export default function RecipientDetailsPage({ settings, campaignId }: {settings
|
||||
|
||||
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode) {
|
||||
if (locked || !draft) return;
|
||||
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
|
||||
const currentAttachments = asRecord(draft.attachments);
|
||||
let nextBasePaths = normalizeAttachmentBasePaths(currentAttachments.base_paths, currentAttachments, true);
|
||||
let attachmentBasePath: AttachmentBasePath | null = null;
|
||||
|
||||
if (needsAttachmentSource) {
|
||||
if (nextBasePaths.length === 0) {
|
||||
nextBasePaths = [{ id: "base-path-campaign", name: "Campaign files", path: ".", allow_individual: true, unsent_warning: false }];
|
||||
}
|
||||
const selectedIndex = Math.max(0, nextBasePaths.findIndex((basePath) => basePath.allow_individual));
|
||||
nextBasePaths = nextBasePaths.map((basePath, index) => index === selectedIndex ? { ...basePath, allow_individual: true } : basePath);
|
||||
attachmentBasePath = nextBasePaths[selectedIndex] ?? null;
|
||||
}
|
||||
|
||||
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath });
|
||||
const nextDraft = needsAttachmentSource ?
|
||||
{
|
||||
...importedDraft,
|
||||
attachments: {
|
||||
...asRecord(importedDraft.attachments),
|
||||
base_paths: nextBasePaths,
|
||||
base_path: nextBasePaths[0]?.path || "."
|
||||
}
|
||||
} :
|
||||
importedDraft;
|
||||
setDraft(nextDraft);
|
||||
setDraft(materializeRecipientImportWithAttachmentDefaults(draft, preview, { mode }));
|
||||
markDirty();
|
||||
setImportOpen(false);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>i18n:govoplan-campaign.recipient_data.c2baaf10</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<CampaignDraftPageScaffold
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
title="i18n:govoplan-campaign.recipient_data.c2baaf10"
|
||||
loading={loading}
|
||||
version={version}
|
||||
versions={data.versions}
|
||||
saveState={saveState}
|
||||
onReload={reload}
|
||||
onSave={() => saveDraft("manual")}
|
||||
dirty={dirty}
|
||||
locked={locked}
|
||||
draft={draft}
|
||||
error={error}
|
||||
localError={localError}
|
||||
currentVersionId={data.campaign?.current_version_id}>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||
<>
|
||||
<Card title="i18n:govoplan-campaign.recipient_field_values_and_attachments.96e670ca" actions={<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>}>
|
||||
{inlineEntries.length === 0 && !source.type && <p className="muted">i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.e9f9a224</p>}
|
||||
{inlineEntries.length === 0 && Boolean(source.type) &&
|
||||
@@ -158,8 +130,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: {settings
|
||||
</div>
|
||||
}
|
||||
</Card>
|
||||
</>
|
||||
</LoadingFrame>
|
||||
</CampaignDraftPageScaffold>
|
||||
|
||||
{importOpen &&
|
||||
<RecipientImportDialog
|
||||
@@ -172,7 +143,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: {settings
|
||||
onImport={applyRecipientImport} />
|
||||
|
||||
}
|
||||
</div>);
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -95,8 +95,8 @@ const stateColors: Record<FlowState, string> = {
|
||||
active: "var(--blue)",
|
||||
locked: "var(--line-dark)",
|
||||
running: "var(--blue)",
|
||||
partial: "#9b86c7",
|
||||
stale: "#9b86c7",
|
||||
partial: "var(--review-flow-purple)",
|
||||
stale: "var(--review-flow-purple)",
|
||||
pending: "var(--muted)"
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Button, DismissibleAlert, LoadingFrame, PageTitle } from "@govoplan/core-webui";
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
|
||||
import LockedVersionNotice from "./LockedVersionNotice";
|
||||
import VersionLine from "./VersionLine";
|
||||
|
||||
type CampaignDraftPageScaffoldProps = {
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
title: string;
|
||||
loading: boolean;
|
||||
version: CampaignVersionDetail | null;
|
||||
versions: CampaignVersionListItem[];
|
||||
saveState: string;
|
||||
onReload: () => Promise<void>;
|
||||
onSave: () => void;
|
||||
dirty: boolean;
|
||||
locked: boolean;
|
||||
draft: Record<string, unknown> | null;
|
||||
error?: string | null;
|
||||
localError?: string | null;
|
||||
currentVersionId?: string | null;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function CampaignDraftPageScaffold({
|
||||
settings,
|
||||
campaignId,
|
||||
title,
|
||||
loading,
|
||||
version,
|
||||
versions,
|
||||
saveState,
|
||||
onReload,
|
||||
onSave,
|
||||
dirty,
|
||||
locked,
|
||||
draft,
|
||||
error,
|
||||
localError,
|
||||
currentVersionId,
|
||||
children
|
||||
}: CampaignDraftPageScaffoldProps) {
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>{title}</PageTitle>
|
||||
<VersionLine version={version} versions={versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={onReload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button variant="primary" onClick={onSave} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={currentVersionId} reload={onReload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||
<>{children}</>
|
||||
</LoadingFrame>
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -4,7 +4,6 @@ type XlsxWorkbookSheet = {
|
||||
data: unknown[][];
|
||||
};
|
||||
type XlsxWorkbookReader = (input: ArrayBuffer) => Promise<XlsxWorkbookSheet[]>;
|
||||
type RawXlsxWorkbookReader = (input: unknown) => Promise<XlsxWorkbookSheet[]>;
|
||||
|
||||
export type ImportFieldDefinition = {
|
||||
name: string;
|
||||
@@ -16,7 +15,11 @@ export type ImportFieldDefinition = {
|
||||
|
||||
export type ImportAttachmentBasePath = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
path?: string;
|
||||
source?: string;
|
||||
allow_individual?: boolean;
|
||||
unsent_warning?: boolean;
|
||||
};
|
||||
|
||||
export type RecipientImportMode = "append" | "replace";
|
||||
@@ -29,7 +32,7 @@ export type CsvParseOptions = {
|
||||
quoted: boolean;
|
||||
};
|
||||
|
||||
export type RecipientImportSourceType = "csv" | "xlsx" | "text";
|
||||
export type RecipientImportSourceType = "csv" | "xlsx" | "text" | "addresses";
|
||||
|
||||
export type RecipientColumnKind = "ignore" | "id" | "active" | "name" | "from" | "to" | "cc" | "bcc" | "reply_to" | "field" | "new_field" | "attachment_pattern";
|
||||
|
||||
@@ -99,6 +102,10 @@ export type RecipientImportProvenance = {
|
||||
imported_at: string;
|
||||
mode: RecipientImportMode;
|
||||
source_type: RecipientImportSourceType;
|
||||
source_id?: string | null;
|
||||
source_label?: string | null;
|
||||
source_revision?: string | null;
|
||||
source_provenance?: Record<string, unknown>;
|
||||
filename?: string | null;
|
||||
sheet_name?: string | null;
|
||||
encoding?: string | null;
|
||||
@@ -211,15 +218,6 @@ async function loadXlsxWorkbookReader(): Promise<XlsxWorkbookReader> {
|
||||
const module = await import("read-excel-file/browser");
|
||||
return module.default as XlsxWorkbookReader;
|
||||
}
|
||||
const globals = globalThis as typeof globalThis & {
|
||||
Buffer?: { from(input: ArrayBuffer): unknown };
|
||||
process?: { versions?: { node?: string } };
|
||||
};
|
||||
if (globals.process?.versions?.node && globals.Buffer?.from) {
|
||||
const dynamicImport = new Function("specifier", "return import(specifier)") as (specifier: string) => Promise<{ default: RawXlsxWorkbookReader }>;
|
||||
const module = await dynamicImport("read-excel-file/node");
|
||||
return (input: ArrayBuffer) => module.default(globals.Buffer!.from(input));
|
||||
}
|
||||
const module = await import("read-excel-file/universal");
|
||||
return module.default as XlsxWorkbookReader;
|
||||
}
|
||||
@@ -463,6 +461,37 @@ options: MaterializeImportOptions)
|
||||
};
|
||||
}
|
||||
|
||||
export function materializeRecipientImportWithAttachmentDefaults(
|
||||
draft: Record<string, unknown>,
|
||||
preview: RecipientImportPreview,
|
||||
options: MaterializeImportOptions)
|
||||
: Record<string, unknown> {
|
||||
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
|
||||
const currentAttachments = asRecord(draft.attachments);
|
||||
let nextBasePaths = normalizeImportAttachmentBasePaths(currentAttachments.base_paths, currentAttachments, true);
|
||||
let attachmentBasePath: ImportAttachmentBasePath | null = null;
|
||||
|
||||
if (needsAttachmentSource) {
|
||||
if (nextBasePaths.length === 0) {
|
||||
nextBasePaths = [{ id: "base-path-campaign", name: "Campaign files", path: ".", allow_individual: true, unsent_warning: false }];
|
||||
}
|
||||
const selectedIndex = Math.max(0, nextBasePaths.findIndex((basePath) => basePath.allow_individual));
|
||||
nextBasePaths = nextBasePaths.map((basePath, index) => index === selectedIndex ? { ...basePath, allow_individual: true } : basePath);
|
||||
attachmentBasePath = options.attachmentBasePath ?? nextBasePaths[selectedIndex] ?? null;
|
||||
}
|
||||
|
||||
const importedDraft = materializeRecipientImport(draft, preview, { ...options, attachmentBasePath });
|
||||
if (!needsAttachmentSource) return importedDraft;
|
||||
return {
|
||||
...importedDraft,
|
||||
attachments: {
|
||||
...asRecord(importedDraft.attachments),
|
||||
base_paths: nextBasePaths,
|
||||
base_path: nextBasePaths[0]?.path || "."
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createRecipientImportProvenance(input: {
|
||||
preview: RecipientImportPreview;
|
||||
mappings: RecipientColumnMapping[];
|
||||
@@ -513,6 +542,27 @@ export function importedRowsNeedAttachmentSource(preview: RecipientImportPreview
|
||||
return preview.rows.some((row) => row.issues.length === 0 && row.patterns.length > 0);
|
||||
}
|
||||
|
||||
function normalizeImportAttachmentBasePaths(value: unknown, attachments: ImportRecord, fallbackAllowIndividual = false): ImportAttachmentBasePath[] {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
return value.filter(isRecord).map((basePath, index) => ({
|
||||
id: getText(basePath, "id", `base-path-${index + 1}`),
|
||||
name: getText(basePath, "name", `Base path ${index + 1}`),
|
||||
source: getText(basePath, "source"),
|
||||
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
|
||||
allow_individual: getBool(basePath, "allow_individual"),
|
||||
unsent_warning: getBool(basePath, "unsent_warning")
|
||||
}));
|
||||
}
|
||||
|
||||
return [{
|
||||
id: "base-path-campaign",
|
||||
name: "Campaign files",
|
||||
path: getText(attachments, "base_path", "."),
|
||||
allow_individual: getBool(attachments, "allow_individual", fallbackAllowIndividual),
|
||||
unsent_warning: false
|
||||
}];
|
||||
}
|
||||
|
||||
function importedRowToEntry(row: ImportedRecipientRow, attachmentBasePath?: ImportAttachmentBasePath | null): Record<string, unknown> {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -815,6 +865,14 @@ function getText(record: ImportRecord, key: string, fallback = ""): string {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getBool(record: ImportRecord, key: string, fallback = false): boolean {
|
||||
const value = record[key];
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") return ["1", "true", "yes", "on"].includes(value.toLowerCase());
|
||||
if (typeof value === "number") return value !== 0;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function humanizeFieldName(value: string): string {
|
||||
return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ export { default } from "./module";
|
||||
export * from "./module";
|
||||
export * from "./api/campaigns";
|
||||
export * from "./features/campaigns/policyUi";
|
||||
export { default as AddressBookPage } from "./features/addressbook/AddressBookPage";
|
||||
export { default as CampaignListPage } from "./features/campaigns/CampaignListPage";
|
||||
export { default as CampaignWorkspace } from "./features/campaigns/CampaignWorkspace";
|
||||
export { default as OperatorQueuePage } from "./features/operator/OperatorQueuePage";
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getCampaign } from "./api/campaigns";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import "./styles/campaign-workspace.css";
|
||||
|
||||
const AddressBookPage = lazy(() => import("./features/addressbook/AddressBookPage"));
|
||||
const CampaignListPage = lazy(() => import("./features/campaigns/CampaignListPage"));
|
||||
const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace"));
|
||||
const OperatorQueuePage = lazy(() => import("./features/operator/OperatorQueuePage"));
|
||||
@@ -35,7 +34,6 @@ export const campaignModule: PlatformWebModule = {
|
||||
order: 30
|
||||
},
|
||||
{ to: "/reports", label: "i18n:govoplan-campaign.reports.88bc3fe3", iconName: "clipboard-pen-line", anyOf: ["campaigns:report:read"], order: 70 },
|
||||
{ to: "/address-book", label: "i18n:govoplan-campaign.address_book.f6327f59", iconName: "users", order: 80 },
|
||||
{ to: "/templates", label: "i18n:govoplan-campaign.templates.f25b700e", iconName: "layout-template", order: 90 }],
|
||||
|
||||
routes: [
|
||||
@@ -43,7 +41,6 @@ export const campaignModule: PlatformWebModule = {
|
||||
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
|
||||
{ path: "/operator", anyOf: operatorScopes, order: 30, render: ({ settings }) => createElement(OperatorQueuePage, { settings }) },
|
||||
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: () => createElement(ReportsPage) },
|
||||
{ path: "/address-book", order: 80, render: () => createElement(AddressBookPage) },
|
||||
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }]
|
||||
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
}
|
||||
.wizard-action-card {
|
||||
min-height: 176px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-soft);
|
||||
padding: 16px;
|
||||
@@ -37,7 +37,7 @@
|
||||
.data-table code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #5d5a55;
|
||||
color: var(--text-subtle);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
white-space: pre-wrap;
|
||||
color: var(--text);
|
||||
background: var(--panel-soft);
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
padding: 14px;
|
||||
font: inherit;
|
||||
@@ -64,7 +64,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 999px;
|
||||
background: var(--panel-soft);
|
||||
padding: 4px 10px;
|
||||
@@ -90,13 +90,13 @@
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: #4d4944;
|
||||
color: var(--text);
|
||||
}
|
||||
.danger-text { color: var(--danger-text); }
|
||||
.section-mini-heading {
|
||||
margin: 18px 0 8px;
|
||||
font-size: 12px;
|
||||
color: #6b6863;
|
||||
color: var(--text-label);
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -140,7 +140,7 @@
|
||||
.form-subsection {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: var(--border-line);
|
||||
padding-top: 14px;
|
||||
}
|
||||
.form-subsection:first-child {
|
||||
@@ -169,7 +169,7 @@
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.toggle-span-full {
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
padding: 10px 12px;
|
||||
@@ -240,7 +240,7 @@
|
||||
gap: 6px;
|
||||
min-height: 88px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-soft);
|
||||
color: inherit;
|
||||
@@ -272,7 +272,7 @@
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
@@ -314,10 +314,10 @@
|
||||
z-index: 30;
|
||||
min-height: 64px;
|
||||
padding: 0 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
border-bottom: var(--border-line);
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
box-shadow: 0 14px 18px -22px rgba(45, 43, 40, .75);
|
||||
box-shadow: var(--shadow-campaign-card);
|
||||
}
|
||||
.campaigns-page .card-body {
|
||||
padding: 0;
|
||||
@@ -449,7 +449,7 @@
|
||||
}
|
||||
|
||||
.message-preview {
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-subtle);
|
||||
padding: 1rem;
|
||||
@@ -484,10 +484,10 @@
|
||||
}
|
||||
.overview-config-card {
|
||||
min-height: 218px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(#ffffff, var(--panel-soft));
|
||||
box-shadow: 0 1px 2px rgba(38, 35, 30, .04);
|
||||
background: linear-gradient(var(--control-gradient-start), var(--panel-soft));
|
||||
box-shadow: var(--shadow-campaign-soft);
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
grid-template-rows: 22px 44px 1fr 35px;
|
||||
@@ -520,7 +520,7 @@
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
min-height: 31px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: var(--border-line);
|
||||
padding-top: 8px;
|
||||
}
|
||||
.overview-config-facts dt {
|
||||
@@ -577,20 +577,20 @@
|
||||
.field-chip-button:hover,
|
||||
.field-chip-button:focus-visible {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.09);
|
||||
box-shadow: var(--shadow-campaign-small);
|
||||
}
|
||||
.field-chip-button.used {
|
||||
background: var(--green-soft);
|
||||
border-color: #78b7a9;
|
||||
color: #2d685d;
|
||||
border-color: var(--success-border-soft);
|
||||
color: var(--success-text-strong);
|
||||
}
|
||||
.field-chip-button.undefined {
|
||||
background: #fff1d0;
|
||||
border-color: #d5a64a;
|
||||
color: #7c5412;
|
||||
background: var(--warning-muted-bg);
|
||||
border-color: var(--warning-border);
|
||||
color: var(--warning-deep);
|
||||
}
|
||||
.field-chip-namespace {
|
||||
border-right: 1px solid rgba(0,0,0,.18);
|
||||
border-right: var(--border-muted-line);
|
||||
color: var(--muted);
|
||||
margin-right: 2px;
|
||||
padding-right: 6px;
|
||||
@@ -614,9 +614,9 @@
|
||||
.template-preview-frame {
|
||||
width: 100%;
|
||||
min-height: 360px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.template-action-dialog {
|
||||
width: min(620px, 100%);
|
||||
@@ -749,7 +749,7 @@
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
min-height: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 999px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--muted);
|
||||
@@ -853,7 +853,7 @@
|
||||
.attachment-file-browser-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-soft);
|
||||
padding: 14px;
|
||||
@@ -908,7 +908,7 @@
|
||||
.chooser-display-input:focus,
|
||||
.field-with-action input.chooser-display-input:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(239, 107, 58, .16);
|
||||
box-shadow: var(--accent-ring-soft);
|
||||
}
|
||||
|
||||
.chooser-display-input:disabled,
|
||||
@@ -948,7 +948,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 999px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--muted);
|
||||
@@ -973,30 +973,6 @@
|
||||
.recipient-data-table td:nth-child(3) { min-width: 192px; }
|
||||
.recipient-index-cell { white-space: nowrap; }
|
||||
|
||||
.address-book-scope-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.address-book-scope-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-soft);
|
||||
padding: 14px;
|
||||
}
|
||||
.address-book-scope-card strong {
|
||||
display: block;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.address-book-scope-card p {
|
||||
margin: 5px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
|
||||
.locked-version-notice .alert-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1012,12 +988,12 @@
|
||||
}
|
||||
|
||||
.locked-version-feedback {
|
||||
color: var(--success, #157347);
|
||||
color: var(--success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.locked-version-error {
|
||||
color: var(--danger, #b42318);
|
||||
color: var(--danger-border-deep);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1045,7 +1021,7 @@
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 999px;
|
||||
color: var(--text, #243247);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
font-size: 0.72rem;
|
||||
@@ -1057,8 +1033,8 @@
|
||||
}
|
||||
|
||||
.version-arrow:hover {
|
||||
background: var(--surface-subtle, rgba(15, 23, 42, 0.08));
|
||||
color: var(--text, #111827);
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.version-arrow.disabled {
|
||||
@@ -1108,7 +1084,7 @@
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line-subtle);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
background: var(--panel-glass);
|
||||
color: var(--text-primary);
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
@@ -1154,7 +1130,7 @@
|
||||
max-width: 100%;
|
||||
border: 1px solid var(--line-subtle);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
background: var(--panel-glass);
|
||||
padding: 0.55rem 0.7rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -1188,7 +1164,7 @@
|
||||
margin: 0;
|
||||
}
|
||||
.warning-text {
|
||||
color: var(--warning, #8a5b00);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
/* Managed attachment chooser refinements. */
|
||||
@@ -1212,7 +1188,7 @@
|
||||
}
|
||||
/* Review & Send workflow page. */
|
||||
.review-send-page {
|
||||
--review-flow-purple: #9b86c7;
|
||||
--review-flow-purple: var(--review-flow-purple);
|
||||
}
|
||||
|
||||
.review-flow-navigation {
|
||||
@@ -1221,10 +1197,10 @@
|
||||
z-index: 35;
|
||||
margin: 0 0 24px;
|
||||
padding: 15px 10px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(247, 246, 244, .96);
|
||||
box-shadow: 0 8px 24px rgba(48, 49, 53, .10);
|
||||
background: var(--campaign-card-bg);
|
||||
box-shadow: 0 8px 24px var(--campaign-stage-shadow-color);
|
||||
backdrop-filter: blur(12px);
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -1258,7 +1234,7 @@
|
||||
|
||||
.review-flow-navigation-item:hover .review-flow-navigation-icon,
|
||||
.review-flow-navigation-item:focus-visible .review-flow-navigation-icon {
|
||||
background: color-mix(in srgb, var(--review-nav-color) 15%, #fff);
|
||||
background: color-mix(in srgb, var(--review-nav-color) 15%, var(--surface));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--review-nav-color) 12%, transparent);
|
||||
}
|
||||
|
||||
@@ -1274,7 +1250,7 @@
|
||||
border: 1px solid var(--review-nav-color);
|
||||
border-radius: 50%;
|
||||
color: color-mix(in srgb, var(--review-nav-color) 82%, var(--text));
|
||||
background: color-mix(in srgb, var(--review-nav-color) 8%, #fff);
|
||||
background: color-mix(in srgb, var(--review-nav-color) 8%, var(--surface));
|
||||
transition: background .16s ease, box-shadow .16s ease;
|
||||
}
|
||||
|
||||
@@ -1346,8 +1322,8 @@
|
||||
border: 1px solid var(--review-stage-color);
|
||||
border-radius: 50%;
|
||||
color: color-mix(in srgb, var(--review-stage-color) 82%, var(--text));
|
||||
background: color-mix(in srgb, var(--review-stage-color) 9%, #fff);
|
||||
box-shadow: 0 0 0 4px var(--bg), 0 4px 12px rgba(48, 49, 53, .10);
|
||||
background: color-mix(in srgb, var(--review-stage-color) 9%, var(--surface));
|
||||
box-shadow: 0 0 0 4px var(--bg), 0 4px 12px var(--campaign-stage-shadow-color);
|
||||
}
|
||||
|
||||
.review-flow-stage[data-state="running"] .review-flow-stage-node {
|
||||
@@ -1402,8 +1378,8 @@
|
||||
padding: 4px 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--review-badge-color) 70%, var(--line));
|
||||
border-radius: var(--radius-pill);
|
||||
color: color-mix(in srgb, var(--review-badge-color) 70%, #242424);
|
||||
background: color-mix(in srgb, var(--review-badge-color) 18%, #fff);
|
||||
color: color-mix(in srgb, var(--review-badge-color) 70%, var(--neutral-950));
|
||||
background: color-mix(in srgb, var(--review-badge-color) 18%, var(--surface));
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .03em;
|
||||
@@ -1447,7 +1423,7 @@
|
||||
padding: 28px;
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
background: rgba(247, 246, 244, .46);
|
||||
background: var(--campaign-card-bg-soft);
|
||||
}
|
||||
|
||||
.review-flow-lock-message::before {
|
||||
@@ -1455,12 +1431,12 @@
|
||||
inset: 50% auto auto 50%;
|
||||
width: min(430px, calc(100% - 44px));
|
||||
height: 110px;
|
||||
border: 1px solid rgba(189, 184, 176, .8);
|
||||
border: 1px solid var(--campaign-panel-border);
|
||||
border-radius: 10px;
|
||||
content: "";
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(255, 255, 255, .91);
|
||||
box-shadow: 0 12px 32px rgba(48, 49, 53, .14);
|
||||
background: var(--campaign-panel-bg);
|
||||
box-shadow: 0 12px 32px var(--campaign-panel-shadow-color);
|
||||
}
|
||||
|
||||
.review-flow-lock-message > * {
|
||||
@@ -1484,10 +1460,10 @@
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line-dark);
|
||||
border: var(--border-line-dark);
|
||||
border-radius: 50%;
|
||||
color: var(--muted);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.review-flow-fact-grid,
|
||||
@@ -1504,7 +1480,7 @@
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
@@ -1534,26 +1510,26 @@
|
||||
margin: 0 0 14px;
|
||||
padding: 10px 12px;
|
||||
border-left: 3px solid var(--blue);
|
||||
background: color-mix(in srgb, var(--blue) 10%, #fff);
|
||||
background: color-mix(in srgb, var(--blue) 10%, var(--surface));
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.review-flow-inline-note.is-complete {
|
||||
border-left-color: var(--green);
|
||||
background: color-mix(in srgb, var(--green) 12%, #fff);
|
||||
background: color-mix(in srgb, var(--green) 12%, var(--surface));
|
||||
}
|
||||
.review-flow-inline-note.is-warning {
|
||||
border-left-color: var(--amber);
|
||||
background: color-mix(in srgb, var(--amber) 16%, #fff);
|
||||
background: color-mix(in srgb, var(--amber) 16%, var(--surface));
|
||||
}
|
||||
.review-flow-inline-note.is-danger {
|
||||
border-left-color: var(--red);
|
||||
background: color-mix(in srgb, var(--red) 10%, #fff);
|
||||
background: color-mix(in srgb, var(--red) 10%, var(--surface));
|
||||
}
|
||||
.review-flow-inline-note.is-stale {
|
||||
border-left-color: var(--review-flow-purple);
|
||||
background: color-mix(in srgb, var(--review-flow-purple) 11%, #fff);
|
||||
background: color-mix(in srgb, var(--review-flow-purple) 11%, var(--surface));
|
||||
}
|
||||
|
||||
.review-flow-result-line {
|
||||
@@ -1596,7 +1572,7 @@
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-left: 3px solid var(--blue);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-soft);
|
||||
@@ -1665,8 +1641,8 @@
|
||||
}
|
||||
|
||||
@keyframes review-flow-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 4px var(--bg), 0 4px 12px rgba(48, 49, 53, .10); }
|
||||
50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--review-stage-color) 18%, transparent), 0 4px 12px rgba(48, 49, 53, .10); }
|
||||
0%, 100% { box-shadow: 0 0 0 4px var(--bg), 0 4px 12px var(--campaign-stage-shadow-color); }
|
||||
50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--review-stage-color) 18%, transparent), 0 4px 12px var(--campaign-stage-shadow-color); }
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
@@ -1775,7 +1751,7 @@
|
||||
|
||||
.attachment-zip-name-button[aria-invalid="true"] {
|
||||
border-color: var(--red);
|
||||
box-shadow: 0 0 0 2px rgba(224, 106, 95, 0.16);
|
||||
box-shadow: 0 0 0 2px var(--danger-focus-ring-soft);
|
||||
}
|
||||
|
||||
.attachment-zip-name-button:disabled {
|
||||
@@ -1797,7 +1773,7 @@
|
||||
|
||||
.template-expression-dialog-header {
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
@@ -1810,7 +1786,7 @@
|
||||
|
||||
.template-expression-dialog-footer {
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: var(--border-line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
@@ -1835,7 +1811,7 @@
|
||||
|
||||
.template-expression-value-field input[aria-invalid="true"] {
|
||||
border-color: var(--red);
|
||||
box-shadow: 0 0 0 2px rgba(224, 106, 95, 0.16), inset 0 1px 2px rgba(0, 0, 0, .04);
|
||||
box-shadow: var(--danger-focus-ring-soft), var(--shadow-control-inset);
|
||||
}
|
||||
|
||||
.attachment-zip-name-error {
|
||||
@@ -1872,7 +1848,7 @@
|
||||
border: 2px solid var(--line-strong, var(--line-subtle));
|
||||
border-radius: 14px;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
background: var(--panel-glass);
|
||||
}
|
||||
|
||||
.attachment-zip-group > header {
|
||||
@@ -1905,6 +1881,11 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.stale-address-import-warning {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.recipient-profiles-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -1943,9 +1924,9 @@
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-muted, #f6f7f9);
|
||||
background: var(--surface-muted);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -1969,7 +1950,7 @@
|
||||
}
|
||||
|
||||
.recipient-outcome-error {
|
||||
color: var(--danger, #b91c1c);
|
||||
color: var(--danger-border-deep);
|
||||
}
|
||||
|
||||
.attempt-history-section {
|
||||
@@ -1983,7 +1964,7 @@
|
||||
|
||||
.attempt-history-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -1996,7 +1977,7 @@
|
||||
.attempt-history-table th,
|
||||
.attempt-history-table td {
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
@@ -2022,9 +2003,9 @@
|
||||
padding: 14px;
|
||||
margin: 12px 0;
|
||||
overflow-wrap: anywhere;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-muted, #f6f7f9);
|
||||
background: var(--surface-muted);
|
||||
font-size: 14px;
|
||||
user-select: all;
|
||||
}
|
||||
@@ -2033,9 +2014,9 @@
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-muted, #f6f7f9);
|
||||
background: var(--surface-muted);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -2055,10 +2036,10 @@
|
||||
}
|
||||
.recipient-import-steps {
|
||||
padding: 13px 10px 9px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(247, 246, 244, .96);
|
||||
box-shadow: 0 8px 24px rgba(48, 49, 53, .10);
|
||||
background: var(--campaign-card-bg);
|
||||
box-shadow: 0 8px 24px var(--campaign-stage-shadow-color);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.recipient-import-step-track {
|
||||
@@ -2091,8 +2072,8 @@
|
||||
}
|
||||
.recipient-import-step-item:not(:disabled):hover .recipient-import-step-icon,
|
||||
.recipient-import-step-item:focus-visible .recipient-import-step-icon {
|
||||
background: color-mix(in srgb, var(--accent, #5d776c) 14%, #fff);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #5d776c) 12%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 14%, var(--surface));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
.recipient-import-step-item:focus-visible {
|
||||
outline: none;
|
||||
@@ -2102,17 +2083,17 @@
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid color-mix(in srgb, var(--accent, #5d776c) 70%, var(--line));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 70%, var(--line));
|
||||
border-radius: 50%;
|
||||
color: color-mix(in srgb, var(--accent, #5d776c) 82%, var(--text));
|
||||
background: color-mix(in srgb, var(--accent, #5d776c) 8%, #fff);
|
||||
color: color-mix(in srgb, var(--accent) 82%, var(--text));
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--surface));
|
||||
font-weight: 700;
|
||||
transition: background .16s ease, box-shadow .16s ease, color .16s ease;
|
||||
}
|
||||
.recipient-import-step-item.is-current .recipient-import-step-icon,
|
||||
.recipient-import-step-item.is-complete .recipient-import-step-icon {
|
||||
color: #fff;
|
||||
background: color-mix(in srgb, var(--accent, #5d776c) 86%, #333);
|
||||
color: var(--on-accent);
|
||||
background: color-mix(in srgb, var(--accent) 86%, var(--neutral-900));
|
||||
}
|
||||
.recipient-import-step-copy {
|
||||
display: grid;
|
||||
@@ -2144,7 +2125,7 @@
|
||||
height: 2px;
|
||||
margin: 21px 2px 0;
|
||||
flex: 0 0 auto;
|
||||
background: color-mix(in srgb, var(--accent, #5d776c) 55%, var(--line));
|
||||
background: color-mix(in srgb, var(--accent) 55%, var(--line));
|
||||
opacity: .82;
|
||||
}
|
||||
.recipient-import-step-panel {
|
||||
@@ -2172,7 +2153,89 @@
|
||||
min-height: 38px;
|
||||
}
|
||||
.recipient-import-summary {
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||
}
|
||||
.address-source-import-controls {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(220px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.address-source-import-controls input[type="search"] {
|
||||
min-width: 0;
|
||||
}
|
||||
.address-source-picker {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 290px;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
.address-source-option {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
padding: 10px 12px;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-control);
|
||||
}
|
||||
.address-source-option:hover,
|
||||
.address-source-option:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--accent) 44%, var(--line));
|
||||
background: var(--bar);
|
||||
outline: none;
|
||||
}
|
||||
.address-source-option.is-selected {
|
||||
border-color: color-mix(in srgb, var(--accent) 62%, var(--line));
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
box-shadow: inset 0 2px 5px rgba(var(--shadow-color-rgb), .12), var(--shadow-inset-highlight);
|
||||
}
|
||||
.address-source-option:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .62;
|
||||
}
|
||||
.address-source-option-main,
|
||||
.address-source-option-meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.address-source-option-main strong {
|
||||
color: var(--text-strong);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.address-source-option-main span,
|
||||
.address-source-option-meta span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.address-source-option-meta {
|
||||
justify-items: end;
|
||||
}
|
||||
.address-source-option-meta code {
|
||||
max-width: 180px;
|
||||
color: var(--text-subtle);
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.compact-empty {
|
||||
min-height: 78px;
|
||||
padding: 16px;
|
||||
}
|
||||
.recipient-import-preview-surface {
|
||||
overflow: auto;
|
||||
@@ -2184,7 +2247,7 @@
|
||||
}
|
||||
.recipient-import-preview-table th,
|
||||
.recipient-import-preview-table td {
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
padding: 9px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
@@ -2208,7 +2271,7 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
.recipient-import-raw-table tr.is-header-row td {
|
||||
background: color-mix(in srgb, var(--accent, #5d776c) 8%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.recipient-import-mapping-table select,
|
||||
@@ -2232,7 +2295,7 @@
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
@@ -2245,9 +2308,9 @@
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--warning-bg, #fff3cd) 62%, transparent);
|
||||
background: color-mix(in srgb, var(--warning-bg) 62%, transparent);
|
||||
}
|
||||
.recipient-import-unmatched-patterns ul {
|
||||
display: grid;
|
||||
@@ -2267,6 +2330,15 @@
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.address-source-import-controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.address-source-option {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.address-source-option-meta {
|
||||
justify-items: start;
|
||||
}
|
||||
.recipient-import-summary {
|
||||
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||
}
|
||||
@@ -2292,7 +2364,7 @@
|
||||
.admin-details-grid > div {
|
||||
min-width: 0;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
.admin-details-grid dt {
|
||||
color: var(--muted);
|
||||
@@ -2310,8 +2382,8 @@
|
||||
gap: 5px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: #e8eef5;
|
||||
color: #36566f;
|
||||
background: var(--info-muted-bg);
|
||||
color: var(--info-text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -2342,10 +2414,10 @@
|
||||
.admin-managed-notice {
|
||||
margin: 0 0 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #c8d6e3;
|
||||
border: 1px solid var(--info-muted-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #eef4f8;
|
||||
color: #294a61;
|
||||
background: var(--info-bg);
|
||||
color: var(--info-text-deep);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user