Add governed contact channel facts
This commit is contained in:
@@ -84,6 +84,53 @@ export type Contact = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type AddressDistributionChannel = "email" | "postal" | "internal_mail" | "portal";
|
||||
export type AddressChannelDecision =
|
||||
| "allowed"
|
||||
| "opted_in"
|
||||
| "preferred"
|
||||
| "opted_out"
|
||||
| "suppressed"
|
||||
| "invalid"
|
||||
| "returned"
|
||||
| "temporarily_unavailable";
|
||||
|
||||
export type ContactChannelRule = {
|
||||
id: string;
|
||||
tenant_id?: string | null;
|
||||
contact_id: string;
|
||||
channel: AddressDistributionChannel;
|
||||
purpose?: string | null;
|
||||
contact_point_id?: string | null;
|
||||
decision: AddressChannelDecision;
|
||||
legal_basis?: string | null;
|
||||
evidence_ref?: string | null;
|
||||
reason?: string | null;
|
||||
preference_rank?: number | null;
|
||||
locale?: string | null;
|
||||
effective_from?: string | null;
|
||||
effective_until?: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
created_by_account_id?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ContactChannelRulePayload = {
|
||||
channel: AddressDistributionChannel;
|
||||
purpose?: string | null;
|
||||
contact_point_id?: string | null;
|
||||
decision: AddressChannelDecision;
|
||||
legal_basis?: string | null;
|
||||
evidence_ref?: string | null;
|
||||
reason?: string | null;
|
||||
preference_rank?: number | null;
|
||||
locale?: string | null;
|
||||
effective_from?: string | null;
|
||||
effective_until?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AddressBookCreatePayload = {
|
||||
scope_type: AddressBookScope;
|
||||
group_id?: string | null;
|
||||
@@ -341,6 +388,10 @@ type AddressSyncConflictListResponse = {
|
||||
conflicts: AddressSyncConflict[];
|
||||
};
|
||||
|
||||
type ContactChannelRuleListResponse = {
|
||||
rules: ContactChannelRule[];
|
||||
};
|
||||
|
||||
function queryString(params: Record<string, string | number | null | undefined>): string {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
@@ -626,6 +677,31 @@ export function restoreContact(settings: ApiSettings, contactId: string): Promis
|
||||
return apiFetch<Contact>(settings, `/api/v1/addresses/contacts/${contactId}/restore`, { method: "POST" });
|
||||
}
|
||||
|
||||
export async function listContactChannelRules(settings: ApiSettings, contactId: string): Promise<ContactChannelRule[]> {
|
||||
const response = await apiFetch<ContactChannelRuleListResponse>(
|
||||
settings,
|
||||
`/api/v1/addresses/contacts/${contactId}/channel-rules`
|
||||
);
|
||||
return response.rules;
|
||||
}
|
||||
|
||||
export function createContactChannelRule(
|
||||
settings: ApiSettings,
|
||||
contactId: string,
|
||||
payload: ContactChannelRulePayload
|
||||
): Promise<ContactChannelRule> {
|
||||
return apiFetch<ContactChannelRule>(settings, `/api/v1/addresses/contacts/${contactId}/channel-rules`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function endContactChannelRule(settings: ApiSettings, ruleId: string): Promise<ContactChannelRule> {
|
||||
return apiFetch<ContactChannelRule>(settings, `/api/v1/addresses/contact-channel-rules/${ruleId}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
}
|
||||
|
||||
export function importAddressBookVcards(settings: ApiSettings, addressBookId: string, content: string): Promise<VCardImportResult> {
|
||||
return apiFetch<VCardImportResult>(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/import`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Download, Edit3, Link2, Plus, RefreshCw, RotateCcw, Save, Search, Trash2, Upload, UserPlus, X } from "lucide-react";
|
||||
import { Download, Edit3, Link2, Plus, RefreshCw, RotateCcw, Save, Search, ShieldCheck, Trash2, Upload, UserPlus, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
|
||||
import {
|
||||
ApiError,
|
||||
@@ -30,12 +30,14 @@ import {
|
||||
createAddressListEntry,
|
||||
createCardDavSyncSource,
|
||||
createContact,
|
||||
createContactChannelRule,
|
||||
deleteAddressBook,
|
||||
deleteAddressList,
|
||||
deleteAddressListEntry,
|
||||
deleteContact,
|
||||
deleteAddressSyncSource,
|
||||
discoverCardDavAddressBooks,
|
||||
endContactChannelRule,
|
||||
exportAddressBookVcards,
|
||||
exportContactVcard,
|
||||
importAddressBookVcards,
|
||||
@@ -49,6 +51,7 @@ import {
|
||||
listAddressSyncTombstones,
|
||||
listContacts,
|
||||
listContactsPage,
|
||||
listContactChannelRules,
|
||||
previewAddressSyncSource,
|
||||
restoreAddressBook,
|
||||
restoreAddressList,
|
||||
@@ -62,7 +65,9 @@ import {
|
||||
type AddressCardDavAddressBook,
|
||||
type AddressBook,
|
||||
type AddressBookScope,
|
||||
type AddressChannelDecision,
|
||||
type AddressCredentialEnvelope,
|
||||
type AddressDistributionChannel,
|
||||
type AddressList,
|
||||
type AddressListEntry,
|
||||
type AddressSyncConflict,
|
||||
@@ -70,7 +75,9 @@ import {
|
||||
type AddressSyncPlan,
|
||||
type AddressSyncSource,
|
||||
type AddressSyncTombstone,
|
||||
type Contact
|
||||
type Contact,
|
||||
type ContactChannelRule,
|
||||
type ContactChannelRulePayload
|
||||
} from "../../api/addresses";
|
||||
|
||||
type Props = {
|
||||
@@ -139,6 +146,20 @@ type ContactFormState = {
|
||||
note: string;
|
||||
};
|
||||
|
||||
type ChannelRuleFormState = {
|
||||
channel: AddressDistributionChannel;
|
||||
purpose: string;
|
||||
contact_point_id: string;
|
||||
decision: AddressChannelDecision;
|
||||
legal_basis: string;
|
||||
evidence_ref: string;
|
||||
reason: string;
|
||||
preference_rank: string;
|
||||
locale: string;
|
||||
effective_from: string;
|
||||
effective_until: string;
|
||||
};
|
||||
|
||||
type ListFormState = {
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -212,6 +233,20 @@ const EMPTY_CARDDAV_FORM: CardDavFormState = {
|
||||
sync_direction: "read_only"
|
||||
};
|
||||
|
||||
const EMPTY_CHANNEL_RULE_FORM: ChannelRuleFormState = {
|
||||
channel: "email",
|
||||
purpose: "",
|
||||
contact_point_id: "",
|
||||
decision: "allowed",
|
||||
legal_basis: "",
|
||||
evidence_ref: "",
|
||||
reason: "",
|
||||
preference_rank: "",
|
||||
locale: "",
|
||||
effective_from: "",
|
||||
effective_until: ""
|
||||
};
|
||||
|
||||
const ADDRESS_CONTACT_DRAG_TYPE = "application/x-govoplan-address-contact-id";
|
||||
const CONFLICT_PAYLOAD_FIELDS = ["display_name", "given_name", "family_name", "organization", "role_title", "emails", "phones", "postal_addresses", "tags", "note"] as const;
|
||||
|
||||
@@ -613,6 +648,13 @@ function downloadText(filename: string, content: string, type = "text/vcard;char
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function channelRuleState(rule: ContactChannelRule): "active" | "scheduled" | "ended" {
|
||||
const now = Date.now();
|
||||
if (rule.effective_until && new Date(rule.effective_until).getTime() <= now) return "ended";
|
||||
if (rule.effective_from && new Date(rule.effective_from).getTime() > now) return "scheduled";
|
||||
return "active";
|
||||
}
|
||||
|
||||
function disabledReason(...conditions: Array<[boolean, string]>): string {
|
||||
return conditions.find(([applies]) => applies)?.[1] ?? "";
|
||||
}
|
||||
@@ -642,6 +684,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
const [listForm, setListForm] = useState<ListFormState>(EMPTY_LIST_FORM);
|
||||
const [contactDialog, setContactDialog] = useState<ContactDialogState | null>(null);
|
||||
const [contactForm, setContactForm] = useState<ContactFormState>(EMPTY_CONTACT_FORM);
|
||||
const [governanceContact, setGovernanceContact] = useState<Contact | null>(null);
|
||||
const [channelRules, setChannelRules] = useState<ContactChannelRule[]>([]);
|
||||
const [channelRuleForm, setChannelRuleForm] = useState<ChannelRuleFormState>(EMPTY_CHANNEL_RULE_FORM);
|
||||
const [memberDialogOpen, setMemberDialogOpen] = useState(false);
|
||||
const [memberCandidates, setMemberCandidates] = useState<Contact[]>([]);
|
||||
const [memberQuery, setMemberQuery] = useState("");
|
||||
@@ -670,6 +715,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
const canDeleteLists = hasScope(auth, "addresses:address_list:delete");
|
||||
const canWriteContacts = hasScope(auth, "addresses:contact:write");
|
||||
const canDeleteContacts = hasScope(auth, "addresses:contact:delete");
|
||||
const canReadGovernance = hasScope(auth, "addresses:governance:read");
|
||||
const canWriteGovernance = hasScope(auth, "addresses:governance:write");
|
||||
const canReadSync = hasScope(auth, "addresses:sync:read");
|
||||
const canWriteSync = hasScope(auth, "addresses:sync:write");
|
||||
|
||||
@@ -744,6 +791,20 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
});
|
||||
}, [memberCandidates, memberQuery, selectedListContactIds]);
|
||||
const selectedContact = visibleContacts.find((contact) => contact.id === selectedContactId) ?? null;
|
||||
const governancePointOptions = useMemo(() => {
|
||||
if (!governanceContact) return [];
|
||||
if (channelRuleForm.channel === "email") {
|
||||
return governanceContact.emails
|
||||
.filter((item): item is typeof item & { id: string } => Boolean(item.id))
|
||||
.map((item) => ({ id: item.id, label: `${item.label || "email"}: ${item.email}` }));
|
||||
}
|
||||
if (channelRuleForm.channel === "postal") {
|
||||
return governanceContact.postal_addresses
|
||||
.filter((item): item is typeof item & { id: string } => Boolean(item.id))
|
||||
.map((item) => ({ id: item.id, label: `${item.label || "address"}: ${formatPostalAddress(item) || "Unformatted address"}` }));
|
||||
}
|
||||
return [];
|
||||
}, [channelRuleForm.channel, governanceContact]);
|
||||
const selectedContactListEntries = selectedContact && selectedList ? contactListEntries(addressListEntries, selectedContact.id) : [];
|
||||
const activeTreeId = selectedList ? `list:${selectedList.id}` : selectedBook ? `book:${selectedBook.id}` : "";
|
||||
const loadingReason = loading ? "Address books are loading." : "";
|
||||
@@ -827,6 +888,19 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
[saving, savingReason],
|
||||
[!contactFormHasIdentity(contactForm), "Enter a display name, name component, or email address before saving."]
|
||||
);
|
||||
const channelRuleSaveReason = disabledReason(
|
||||
[saving, savingReason],
|
||||
[!governanceContact, "Select a contact before recording a governance fact."],
|
||||
[!canWriteGovernance, "You need permission to manage communication governance."],
|
||||
[
|
||||
Boolean(
|
||||
channelRuleForm.effective_from
|
||||
&& channelRuleForm.effective_until
|
||||
&& new Date(channelRuleForm.effective_until) <= new Date(channelRuleForm.effective_from)
|
||||
),
|
||||
"The end of the effective period must be after its start."
|
||||
]
|
||||
);
|
||||
const dialogCancelReason = disabledReason([saving, savingReason]);
|
||||
const vcardImportReason = disabledReason(
|
||||
[saving, savingReason],
|
||||
@@ -1121,6 +1195,71 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
setContactDialog({ mode: "edit", contact });
|
||||
}
|
||||
|
||||
async function openGovernanceDialog(contact: Contact) {
|
||||
setGovernanceContact(contact);
|
||||
setChannelRuleForm(EMPTY_CHANNEL_RULE_FORM);
|
||||
setChannelRules([]);
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
setChannelRules(await listContactChannelRules(settings, contact.id));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
setGovernanceContact(null);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitChannelRule(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!governanceContact || channelRuleSaveReason) return;
|
||||
const toIso = (value: string) => value ? new Date(value).toISOString() : null;
|
||||
const payload: ContactChannelRulePayload = {
|
||||
channel: channelRuleForm.channel,
|
||||
purpose: channelRuleForm.purpose.trim() || null,
|
||||
contact_point_id: channelRuleForm.contact_point_id || null,
|
||||
decision: channelRuleForm.decision,
|
||||
legal_basis: channelRuleForm.legal_basis.trim() || null,
|
||||
evidence_ref: channelRuleForm.evidence_ref.trim() || null,
|
||||
reason: channelRuleForm.reason.trim() || null,
|
||||
preference_rank: channelRuleForm.preference_rank ? Number(channelRuleForm.preference_rank) : null,
|
||||
locale: channelRuleForm.locale.trim() || null,
|
||||
effective_from: toIso(channelRuleForm.effective_from),
|
||||
effective_until: toIso(channelRuleForm.effective_until),
|
||||
metadata: {}
|
||||
};
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
await createContactChannelRule(settings, governanceContact.id, payload);
|
||||
setChannelRules(await listContactChannelRules(settings, governanceContact.id));
|
||||
setChannelRuleForm((current) => ({ ...EMPTY_CHANNEL_RULE_FORM, channel: current.channel }));
|
||||
setNotice("Communication-governance fact recorded.");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function endChannelRule(rule: ContactChannelRule) {
|
||||
if (!governanceContact || saving || !canWriteGovernance) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
await endContactChannelRule(settings, rule.id);
|
||||
setChannelRules(await listContactChannelRules(settings, governanceContact.id));
|
||||
setNotice("Communication-governance fact ended; its history was retained.");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateEmailRow(rowId: string, patch: Partial<Omit<ContactEmailRow, "rowId">>) {
|
||||
setContactForm((current) => ({
|
||||
...current,
|
||||
@@ -1901,6 +2040,17 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
{selectedContact.deleted_at ?
|
||||
<Button type="button" title="Restore contact" aria-label="Restore contact" disabledReason={restoreContactReason()} onClick={() => void restoreDeletedContact(selectedContact)}><RotateCcw size={15} /> Restore</Button> :
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
title="Communication governance"
|
||||
aria-label="Communication governance"
|
||||
disabledReason={disabledReason(
|
||||
[!canReadGovernance, "You need permission to view communication governance."],
|
||||
[saving, savingReason]
|
||||
)}
|
||||
onClick={() => void openGovernanceDialog(selectedContact)}>
|
||||
<ShieldCheck size={15} /> Governance
|
||||
</Button>
|
||||
<Button type="button" title="Export contact vCard" aria-label="Export contact vCard" disabledReason={exportContactReason()} onClick={() => void exportOneContact(selectedContact)}><Download size={15} /> vCard</Button>
|
||||
<Button type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</Button>
|
||||
<Button type="button" variant="danger" title="Delete contact" aria-label="Delete contact" disabledReason={deleteContactReason()} onClick={() => setConfirmState({ kind: "contact", contact: selectedContact })}><Trash2 size={15} /> Delete</Button>
|
||||
@@ -2235,6 +2385,128 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(governanceContact)}
|
||||
title={governanceContact ? `Communication governance · ${governanceContact.display_name}` : "Communication governance"}
|
||||
onClose={() => setGovernanceContact(null)}
|
||||
closeDisabled={saving}
|
||||
className="address-governance-dialog"
|
||||
footerClassName="button-row compact-actions"
|
||||
footer={<Button type="button" onClick={() => setGovernanceContact(null)} disabledReason={dialogCancelReason}>Close</Button>}>
|
||||
<div className="address-governance-layout">
|
||||
<section className="address-form-section">
|
||||
<div className="address-form-section-heading">
|
||||
<div>
|
||||
<strong>Recorded facts</strong>
|
||||
<p className="muted small-text">History is retained. End a fact when it no longer applies.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="address-governance-list">
|
||||
{channelRules.length === 0 ?
|
||||
<p className="muted">No communication-governance facts recorded.</p> :
|
||||
channelRules.map((rule) => {
|
||||
const state = channelRuleState(rule);
|
||||
return (
|
||||
<article className="address-governance-rule" key={rule.id}>
|
||||
<div className="address-governance-rule-main">
|
||||
<span className="address-governance-rule-heading">
|
||||
<strong>{rule.channel.replace("_", " ")} · {rule.decision.replaceAll("_", " ")}</strong>
|
||||
<StatusBadge status={state} />
|
||||
</span>
|
||||
<span>{rule.purpose || "All purposes"}{rule.reason ? ` · ${rule.reason}` : ""}</span>
|
||||
<small>
|
||||
{rule.legal_basis ? `Basis: ${rule.legal_basis}` : "No legal basis recorded"}
|
||||
{rule.evidence_ref ? ` · Evidence: ${rule.evidence_ref}` : ""}
|
||||
{rule.effective_from ? ` · From ${formatDateTime(rule.effective_from, ADDRESS_DATE_TIME_OPTIONS)}` : ""}
|
||||
{rule.effective_until ? ` · Until ${formatDateTime(rule.effective_until, ADDRESS_DATE_TIME_OPTIONS)}` : ""}
|
||||
</small>
|
||||
</div>
|
||||
{state !== "ended" &&
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
disabledReason={disabledReason(
|
||||
[!canWriteGovernance, "You need permission to manage communication governance."],
|
||||
[saving, savingReason]
|
||||
)}
|
||||
onClick={() => void endChannelRule(rule)}>
|
||||
<X size={15} /> End
|
||||
</Button>
|
||||
}
|
||||
</article>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{canWriteGovernance &&
|
||||
<form className="address-dialog-form address-form-section" onSubmit={(event) => void submitChannelRule(event)}>
|
||||
<div className="address-form-section-heading">
|
||||
<div>
|
||||
<strong>Record a fact</strong>
|
||||
<p className="muted small-text">Addresses records evidence; Policy may apply stricter delivery rules.</p>
|
||||
</div>
|
||||
<Button type="submit" variant="primary" disabledReason={channelRuleSaveReason}><Plus size={15} /> Add</Button>
|
||||
</div>
|
||||
<div className="form-grid two">
|
||||
<FormField label="Channel">
|
||||
<select
|
||||
value={channelRuleForm.channel}
|
||||
onChange={(event) => setChannelRuleForm((current) => ({
|
||||
...current,
|
||||
channel: event.target.value as AddressDistributionChannel,
|
||||
contact_point_id: ""
|
||||
}))}>
|
||||
<option value="email">Email</option>
|
||||
<option value="postal">Postal mail</option>
|
||||
<option value="internal_mail">Internal mail</option>
|
||||
<option value="portal">Portal</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Contact point">
|
||||
<select
|
||||
value={channelRuleForm.contact_point_id}
|
||||
disabled={governancePointOptions.length === 0}
|
||||
onChange={(event) => setChannelRuleForm((current) => ({ ...current, contact_point_id: event.target.value }))}>
|
||||
<option value="">All {channelRuleForm.channel.replace("_", " ")} contact points</option>
|
||||
{governancePointOptions.map((item) => <option value={item.id} key={item.id}>{item.label}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="form-grid two">
|
||||
<FormField label="Decision">
|
||||
<select value={channelRuleForm.decision} onChange={(event) => setChannelRuleForm((current) => ({ ...current, decision: event.target.value as AddressChannelDecision }))}>
|
||||
<option value="allowed">Allowed</option>
|
||||
<option value="opted_in">Opted in</option>
|
||||
<option value="preferred">Preferred</option>
|
||||
<option value="opted_out">Opted out</option>
|
||||
<option value="suppressed">Suppressed</option>
|
||||
<option value="invalid">Invalid</option>
|
||||
<option value="returned">Returned</option>
|
||||
<option value="temporarily_unavailable">Temporarily unavailable</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Communication purpose">
|
||||
<input value={channelRuleForm.purpose} placeholder="All purposes" onChange={(event) => setChannelRuleForm((current) => ({ ...current, purpose: event.target.value }))} />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="form-grid three">
|
||||
<FormField label="Legal basis"><input value={channelRuleForm.legal_basis} onChange={(event) => setChannelRuleForm((current) => ({ ...current, legal_basis: event.target.value }))} /></FormField>
|
||||
<FormField label="Evidence reference"><input value={channelRuleForm.evidence_ref} onChange={(event) => setChannelRuleForm((current) => ({ ...current, evidence_ref: event.target.value }))} /></FormField>
|
||||
<FormField label="Locale"><input value={channelRuleForm.locale} placeholder="de-DE" onChange={(event) => setChannelRuleForm((current) => ({ ...current, locale: event.target.value }))} /></FormField>
|
||||
</div>
|
||||
<div className="form-grid three">
|
||||
<FormField label="Effective from"><input type="datetime-local" value={channelRuleForm.effective_from} onChange={(event) => setChannelRuleForm((current) => ({ ...current, effective_from: event.target.value }))} /></FormField>
|
||||
<FormField label="Effective until"><input type="datetime-local" value={channelRuleForm.effective_until} onChange={(event) => setChannelRuleForm((current) => ({ ...current, effective_until: event.target.value }))} /></FormField>
|
||||
<FormField label="Preference rank"><input type="number" min="0" max="10000" value={channelRuleForm.preference_rank} onChange={(event) => setChannelRuleForm((current) => ({ ...current, preference_rank: event.target.value }))} /></FormField>
|
||||
</div>
|
||||
<FormField label="Reason"><textarea rows={2} value={channelRuleForm.reason} onChange={(event) => setChannelRuleForm((current) => ({ ...current, reason: event.target.value }))} /></FormField>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={memberDialogOpen}
|
||||
title={selectedList ? `Add contacts to ${selectedList.name}` : "Add contacts to list"}
|
||||
|
||||
@@ -539,6 +539,51 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.address-governance-dialog .dialog-panel {
|
||||
width: min(980px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.address-governance-layout,
|
||||
.address-governance-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.address-governance-list {
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.address-governance-rule {
|
||||
align-items: center;
|
||||
border-bottom: var(--border-line);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.address-governance-rule:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.address-governance-rule-main,
|
||||
.address-governance-rule-heading {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.address-governance-rule-main {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.address-governance-rule-heading {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.address-form-row-email,
|
||||
.address-form-row-phone {
|
||||
grid-template-columns: 92px minmax(88px, 0.3fr) minmax(220px, 1fr) 34px;
|
||||
|
||||
Reference in New Issue
Block a user