feat: initialize governed postbox module
This commit is contained in:
@@ -0,0 +1,800 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Archive,
|
||||
Building2,
|
||||
CheckCheck,
|
||||
Inbox,
|
||||
Layers3,
|
||||
MailOpen,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
UserRoundCheck,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
DataGridPaginationBar,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
IconButton,
|
||||
SegmentedControl,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
StatusBadge,
|
||||
ToggleSwitch,
|
||||
hasScope,
|
||||
type ApiSettings,
|
||||
type AuthInfo
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
createPostboxGrouping,
|
||||
deletePostboxGrouping,
|
||||
getPostboxMessage,
|
||||
listPostboxGroupings,
|
||||
listPostboxMessages,
|
||||
listPostboxes,
|
||||
markPostboxMessage,
|
||||
updatePostboxGrouping,
|
||||
type PostboxDirectoryItem,
|
||||
type PostboxGrouping,
|
||||
type PostboxMessage
|
||||
} from "../../api/postbox";
|
||||
|
||||
|
||||
type GroupingDraft = {
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
postbox_ids: string[];
|
||||
};
|
||||
|
||||
type MessageStateFilter = "all" | "unread" | "read" | "acknowledged";
|
||||
|
||||
const emptyGrouping = (): GroupingDraft => ({
|
||||
id: "",
|
||||
name: "",
|
||||
is_default: false,
|
||||
postbox_ids: []
|
||||
});
|
||||
|
||||
export default function PostboxPage({
|
||||
settings,
|
||||
auth
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
}) {
|
||||
const requestedMessageId = useRef(
|
||||
new URLSearchParams(window.location.search).get("message") ?? ""
|
||||
);
|
||||
const requestedMessageLoaded = useRef(false);
|
||||
const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]);
|
||||
const [groupings, setGroupings] = useState<PostboxGrouping[]>([]);
|
||||
const [selectedScope, setSelectedScope] = useState("all");
|
||||
const [selectedPostboxId, setSelectedPostboxId] = useState("");
|
||||
const [messages, setMessages] = useState<PostboxMessage[]>([]);
|
||||
const [selectedMessageId, setSelectedMessageId] = useState("");
|
||||
const [selectedMessage, setSelectedMessage] = useState<PostboxMessage | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [messageState, setMessageState] = useState<MessageStateFilter>("all");
|
||||
const [searchDraft, setSearchDraft] = useState("");
|
||||
const [messageQuery, setMessageQuery] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [loadingDirectory, setLoadingDirectory] = useState(true);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [groupingDialogOpen, setGroupingDialogOpen] = useState(false);
|
||||
const [groupingDraft, setGroupingDraft] = useState<GroupingDraft>(emptyGrouping);
|
||||
|
||||
const canAcknowledge = hasScope(auth, "postbox:message:acknowledge");
|
||||
const selectedPostbox = useMemo(
|
||||
() => postboxes.find((postbox) => postbox.id === selectedPostboxId) ?? null,
|
||||
[postboxes, selectedPostboxId]
|
||||
);
|
||||
const selectedGrouping = useMemo(
|
||||
() => groupings.find((grouping) => grouping.id === selectedScope) ?? null,
|
||||
[groupings, selectedScope]
|
||||
);
|
||||
const scopePostboxIds = useMemo(() => {
|
||||
if (selectedPostboxId) return [selectedPostboxId];
|
||||
if (selectedGrouping) {
|
||||
const visible = new Set(postboxes.map((postbox) => postbox.id));
|
||||
return selectedGrouping.postbox_ids.filter((postboxId) => visible.has(postboxId));
|
||||
}
|
||||
return postboxes.map((postbox) => postbox.id);
|
||||
}, [postboxes, selectedGrouping, selectedPostboxId]);
|
||||
const scopeKey = scopePostboxIds.join("|");
|
||||
|
||||
const loadDirectory = useCallback(async () => {
|
||||
setLoadingDirectory(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextPostboxes, nextGroupings] = await Promise.all([
|
||||
listPostboxes(settings),
|
||||
listPostboxGroupings(settings)
|
||||
]);
|
||||
setPostboxes(nextPostboxes);
|
||||
setGroupings(nextGroupings);
|
||||
setSelectedScope((current) => {
|
||||
if (current === "all" || nextGroupings.some((grouping) => grouping.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return nextGroupings.find((grouping) => grouping.is_default)?.id ?? "all";
|
||||
});
|
||||
setSelectedPostboxId((current) =>
|
||||
current && nextPostboxes.some((postbox) => postbox.id === current)
|
||||
? current
|
||||
: ""
|
||||
);
|
||||
} catch (loadError) {
|
||||
setError(errorMessage(loadError));
|
||||
} finally {
|
||||
setLoadingDirectory(false);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const loadMessages = useCallback(async () => {
|
||||
if (!scopePostboxIds.length) {
|
||||
setMessages([]);
|
||||
setSelectedMessageId("");
|
||||
setSelectedMessage(null);
|
||||
setTotal(0);
|
||||
return;
|
||||
}
|
||||
setLoadingMessages(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await listPostboxMessages(
|
||||
settings,
|
||||
scopePostboxIds,
|
||||
pageSize,
|
||||
(page - 1) * pageSize,
|
||||
messageQuery,
|
||||
messageState
|
||||
);
|
||||
setMessages(response.messages);
|
||||
setTotal(response.total);
|
||||
setSelectedMessageId((current) =>
|
||||
current &&
|
||||
(
|
||||
response.messages.some((message) => message.id === current) ||
|
||||
current === requestedMessageId.current
|
||||
)
|
||||
? current
|
||||
: ""
|
||||
);
|
||||
if (!response.messages.length) setSelectedMessage(null);
|
||||
} catch (loadError) {
|
||||
setError(errorMessage(loadError));
|
||||
} finally {
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
}, [messageQuery, messageState, page, pageSize, scopeKey, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDirectory();
|
||||
}, [loadDirectory]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadMessages();
|
||||
}, [loadMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
const messageId = requestedMessageId.current;
|
||||
if (
|
||||
requestedMessageLoaded.current ||
|
||||
!messageId ||
|
||||
loadingDirectory ||
|
||||
!postboxes.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
requestedMessageLoaded.current = true;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const message = await getPostboxMessage(settings, messageId);
|
||||
if (cancelled) return;
|
||||
if (!postboxes.some((postbox) => postbox.id === message.postbox_id)) {
|
||||
setError("The linked message is not available in your current Postbox assignments.");
|
||||
return;
|
||||
}
|
||||
setSelectedScope("all");
|
||||
setSelectedPostboxId(message.postbox_id);
|
||||
setSelectedMessageId(message.id);
|
||||
setSelectedMessage(message);
|
||||
} catch (loadError) {
|
||||
if (!cancelled) setError(errorMessage(loadError));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadingDirectory, postboxes, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedMessageId) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
let message = await getPostboxMessage(settings, selectedMessageId);
|
||||
if (!message.read_at) {
|
||||
message = await markPostboxMessage(settings, selectedMessageId, "read");
|
||||
}
|
||||
if (cancelled) return;
|
||||
setSelectedMessage(message);
|
||||
setMessages((items) =>
|
||||
items.map((item) => (item.id === message.id ? message : item))
|
||||
);
|
||||
} catch (loadError) {
|
||||
if (!cancelled) setError(errorMessage(loadError));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedMessageId, settings]);
|
||||
|
||||
async function acknowledgeSelected() {
|
||||
if (!selectedMessage || !canAcknowledge) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const next = await markPostboxMessage(
|
||||
settings,
|
||||
selectedMessage.id,
|
||||
"acknowledged"
|
||||
);
|
||||
setSelectedMessage(next);
|
||||
setMessages((items) =>
|
||||
items.map((item) => (item.id === next.id ? next : item))
|
||||
);
|
||||
} catch (actionError) {
|
||||
setError(errorMessage(actionError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function selectPostbox(postboxId: string) {
|
||||
setSelectedPostboxId(postboxId);
|
||||
setPage(1);
|
||||
setSelectedMessageId("");
|
||||
setSelectedMessage(null);
|
||||
}
|
||||
|
||||
function selectScope(scopeId: string) {
|
||||
setSelectedScope(scopeId);
|
||||
setSelectedPostboxId("");
|
||||
setPage(1);
|
||||
setSelectedMessageId("");
|
||||
setSelectedMessage(null);
|
||||
}
|
||||
|
||||
function openNewGrouping() {
|
||||
setGroupingDraft(emptyGrouping());
|
||||
setGroupingDialogOpen(true);
|
||||
}
|
||||
|
||||
function openGrouping(grouping: PostboxGrouping) {
|
||||
setGroupingDraft({
|
||||
id: grouping.id,
|
||||
name: grouping.name,
|
||||
is_default: grouping.is_default,
|
||||
postbox_ids: [...grouping.postbox_ids]
|
||||
});
|
||||
setGroupingDialogOpen(true);
|
||||
}
|
||||
|
||||
async function saveGrouping() {
|
||||
if (!groupingDraft.name.trim()) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const payload = {
|
||||
name: groupingDraft.name.trim(),
|
||||
is_default: groupingDraft.is_default,
|
||||
postbox_ids: groupingDraft.postbox_ids
|
||||
};
|
||||
try {
|
||||
const saved = groupingDraft.id
|
||||
? await updatePostboxGrouping(settings, groupingDraft.id, payload)
|
||||
: await createPostboxGrouping(settings, payload);
|
||||
await loadDirectory();
|
||||
setSelectedScope(saved.id);
|
||||
setSelectedPostboxId("");
|
||||
setGroupingDialogOpen(false);
|
||||
} catch (actionError) {
|
||||
setError(errorMessage(actionError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeGrouping() {
|
||||
if (!groupingDraft.id) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await deletePostboxGrouping(settings, groupingDraft.id);
|
||||
setSelectedScope("all");
|
||||
setSelectedPostboxId("");
|
||||
setGroupingDialogOpen(false);
|
||||
await loadDirectory();
|
||||
} catch (actionError) {
|
||||
setError(errorMessage(actionError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="workspace-data-page module-entry-page postbox-page">
|
||||
<div className="postbox-shell">
|
||||
<aside className="postbox-directory" data-view-surface="postbox.inbox.directory">
|
||||
<div className="postbox-bar">
|
||||
<div className="postbox-bar-title">
|
||||
<Inbox size={17} aria-hidden="true" />
|
||||
<strong>Postbox</strong>
|
||||
</div>
|
||||
<div className="postbox-icon-actions">
|
||||
<IconButton
|
||||
label="New unified view"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={openNewGrouping}
|
||||
/>
|
||||
<IconButton
|
||||
label="Refresh"
|
||||
icon={<RefreshCw size={16} />}
|
||||
onClick={() => void loadDirectory()}
|
||||
disabled={loadingDirectory || busy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="postbox-scope-control">
|
||||
<label htmlFor="postbox-scope">Inbox view</label>
|
||||
<div className="postbox-scope-row">
|
||||
<select
|
||||
id="postbox-scope"
|
||||
value={selectedScope}
|
||||
onChange={(event) => selectScope(event.target.value)}
|
||||
>
|
||||
<option value="all">All postboxes</option>
|
||||
{groupings.map((grouping) => (
|
||||
<option key={grouping.id} value={grouping.id}>
|
||||
{grouping.name}{grouping.is_default ? " (default)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedGrouping ? (
|
||||
<IconButton
|
||||
label="Edit unified view"
|
||||
icon={<Pencil size={15} />}
|
||||
onClick={() => openGrouping(selectedGrouping)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="postbox-directory-list">
|
||||
{loadingDirectory ? <p className="postbox-note">Loading postboxes</p> : null}
|
||||
{!loadingDirectory && !postboxes.length ? (
|
||||
<div className="postbox-empty compact">
|
||||
<Archive size={20} />
|
||||
<strong>No assigned postboxes</strong>
|
||||
<p>Postboxes appear when your account has a current matching function assignment.</p>
|
||||
</div>
|
||||
) : null}
|
||||
{postboxes.length ? (
|
||||
<SelectionList label="Assigned postboxes">
|
||||
{postboxes.map((postbox) => (
|
||||
<SelectionListItem
|
||||
key={postbox.id}
|
||||
selected={selectedPostboxId === postbox.id}
|
||||
className="postbox-directory-item"
|
||||
onClick={() => selectPostbox(postbox.id)}
|
||||
>
|
||||
<span className="postbox-item-title">
|
||||
<strong>{postbox.name}</strong>
|
||||
{postbox.vacant ? (
|
||||
<StatusBadge status="warning" label="Vacant" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="postbox-item-context">
|
||||
{postbox.organization_unit_name || "No organization"} ·{" "}
|
||||
{postbox.function_name || "No function"}
|
||||
</span>
|
||||
<span className="postbox-item-address">{postbox.address}</span>
|
||||
</SelectionListItem>
|
||||
))}
|
||||
</SelectionList>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="postbox-message-list" data-view-surface="postbox.inbox.messages">
|
||||
<div className="postbox-bar">
|
||||
<div className="postbox-bar-title">
|
||||
{selectedPostbox ? (
|
||||
<>
|
||||
<Building2 size={17} aria-hidden="true" />
|
||||
<strong>{selectedPostbox.name}</strong>
|
||||
</>
|
||||
) : selectedGrouping ? (
|
||||
<>
|
||||
<Layers3 size={17} aria-hidden="true" />
|
||||
<strong>{selectedGrouping.name}</strong>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Layers3 size={17} aria-hidden="true" />
|
||||
<strong>All postboxes</strong>
|
||||
</>
|
||||
)}
|
||||
<span className="postbox-total">{total}</span>
|
||||
</div>
|
||||
<IconButton
|
||||
label="Refresh messages"
|
||||
icon={<RefreshCw size={16} />}
|
||||
onClick={() => void loadMessages()}
|
||||
disabled={loadingMessages || busy}
|
||||
/>
|
||||
</div>
|
||||
<div className="postbox-message-filters">
|
||||
<div className="postbox-search-row">
|
||||
<input
|
||||
type="search"
|
||||
value={searchDraft}
|
||||
placeholder="Search messages"
|
||||
aria-label="Search Postbox messages"
|
||||
onChange={(event) => setSearchDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
setMessageQuery(searchDraft.trim());
|
||||
setPage(1);
|
||||
setSelectedMessageId("");
|
||||
setSelectedMessage(null);
|
||||
}}
|
||||
/>
|
||||
{searchDraft || messageQuery ? (
|
||||
<IconButton
|
||||
label="Clear message search"
|
||||
icon={<X size={15} />}
|
||||
onClick={() => {
|
||||
setSearchDraft("");
|
||||
setMessageQuery("");
|
||||
setPage(1);
|
||||
setSelectedMessageId("");
|
||||
setSelectedMessage(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<IconButton
|
||||
label="Search messages"
|
||||
icon={<Search size={15} />}
|
||||
onClick={() => {
|
||||
setMessageQuery(searchDraft.trim());
|
||||
setPage(1);
|
||||
setSelectedMessageId("");
|
||||
setSelectedMessage(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<SegmentedControl<MessageStateFilter>
|
||||
ariaLabel="Message state"
|
||||
width="fill"
|
||||
options={[
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "unread", label: "Unread" },
|
||||
{ id: "read", label: "Read" },
|
||||
{ id: "acknowledged", label: "Acknowledged" }
|
||||
]}
|
||||
value={messageState}
|
||||
onChange={(next) => {
|
||||
setMessageState(next);
|
||||
setPage(1);
|
||||
setSelectedMessageId("");
|
||||
setSelectedMessage(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<DismissibleAlert tone="danger" compact resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
) : null}
|
||||
<div className="postbox-messages">
|
||||
{loadingMessages ? <p className="postbox-note">Loading messages</p> : null}
|
||||
{!loadingMessages && !messages.length ? (
|
||||
<div className="postbox-empty">
|
||||
<MailOpen size={24} />
|
||||
<strong>No messages</strong>
|
||||
<p>This view has no delivered Postbox messages.</p>
|
||||
</div>
|
||||
) : null}
|
||||
{messages.length ? (
|
||||
<SelectionList label="Postbox messages">
|
||||
{messages.map((message) => (
|
||||
<SelectionListItem
|
||||
key={message.id}
|
||||
selected={selectedMessageId === message.id}
|
||||
className={`postbox-message-item ${message.read_at ? "is-read" : "is-unread"}`}
|
||||
onClick={() => setSelectedMessageId(message.id)}
|
||||
>
|
||||
<span className="postbox-message-heading">
|
||||
<strong>{message.subject}</strong>
|
||||
<time>{formatDate(message.delivered_at)}</time>
|
||||
</span>
|
||||
<span className="postbox-message-preview">
|
||||
{message.sender_label || message.producer_module || "Platform"}
|
||||
</span>
|
||||
<span className="postbox-message-meta">
|
||||
<span>{sourceName(postboxes, message.postbox_id)}</span>
|
||||
{message.attachments.length ? (
|
||||
<span><Paperclip size={13} /> {message.attachments.length}</span>
|
||||
) : null}
|
||||
{message.acknowledged_at ? (
|
||||
<span><CheckCheck size={13} /> Acknowledged</span>
|
||||
) : null}
|
||||
</span>
|
||||
</SelectionListItem>
|
||||
))}
|
||||
</SelectionList>
|
||||
) : null}
|
||||
</div>
|
||||
<DataGridPaginationBar
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalRows={total}
|
||||
pageSizeOptions={[25, 50, 100, 200]}
|
||||
disabled={loadingMessages}
|
||||
ariaLabel="Postbox message pagination"
|
||||
onPageChange={(next) => {
|
||||
setPage(next);
|
||||
setSelectedMessageId("");
|
||||
setSelectedMessage(null);
|
||||
}}
|
||||
onPageSizeChange={(next) => {
|
||||
setPageSize(next);
|
||||
setPage(1);
|
||||
setSelectedMessageId("");
|
||||
setSelectedMessage(null);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="postbox-detail">
|
||||
<div className="postbox-bar">
|
||||
<div className="postbox-bar-title">
|
||||
<MailOpen size={17} aria-hidden="true" />
|
||||
<strong>{selectedMessage?.subject || "Message"}</strong>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => void acknowledgeSelected()}
|
||||
disabled={!selectedMessage || Boolean(selectedMessage.acknowledged_at) || busy}
|
||||
disabledReason={!canAcknowledge ? "You cannot acknowledge Postbox messages." : undefined}
|
||||
>
|
||||
<CheckCheck size={16} /> Acknowledge
|
||||
</Button>
|
||||
</div>
|
||||
{selectedMessage ? (
|
||||
<MessageDetail
|
||||
message={selectedMessage}
|
||||
postbox={postboxes.find((item) => item.id === selectedMessage.postbox_id)}
|
||||
/>
|
||||
) : (
|
||||
<div className="postbox-empty">
|
||||
<Inbox size={24} />
|
||||
<strong>Select a message</strong>
|
||||
<p>Source, function context, content, and evidence remain attached to the originating postbox.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={groupingDialogOpen}
|
||||
title={groupingDraft.id ? "Edit unified view" : "New unified view"}
|
||||
className="postbox-dialog"
|
||||
onClose={() => setGroupingDialogOpen(false)}
|
||||
closeDisabled={busy}
|
||||
footer={
|
||||
<div className="postbox-dialog-actions">
|
||||
{groupingDraft.id ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => void removeGrouping()}
|
||||
disabled={busy}
|
||||
>
|
||||
<Trash2 size={16} /> Delete
|
||||
</Button>
|
||||
) : <span />}
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => setGroupingDialogOpen(false)} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void saveGrouping()}
|
||||
disabled={busy || !groupingDraft.name.trim()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="postbox-form-grid">
|
||||
<FormField label="Name">
|
||||
<input
|
||||
value={groupingDraft.name}
|
||||
onChange={(event) =>
|
||||
setGroupingDraft((current) => ({
|
||||
...current,
|
||||
name: event.target.value
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="postbox-toggle-field">
|
||||
<ToggleSwitch
|
||||
label="Default unified view"
|
||||
checked={groupingDraft.is_default}
|
||||
onChange={(checked) =>
|
||||
setGroupingDraft((current) => ({
|
||||
...current,
|
||||
is_default: checked
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset className="postbox-source-selector">
|
||||
<legend>Source postboxes</legend>
|
||||
{postboxes.map((postbox) => (
|
||||
<label key={postbox.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={groupingDraft.postbox_ids.includes(postbox.id)}
|
||||
onChange={(event) =>
|
||||
setGroupingDraft((current) => ({
|
||||
...current,
|
||||
postbox_ids: event.target.checked
|
||||
? [...current.postbox_ids, postbox.id]
|
||||
: current.postbox_ids.filter((id) => id !== postbox.id)
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<strong>{postbox.name}</strong>
|
||||
<small>{postbox.organization_unit_name} · {postbox.function_name}</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
</Dialog>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageDetail({
|
||||
message,
|
||||
postbox
|
||||
}: {
|
||||
message: PostboxMessage;
|
||||
postbox?: PostboxDirectoryItem;
|
||||
}) {
|
||||
return (
|
||||
<div className="postbox-message-detail">
|
||||
<header>
|
||||
<div className="postbox-detail-status">
|
||||
<StatusBadge status={message.status} />
|
||||
<StatusBadge status={message.classification} />
|
||||
{message.acknowledged_at ? (
|
||||
<StatusBadge status="success" label="Acknowledged" />
|
||||
) : null}
|
||||
</div>
|
||||
<h1>{message.subject}</h1>
|
||||
<div className="postbox-detail-byline">
|
||||
<span>{message.sender_label || message.producer_module || "Platform"}</span>
|
||||
<time>{formatLongDate(message.delivered_at)}</time>
|
||||
</div>
|
||||
</header>
|
||||
<section className="postbox-provenance">
|
||||
<h2>Source and responsibility</h2>
|
||||
<dl>
|
||||
<div><dt>Postbox</dt><dd>{postbox?.name || message.postbox_id}</dd></div>
|
||||
<div><dt>Organization</dt><dd>{postbox?.organization_unit_name || "Not recorded"}</dd></div>
|
||||
<div><dt>Function</dt><dd>{postbox?.function_name || "Not recorded"}</dd></div>
|
||||
<div><dt>Address</dt><dd>{postbox?.address || "Not loaded"}</dd></div>
|
||||
<div><dt>Producer</dt><dd>{producerLabel(message)}</dd></div>
|
||||
<div><dt>Encryption profile</dt><dd>{message.encryption_profile} · epoch {message.key_epoch}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section className="postbox-body">
|
||||
<p>{message.body_text || "No plaintext body is available for this message."}</p>
|
||||
</section>
|
||||
{message.participants.length ? (
|
||||
<section className="postbox-participants">
|
||||
<h2>Participants</h2>
|
||||
{message.participants.map((participant, index) => (
|
||||
<div key={`${participant.kind}-${participant.reference_id || index}`}>
|
||||
<strong>{participant.kind}</strong>
|
||||
<span>{participant.label || participant.address || participant.reference_id || participant.reference_type}</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
<section className="postbox-attachments">
|
||||
<h2>Evidence and attachments</h2>
|
||||
{!message.attachments.length ? <p>No attachment references.</p> : null}
|
||||
{message.attachments.map((attachment) => (
|
||||
<div key={`${attachment.reference_type}:${attachment.reference_id}`}>
|
||||
<Paperclip size={15} />
|
||||
<span>
|
||||
<strong>{attachment.name || attachment.reference_id}</strong>
|
||||
<small>{attachment.reference_type}{attachment.media_type ? ` · ${attachment.media_type}` : ""}</small>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
{postbox?.access ? (
|
||||
<section className="postbox-access-explanation">
|
||||
<UserRoundCheck size={17} />
|
||||
<div>
|
||||
<strong>Current access</strong>
|
||||
<p>{postbox.access.explanation}</p>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sourceName(
|
||||
postboxes: PostboxDirectoryItem[],
|
||||
postboxId: string
|
||||
): string {
|
||||
return postboxes.find((postbox) => postbox.id === postboxId)?.name ?? "Postbox";
|
||||
}
|
||||
|
||||
function producerLabel(message: PostboxMessage): string {
|
||||
const resource = [
|
||||
message.producer_module,
|
||||
message.producer_resource_type,
|
||||
message.producer_resource_id
|
||||
].filter(Boolean);
|
||||
return resource.length ? resource.join(" / ") : "Platform-native message";
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatLongDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "long",
|
||||
timeStyle: "short"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "Postbox request failed";
|
||||
}
|
||||
Reference in New Issue
Block a user