feat(webui): complete postbox quick access

This commit is contained in:
2026-08-19 19:49:00 +02:00
parent d005065e50
commit 15d93aaa25
7 changed files with 286 additions and 12 deletions
+48 -2
View File
@@ -45,6 +45,7 @@ import { FormGrid,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import { useLocation, useNavigate } from "react-router";
import {
createPostboxGrouping,
createPostboxMessage,
@@ -110,13 +111,16 @@ export default function PostboxPage({
settings: ApiSettings;
auth: AuthInfo;
}) {
const location = useLocation();
const navigate = useNavigate();
const requestedMessageId = useRef(
new URLSearchParams(window.location.search).get("message") ?? ""
new URLSearchParams(location.search).get("message") ?? ""
);
const requestedPostboxId = useRef(
new URLSearchParams(window.location.search).get("postbox") ?? ""
new URLSearchParams(location.search).get("postbox") ?? ""
);
const requestedMessageLoaded = useRef(false);
const requestedComposeLoaded = useRef(false);
const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]);
const [groupings, setGroupings] = useState<PostboxGrouping[]>([]);
const [selectedScope, setSelectedScope] = useState("all");
@@ -269,6 +273,44 @@ export default function PostboxPage({
void loadDirectory();
}, [loadDirectory]);
useEffect(() => {
const parameters = new URLSearchParams(location.search);
if (
requestedComposeLoaded.current ||
parameters.get("quickAction") !== "compose" ||
loadingDirectory
) {
return;
}
requestedComposeLoaded.current = true;
const requestedId = parameters.get("postbox");
const postbox =
postboxes.find((item) => item.id === requestedId) ?? postboxes[0] ?? null;
if (canSend && postbox) {
setSelectedPostboxId(postbox.id);
openComposeFor(postbox);
} else if (!canSend) {
setError(POSTBOX_INTERFACE_I18N.noSendReason);
} else {
setError(POSTBOX_INTERFACE_I18N.noPostbox);
}
parameters.delete("quickAction");
const search = parameters.toString();
navigate(
{ pathname: location.pathname, search: search ? `?${search}` : "" },
{ replace: true, state: location.state }
);
}, [
canSend,
loadingDirectory,
location.pathname,
location.search,
location.state,
navigate,
postboxes
]);
useEffect(() => {
void loadMessages();
}, [loadMessages]);
@@ -469,6 +511,10 @@ export default function PostboxPage({
function openCompose() {
const postbox = selectedPostbox ?? postboxes[0] ?? null;
if (!postbox) return;
openComposeFor(postbox);
}
function openComposeFor(postbox: PostboxDirectoryItem) {
setReplyParent(null);
const next = {
...emptyMessageDraft(),
@@ -0,0 +1,160 @@
import { ExternalLink, Inbox, Pencil, Send } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router";
import {
Button,
DismissibleAlert,
LoadingFrame,
SelectionList,
SelectionListItem,
SelectionListItemContent,
hasScope,
quickAccessLaunchState,
useDashboardWidgetData,
usePlatformLanguage,
type QuickAccessToolRenderContext
} from "@govoplan/core-webui";
import {
listPostboxMessages,
listPostboxes,
type PostboxMessage
} from "../../api/postbox";
const MESSAGE_LIMIT = 7;
type Props = Pick<
QuickAccessToolRenderContext,
"settings" | "auth" | "launchContext" | "complete" | "close"
>;
/** Function-bound projection; Postbox re-evaluates the current acting context. */
export default function PostboxQuickAccess({
settings,
auth,
launchContext,
complete,
close
}: Props) {
const { language } = usePlatformLanguage();
const [selectedId, setSelectedId] = useState("");
const load = useCallback(async () => {
const postboxes = await listPostboxes(settings);
if (!postboxes.length) return { postboxes, messages: [], total: 0 };
const response = await listPostboxMessages(
settings,
postboxes.map((postbox) => postbox.id),
MESSAGE_LIMIT,
0,
"",
"unread"
);
return { postboxes, ...response };
}, [settings]);
const { data, loading, error } = useDashboardWidgetData(load, 0);
const messages = data?.messages ?? [];
const selected = useMemo(
() => messages.find((message) => message.id === selectedId) ?? messages[0] ?? null,
[messages, selectedId]
);
const selectedPostbox = data?.postboxes.find(
(postbox) => postbox.id === selected?.postbox_id
) ?? data?.postboxes[0] ?? null;
const canCompose = hasScope(auth, "postbox:message:write");
useEffect(() => {
if (!selectedId && messages[0]) setSelectedId(messages[0].id);
if (selectedId && !messages.some((message) => message.id === selectedId)) {
setSelectedId(messages[0]?.id ?? "");
}
}, [messages, selectedId]);
function selectForHost(message: PostboxMessage) {
complete({
contractVersion: "1",
outcome: "completed",
action: "selected",
reference: {
ownerModule: "postbox",
kind: "message",
objectId: message.id,
tenantId: message.tenant_id,
label: message.subject,
version: `${message.status}:${message.delivered_at}`,
path: `/postbox?message=${encodeURIComponent(message.id)}`
}
});
}
return (
<LoadingFrame loading={loading} label="i18n:govoplan-postbox.quick_loading_unread">
{launchContext.actingContext?.assignmentId ? (
<p className="muted small-note">
i18n:govoplan-postbox.quick_acting_context
</p>
) : null}
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
{messages.length ? (
<SelectionList variant="navigation" label="i18n:govoplan-postbox.quick_unread_messages">
{messages.map((message) => (
<SelectionListItem
key={message.id}
selected={selected?.id === message.id}
onClick={() => setSelectedId(message.id)}
>
<SelectionListItemContent
leading={<Inbox size={16} aria-hidden="true" />}
title={message.subject}
description={`${message.sender_label || message.producer_module || "Postbox"} · ${messageDate(message, language)}`}
/>
</SelectionListItem>
))}
</SelectionList>
) : !loading && !error ? (
<p className="muted">i18n:govoplan-postbox.quick_no_unread</p>
) : null}
{selected ? (
<section className="postbox-quick-detail" aria-label="i18n:govoplan-postbox.quick_message_details">
<strong>{selected.subject}</strong>
<span>{selectedPostbox?.function_name || selectedPostbox?.name}</span>
{selected.body_text ? <p>{selected.body_text}</p> : null}
<div className="button-row compact-actions">
<Button variant="primary" onClick={() => selectForHost(selected)}>
<Send size={15} aria-hidden="true" /> i18n:govoplan-postbox.quick_select_message
</Button>
<Link
className="btn btn-secondary"
to={`/postbox?message=${encodeURIComponent(selected.id)}`}
state={quickAccessLaunchState(launchContext)}
onClick={() => selectForHost(selected)}
>
<ExternalLink size={15} aria-hidden="true" /> i18n:govoplan-postbox.quick_open_message
</Link>
</div>
</section>
) : null}
<div className="dashboard-contribution-footer">
{canCompose && selectedPostbox ? (
<Link
className="btn btn-secondary"
to={`/postbox?quickAction=compose&postbox=${encodeURIComponent(selectedPostbox.id)}`}
state={quickAccessLaunchState(launchContext)}
onClick={close}
>
<Pencil size={15} aria-hidden="true" /> i18n:govoplan-postbox.quick_compose
</Link>
) : null}
<span className="muted small-note">{messages.length} / {data?.total ?? 0}</span>
</div>
</LoadingFrame>
);
}
function messageDate(message: PostboxMessage, language: string): string {
return new Intl.DateTimeFormat(language, {
dateStyle: "medium",
timeStyle: "short"
}).format(new Date(message.delivered_at));
}