feat(postbox): enforce unified inbox separation
This commit is contained in:
@@ -51,8 +51,10 @@ import {
|
||||
retirePostboxTemplate,
|
||||
revisePostboxTemplate,
|
||||
updatePostboxProtectionPolicy,
|
||||
updatePostboxGroupingPolicy,
|
||||
type PostboxDirectoryItem,
|
||||
type PostboxExactCreatePayload,
|
||||
type PostboxGroupingPolicy,
|
||||
type PostboxOrganizationFunction,
|
||||
type PostboxOrganizationStructure,
|
||||
type PostboxOrganizationUnit,
|
||||
@@ -114,6 +116,11 @@ const protectionPolicyDefaults = (): PostboxProtectionPolicy => ({
|
||||
vacancy_escalation_content_access: "metadata_only"
|
||||
});
|
||||
|
||||
const groupingPolicyDefaults = (): PostboxGroupingPolicy => ({
|
||||
mode: "allow",
|
||||
reason: null
|
||||
});
|
||||
|
||||
const routingDefaults = (): PostboxRoutingPolicy => ({
|
||||
linked_copy: {
|
||||
enabled: false,
|
||||
@@ -157,6 +164,7 @@ const templateDefaults = (): TemplateDraft => ({
|
||||
encryption_profile: "server_envelope_v1",
|
||||
encryption_vault_id: "",
|
||||
protection_policy: protectionPolicyDefaults(),
|
||||
grouping_policy: groupingPolicyDefaults(),
|
||||
routing_policy: routingDefaults()
|
||||
});
|
||||
|
||||
@@ -170,7 +178,8 @@ const exactDefaults = (): ExactDraft => ({
|
||||
portal_visible: false,
|
||||
encryption_profile: "server_envelope_v1",
|
||||
encryption_vault_id: "",
|
||||
protection_policy: protectionPolicyDefaults()
|
||||
protection_policy: protectionPolicyDefaults(),
|
||||
grouping_policy: groupingPolicyDefaults()
|
||||
});
|
||||
|
||||
const protectionTransitionDefaults = (
|
||||
@@ -248,6 +257,9 @@ export default function PostboxAdminPanel({
|
||||
const [policyDialogOpen, setPolicyDialogOpen] = useState(false);
|
||||
const [policyDraft, setPolicyDraft] = useState<PostboxProtectionPolicy>(protectionPolicyDefaults);
|
||||
const [policyBaseline, setPolicyBaseline] = useState<PostboxProtectionPolicy>(protectionPolicyDefaults);
|
||||
const [groupingPolicyDialogOpen, setGroupingPolicyDialogOpen] = useState(false);
|
||||
const [groupingPolicyDraft, setGroupingPolicyDraft] = useState<PostboxGroupingPolicy>(groupingPolicyDefaults);
|
||||
const [groupingPolicyBaseline, setGroupingPolicyBaseline] = useState<PostboxGroupingPolicy>(groupingPolicyDefaults);
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
|
||||
const selectedTemplate = useMemo(
|
||||
@@ -283,9 +295,11 @@ export default function PostboxAdminPanel({
|
||||
const materializeDirty = materializeDialogOpen && draftKey(materializeDraft) !== draftKey(materializeBaseline);
|
||||
const protectionDirty = protectionDialogOpen && draftKey(protectionDraft) !== draftKey(protectionBaseline);
|
||||
const policyDirty = policyDialogOpen && draftKey(policyDraft) !== draftKey(policyBaseline);
|
||||
const groupingPolicyDirty = groupingPolicyDialogOpen
|
||||
&& draftKey(groupingPolicyDraft) !== draftKey(groupingPolicyBaseline);
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: templateDirty || exactDirty || materializeDirty || protectionDirty || policyDirty,
|
||||
dirty: templateDirty || exactDirty || materializeDirty || protectionDirty || policyDirty || groupingPolicyDirty,
|
||||
title: "Unsaved Postbox administration draft",
|
||||
message: "Save or discard the open Postbox administration draft before leaving this surface.",
|
||||
onSave: saveActiveDraft,
|
||||
@@ -375,6 +389,7 @@ export default function PostboxAdminPanel({
|
||||
encryption_profile: revision.encryption_profile,
|
||||
encryption_vault_id: revision.encryption_vault_id ?? "",
|
||||
protection_policy: revision.protection_policy ?? protectionPolicyDefaults(),
|
||||
grouping_policy: revision.grouping_policy ?? groupingPolicyDefaults(),
|
||||
routing_policy: revision.routing_policy ?? routingDefaults()
|
||||
};
|
||||
setTemplatePreview(null);
|
||||
@@ -581,6 +596,38 @@ export default function PostboxAdminPanel({
|
||||
}
|
||||
}
|
||||
|
||||
function openGroupingPolicy() {
|
||||
if (!selectedPostbox) return;
|
||||
const next = selectedPostbox.grouping_policy || groupingPolicyDefaults();
|
||||
setGroupingPolicyDraft(next);
|
||||
setGroupingPolicyBaseline(next);
|
||||
setGroupingPolicyDialogOpen(true);
|
||||
}
|
||||
|
||||
async function saveGroupingPolicy(): Promise<boolean> {
|
||||
if (!selectedPostbox) return false;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
await updatePostboxGroupingPolicy(
|
||||
settings,
|
||||
selectedPostbox,
|
||||
groupingPolicyDraft
|
||||
);
|
||||
setGroupingPolicyBaseline(groupingPolicyDraft);
|
||||
setGroupingPolicyDialogOpen(false);
|
||||
setSuccess("Unified-inbox separation policy updated.");
|
||||
await load();
|
||||
return true;
|
||||
} catch (actionError) {
|
||||
setError(errorMessage(actionError));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openMaterialize(template: PostboxTemplate) {
|
||||
const revision = currentRevision(template);
|
||||
const compatible = compatibleTargets(units, revision?.function_type_id);
|
||||
@@ -660,6 +707,7 @@ export default function PostboxAdminPanel({
|
||||
if (materializeDirty) return materialize();
|
||||
if (protectionDirty) return saveProtectionTransition();
|
||||
if (policyDirty) return saveProtectionPolicy();
|
||||
if (groupingPolicyDirty) return saveGroupingPolicy();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
@@ -684,6 +732,10 @@ export default function PostboxAdminPanel({
|
||||
setPolicyDraft(policyBaseline);
|
||||
setPolicyDialogOpen(false);
|
||||
}
|
||||
if (groupingPolicyDialogOpen) {
|
||||
setGroupingPolicyDraft(groupingPolicyBaseline);
|
||||
setGroupingPolicyDialogOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function closeTemplateDialog() {
|
||||
@@ -731,6 +783,15 @@ export default function PostboxAdminPanel({
|
||||
else close();
|
||||
}
|
||||
|
||||
function closeGroupingPolicyDialog() {
|
||||
const close = () => {
|
||||
setGroupingPolicyDraft(groupingPolicyBaseline);
|
||||
setGroupingPolicyDialogOpen(false);
|
||||
};
|
||||
if (groupingPolicyDirty) requestDiscard(close);
|
||||
else close();
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageLayout
|
||||
title="Postboxes"
|
||||
@@ -807,6 +868,7 @@ export default function PostboxAdminPanel({
|
||||
onSelect={setSelectedPostboxId}
|
||||
onChangeProtection={openProtectionTransition}
|
||||
onEditPolicy={openProtectionPolicy}
|
||||
onEditGroupingPolicy={openGroupingPolicy}
|
||||
onArchive={setArchiveTarget}
|
||||
busy={busy}
|
||||
/>
|
||||
@@ -872,6 +934,15 @@ export default function PostboxAdminPanel({
|
||||
onClose={closePolicyDialog}
|
||||
onSave={() => void saveProtectionPolicy()}
|
||||
/>
|
||||
<GroupingPolicyDialog
|
||||
open={groupingPolicyDialogOpen}
|
||||
postbox={selectedPostbox}
|
||||
draft={groupingPolicyDraft}
|
||||
busy={busy}
|
||||
onChange={setGroupingPolicyDraft}
|
||||
onClose={closeGroupingPolicyDialog}
|
||||
onSave={() => void saveGroupingPolicy()}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={Boolean(archiveTarget)}
|
||||
title="Archive Postbox"
|
||||
@@ -1051,6 +1122,7 @@ function PostboxWorkspace({
|
||||
onSelect,
|
||||
onChangeProtection,
|
||||
onEditPolicy,
|
||||
onEditGroupingPolicy,
|
||||
onArchive,
|
||||
busy
|
||||
}: {
|
||||
@@ -1060,6 +1132,7 @@ function PostboxWorkspace({
|
||||
onSelect: (id: string) => void;
|
||||
onChangeProtection: () => void;
|
||||
onEditPolicy: () => void;
|
||||
onEditGroupingPolicy: () => void;
|
||||
onArchive: (postbox: PostboxDirectoryItem) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
@@ -1104,6 +1177,13 @@ function PostboxWorkspace({
|
||||
>
|
||||
<Pencil size={16} /> Edit policy
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onEditGroupingPolicy}
|
||||
disabled={busy || selected.status !== "active"}
|
||||
disabledReason={postboxBusyReason(false, busy) ?? (selected.status !== "active" ? POSTBOX_INTERFACE_I18N.archivedPostbox : undefined)}
|
||||
>
|
||||
<Boxes size={16} /> Inbox separation
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onChangeProtection}
|
||||
disabled={busy || selected.status !== "active"}
|
||||
@@ -1134,6 +1214,7 @@ function PostboxWorkspace({
|
||||
<div><dt>Key epoch</dt><dd>{selected.key_epoch}</dd></div>
|
||||
<div><dt>New incumbent history</dt><dd>{selected.protection_policy.new_incumbent_history.replaceAll("_", " ")}</dd></div>
|
||||
<div><dt>External recipient assurance</dt><dd>{selected.protection_policy.external_recipient_assurance.replaceAll("_", " ")}</dd></div>
|
||||
<div><dt>Unified-inbox policy</dt><dd>{selected.grouping_policy.mode.replaceAll("_", " ")}</dd></div>
|
||||
</dl>
|
||||
{transitions.length ? (
|
||||
<div className="postbox-revision-history">
|
||||
@@ -1435,6 +1516,10 @@ function TemplateDialog({
|
||||
protection_policy
|
||||
})}
|
||||
/>
|
||||
<GroupingPolicyFields
|
||||
policy={draft.grouping_policy}
|
||||
onChange={(grouping_policy) => onChange({ ...draft, grouping_policy })}
|
||||
/>
|
||||
<div className="postbox-routing-section">
|
||||
<div className="postbox-routing-heading">
|
||||
<div>
|
||||
@@ -1825,6 +1910,10 @@ function ExactPostboxDialog({
|
||||
protection_policy
|
||||
})}
|
||||
/>
|
||||
<GroupingPolicyFields
|
||||
policy={draft.grouping_policy}
|
||||
onChange={(grouping_policy) => onChange({ ...draft, grouping_policy })}
|
||||
/>
|
||||
</FormGrid>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -2054,6 +2143,51 @@ function AuthorityField({
|
||||
);
|
||||
}
|
||||
|
||||
function GroupingPolicyFields({
|
||||
policy,
|
||||
onChange
|
||||
}: {
|
||||
policy: PostboxGroupingPolicy;
|
||||
onChange: (policy: PostboxGroupingPolicy) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="postbox-protection-section">
|
||||
<div className="postbox-routing-heading">
|
||||
<div>
|
||||
<strong>Unified-inbox separation</strong>
|
||||
<span>Keep source containers and institutional responsibilities visibly separated where required.</span>
|
||||
<DocumentationHelpLink reference={POSTBOX_FIELD_DOCUMENTATION} />
|
||||
</div>
|
||||
</div>
|
||||
<FormGrid>
|
||||
<FormField label="Grouping rule" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<select
|
||||
value={policy.mode}
|
||||
onChange={(event) => onChange({
|
||||
...policy,
|
||||
mode: event.target.value as PostboxGroupingPolicy["mode"]
|
||||
})}
|
||||
>
|
||||
<option value="allow">May be combined</option>
|
||||
<option value="same_classification">Only with the same classification</option>
|
||||
<option value="separate">Always keep this Postbox separate</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Explanation" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
value={policy.reason || ""}
|
||||
placeholder="Why this separation is required"
|
||||
onChange={(event) => onChange({
|
||||
...policy,
|
||||
reason: event.target.value || null
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuorumField({
|
||||
label,
|
||||
value,
|
||||
@@ -2130,6 +2264,47 @@ function ProtectionPolicyDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function GroupingPolicyDialog({
|
||||
open,
|
||||
postbox,
|
||||
draft,
|
||||
busy,
|
||||
onChange,
|
||||
onClose,
|
||||
onSave
|
||||
}: {
|
||||
open: boolean;
|
||||
postbox: PostboxDirectoryItem | null;
|
||||
draft: PostboxGroupingPolicy;
|
||||
busy: boolean;
|
||||
onChange: (policy: PostboxGroupingPolicy) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title={postbox ? `Unified-inbox policy for ${postbox.name}` : "Unified-inbox policy"}
|
||||
className="postbox-dialog"
|
||||
onClose={onClose}
|
||||
closeDisabled={busy}
|
||||
footer={
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={onClose} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>Cancel</Button>
|
||||
<Button variant="primary" onClick={onSave} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>
|
||||
<Save size={16} /> Save separation policy
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<GroupingPolicyFields policy={draft} onChange={onChange} />
|
||||
<p className="postbox-form-note">
|
||||
The rule is evaluated whenever a personal grouping or aggregate message projection is used. Existing preferences are retained, but a newly enforced rule prevents an unsafe combined projection and explains its configured source.
|
||||
</p>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ProtectionTransitionDialog({
|
||||
open,
|
||||
postbox,
|
||||
@@ -2435,6 +2610,7 @@ function revisionPayload(draft: TemplateDraft): PostboxTemplateRevisionPayload {
|
||||
? draft.encryption_vault_id?.trim() || null
|
||||
: null,
|
||||
protection_policy: draft.protection_policy,
|
||||
grouping_policy: draft.grouping_policy,
|
||||
routing_policy: draft.routing_policy
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,17 +29,22 @@ export default function PostboxInboxWidget({
|
||||
const maxItems = numberSetting(configuration.maxItems, 5, 1, 12);
|
||||
const load = useCallback(async () => {
|
||||
const postboxes = await listPostboxes(settings);
|
||||
if (!postboxes.length) {
|
||||
return { messages: [], total: 0 };
|
||||
const eligible = postboxes.filter(
|
||||
(postbox) => postbox.grouping_policy.mode === "allow"
|
||||
);
|
||||
const separatedCount = postboxes.length - eligible.length;
|
||||
if (!eligible.length) {
|
||||
return { messages: [], total: 0, separatedCount };
|
||||
}
|
||||
return listPostboxMessages(
|
||||
const response = await listPostboxMessages(
|
||||
settings,
|
||||
postboxes.map((postbox) => postbox.id),
|
||||
eligible.map((postbox) => postbox.id),
|
||||
maxItems,
|
||||
0,
|
||||
"",
|
||||
"unread"
|
||||
);
|
||||
return { ...response, separatedCount };
|
||||
}, [maxItems, settings]);
|
||||
const { data, loading, error } = useDashboardWidgetData(load, refreshKey);
|
||||
|
||||
@@ -50,6 +55,11 @@ export default function PostboxInboxWidget({
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
{data?.separatedCount ? (
|
||||
<DismissibleAlert tone="info" dismissible={false}>
|
||||
{data.separatedCount} governed Postbox source{data.separatedCount === 1 ? " is" : "s are"} shown only in separated inbox views.
|
||||
</DismissibleAlert>
|
||||
) : null}
|
||||
<DashboardWidgetList
|
||||
emptyText="No unread Postbox messages."
|
||||
items={(data?.messages ?? []).map((message) => ({
|
||||
|
||||
@@ -119,6 +119,9 @@ export default function PostboxPage({
|
||||
const requestedPostboxId = useRef(
|
||||
new URLSearchParams(location.search).get("postbox") ?? ""
|
||||
);
|
||||
const requestedGroupingId = useRef(
|
||||
new URLSearchParams(location.search).get("grouping") ?? ""
|
||||
);
|
||||
const requestedMessageLoaded = useRef(false);
|
||||
const requestedComposeLoaded = useRef(false);
|
||||
const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]);
|
||||
@@ -176,6 +179,14 @@ export default function PostboxPage({
|
||||
}
|
||||
return postboxes.map((postbox) => postbox.id);
|
||||
}, [postboxes, selectedGrouping, selectedPostboxId]);
|
||||
const scopeSeparationConflict = useMemo(
|
||||
() => selectedPostboxId ? null : groupingConflict(postboxes, scopePostboxIds),
|
||||
[postboxes, scopePostboxIds, selectedPostboxId]
|
||||
);
|
||||
const groupingDraftConflict = useMemo(
|
||||
() => groupingConflict(postboxes, groupingDraft.postbox_ids),
|
||||
[groupingDraft.postbox_ids, postboxes]
|
||||
);
|
||||
const scopeKey = scopePostboxIds.join("|");
|
||||
const composeDisabledReason = postboxBusyReason(false, busy)
|
||||
?? (!canSend ? POSTBOX_INTERFACE_I18N.noSendReason : undefined)
|
||||
@@ -218,6 +229,9 @@ export default function PostboxPage({
|
||||
setPostboxes(nextPostboxes);
|
||||
setGroupings(nextGroupings);
|
||||
setSelectedScope((current) => {
|
||||
if (nextGroupings.some((grouping) => grouping.id === requestedGroupingId.current)) {
|
||||
return requestedGroupingId.current;
|
||||
}
|
||||
if (current === "all" || nextGroupings.some((grouping) => grouping.id === current)) {
|
||||
return current;
|
||||
}
|
||||
@@ -238,7 +252,7 @@ export default function PostboxPage({
|
||||
}, [settings]);
|
||||
|
||||
const loadMessages = useCallback(async () => {
|
||||
if (!scopePostboxIds.length) {
|
||||
if (!scopePostboxIds.length || scopeSeparationConflict) {
|
||||
setMessages([]);
|
||||
setSelectedMessageId("");
|
||||
setSelectedMessage(null);
|
||||
@@ -274,7 +288,7 @@ export default function PostboxPage({
|
||||
} finally {
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
}, [messageQuery, messageState, page, pageSize, scopeKey, settings]);
|
||||
}, [messageQuery, messageState, page, pageSize, scopeKey, scopeSeparationConflict, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDirectory();
|
||||
@@ -440,12 +454,22 @@ export default function PostboxPage({
|
||||
}
|
||||
|
||||
function selectScope(scopeId: string) {
|
||||
requestedGroupingId.current = scopeId === "all" ? "" : scopeId;
|
||||
setSelectedScope(scopeId);
|
||||
setSelectedPostboxId("");
|
||||
setPage(1);
|
||||
setSelectedMessageId("");
|
||||
setSelectedMessage(null);
|
||||
setUnavailableSelection("");
|
||||
const parameters = new URLSearchParams(location.search);
|
||||
if (scopeId === "all") parameters.delete("grouping");
|
||||
else parameters.set("grouping", scopeId);
|
||||
parameters.delete("postbox");
|
||||
const search = parameters.toString();
|
||||
navigate(
|
||||
{ pathname: location.pathname, search: search ? `?${search}` : "" },
|
||||
{ replace: true, state: location.state }
|
||||
);
|
||||
}
|
||||
|
||||
function openNewGrouping() {
|
||||
@@ -469,6 +493,10 @@ export default function PostboxPage({
|
||||
|
||||
async function saveGrouping(): Promise<boolean> {
|
||||
if (!groupingDraft.name.trim()) return false;
|
||||
if (groupingDraftConflict) {
|
||||
setError(groupingDraftConflict);
|
||||
return false;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const payload = {
|
||||
@@ -482,8 +510,7 @@ export default function PostboxPage({
|
||||
? await updatePostboxGrouping(settings, existing, payload)
|
||||
: await createPostboxGrouping(settings, payload);
|
||||
await loadDirectory();
|
||||
setSelectedScope(saved.id);
|
||||
setSelectedPostboxId("");
|
||||
selectScope(saved.id);
|
||||
setGroupingDialogOpen(false);
|
||||
setGroupingBaseline(groupingDraft);
|
||||
return true;
|
||||
@@ -503,8 +530,7 @@ export default function PostboxPage({
|
||||
const existing = groupings.find((item) => item.id === deleteGroupingTarget.id);
|
||||
if (!existing) throw new Error("The grouping is no longer available.");
|
||||
await deletePostboxGrouping(settings, existing);
|
||||
setSelectedScope("all");
|
||||
setSelectedPostboxId("");
|
||||
selectScope("all");
|
||||
setGroupingDialogOpen(false);
|
||||
setDeleteGroupingTarget(null);
|
||||
await loadDirectory();
|
||||
@@ -693,6 +719,19 @@ export default function PostboxPage({
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{scopeSeparationConflict ? (
|
||||
<DismissibleAlert tone="warning" dismissible={false}>
|
||||
<strong>Combined view unavailable.</strong>{" "}
|
||||
{scopeSeparationConflict} Select one source Postbox or edit the unified view.
|
||||
</DismissibleAlert>
|
||||
) : selectedGrouping?.constraints.length ? (
|
||||
<DismissibleAlert tone="info" dismissible={false}>
|
||||
This projection is governed by {selectedGrouping.constraints.length} source-separation rule{selectedGrouping.constraints.length === 1 ? "" : "s"}.
|
||||
{selectedGrouping.constraints.find((item) => item.reason)?.reason
|
||||
? ` ${selectedGrouping.constraints.find((item) => item.reason)?.reason}`
|
||||
: ""}
|
||||
</DismissibleAlert>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="postbox-directory-list">
|
||||
@@ -972,8 +1011,11 @@ export default function PostboxPage({
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void saveGrouping()}
|
||||
disabled={busy || !groupingDraft.name.trim()}
|
||||
disabledReason={postboxBusyReason(false, busy) ?? (!groupingDraft.name.trim() ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
|
||||
disabled={busy || !groupingDraft.name.trim() || Boolean(groupingDraftConflict)}
|
||||
disabledReason={postboxBusyReason(false, busy)
|
||||
?? (!groupingDraft.name.trim() ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)
|
||||
?? groupingDraftConflict
|
||||
?? undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
@@ -1013,6 +1055,13 @@ export default function PostboxPage({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={groupingDraft.postbox_ids.includes(postbox.id)}
|
||||
disabled={
|
||||
!groupingDraft.postbox_ids.includes(postbox.id)
|
||||
&& Boolean(groupingConflict(
|
||||
postboxes,
|
||||
[...groupingDraft.postbox_ids, postbox.id]
|
||||
))
|
||||
}
|
||||
onChange={(event) =>
|
||||
setGroupingDraft((current) => ({
|
||||
...current,
|
||||
@@ -1025,10 +1074,21 @@ export default function PostboxPage({
|
||||
<span>
|
||||
<strong>{postbox.name}</strong>
|
||||
<small>{postbox.organization_unit_name} · {postbox.function_name}</small>
|
||||
{postbox.grouping_policy.mode !== "allow" ? (
|
||||
<small>
|
||||
{postbox.grouping_policy.reason
|
||||
|| postbox.grouping_policy.mode.replaceAll("_", " ")}
|
||||
</small>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
{groupingDraftConflict ? (
|
||||
<DismissibleAlert tone="warning" dismissible={false}>
|
||||
{groupingDraftConflict}
|
||||
</DismissibleAlert>
|
||||
) : null}
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
@@ -1304,6 +1364,34 @@ function attachmentResolutionExplanation(reasonCode: string): string {
|
||||
return explanations[reasonCode] || "The referenced payload cannot currently be opened.";
|
||||
}
|
||||
|
||||
function groupingConflict(
|
||||
postboxes: PostboxDirectoryItem[],
|
||||
postboxIds: string[]
|
||||
): string | null {
|
||||
const selected = postboxIds
|
||||
.map((postboxId) => postboxes.find((postbox) => postbox.id === postboxId))
|
||||
.filter((postbox): postbox is PostboxDirectoryItem => Boolean(postbox));
|
||||
if (selected.length <= 1) return null;
|
||||
const separate = selected.find(
|
||||
(postbox) => postbox.grouping_policy.mode === "separate"
|
||||
);
|
||||
if (separate) {
|
||||
return separate.grouping_policy.reason
|
||||
|| `${separate.name} must remain a separate inbox.`;
|
||||
}
|
||||
const classificationRule = selected.find(
|
||||
(postbox) => postbox.grouping_policy.mode === "same_classification"
|
||||
);
|
||||
if (
|
||||
classificationRule
|
||||
&& new Set(selected.map((postbox) => postbox.classification)).size > 1
|
||||
) {
|
||||
return classificationRule.grouping_policy.reason
|
||||
|| "These Postboxes cannot be combined across classifications.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sourceName(
|
||||
postboxes: PostboxDirectoryItem[],
|
||||
postboxId: string
|
||||
|
||||
@@ -39,16 +39,22 @@ export default function PostboxQuickAccess({
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const load = useCallback(async () => {
|
||||
const postboxes = await listPostboxes(settings);
|
||||
if (!postboxes.length) return { postboxes, messages: [], total: 0 };
|
||||
const eligible = postboxes.filter(
|
||||
(postbox) => postbox.grouping_policy.mode === "allow"
|
||||
);
|
||||
const separatedCount = postboxes.length - eligible.length;
|
||||
if (!eligible.length) {
|
||||
return { postboxes, messages: [], total: 0, separatedCount };
|
||||
}
|
||||
const response = await listPostboxMessages(
|
||||
settings,
|
||||
postboxes.map((postbox) => postbox.id),
|
||||
eligible.map((postbox) => postbox.id),
|
||||
MESSAGE_LIMIT,
|
||||
0,
|
||||
"",
|
||||
"unread"
|
||||
);
|
||||
return { postboxes, ...response };
|
||||
return { postboxes, separatedCount, ...response };
|
||||
}, [settings]);
|
||||
const { data, loading, error } = useDashboardWidgetData(load, 0);
|
||||
const messages = data?.messages ?? [];
|
||||
@@ -93,6 +99,11 @@ export default function PostboxQuickAccess({
|
||||
</p>
|
||||
) : null}
|
||||
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
{data?.separatedCount ? (
|
||||
<DismissibleAlert tone="info" dismissible={false}>
|
||||
{data.separatedCount} Postbox source{data.separatedCount === 1 ? " is" : "s are"} available only in a separated inbox view.
|
||||
</DismissibleAlert>
|
||||
) : null}
|
||||
|
||||
{messages.length ? (
|
||||
<SelectionList variant="navigation" label="i18n:govoplan-postbox.quick_unread_messages">
|
||||
|
||||
Reference in New Issue
Block a user