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));
}
+16
View File
@@ -3,6 +3,14 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
const en = {
"i18n:govoplan-postbox.postbox": "Postbox",
"i18n:govoplan-postbox.quick_access_description": "Institutional messages addressed to your functions.",
"i18n:govoplan-postbox.quick_loading_unread": "Loading unread Postbox messages",
"i18n:govoplan-postbox.quick_acting_context": "Showing messages for the current acting assignment.",
"i18n:govoplan-postbox.quick_unread_messages": "Unread Postbox messages",
"i18n:govoplan-postbox.quick_no_unread": "No unread Postbox messages.",
"i18n:govoplan-postbox.quick_message_details": "Postbox message details",
"i18n:govoplan-postbox.quick_select_message": "Select message",
"i18n:govoplan-postbox.quick_open_message": "Open message",
"i18n:govoplan-postbox.quick_compose": "Compose message",
"i18n:govoplan-postbox.postboxes": "Postboxes",
"i18n:govoplan-postbox.postbox_inbox": "Postbox inbox",
"i18n:govoplan-postbox.postbox_inbox_description": "Unread messages across accessible Postboxes.",
@@ -237,6 +245,14 @@ const de = {
...en,
"i18n:govoplan-postbox.postbox": "Postfach",
"i18n:govoplan-postbox.quick_access_description": "Institutionelle Nachrichten an Ihre Funktionen.",
"i18n:govoplan-postbox.quick_loading_unread": "Ungelesene Postfachnachrichten werden geladen",
"i18n:govoplan-postbox.quick_acting_context": "Nachrichten der aktuellen Handlungszuweisung werden angezeigt.",
"i18n:govoplan-postbox.quick_unread_messages": "Ungelesene Postfachnachrichten",
"i18n:govoplan-postbox.quick_no_unread": "Keine ungelesenen Postfachnachrichten.",
"i18n:govoplan-postbox.quick_message_details": "Details der Postfachnachricht",
"i18n:govoplan-postbox.quick_select_message": "Nachricht auswählen",
"i18n:govoplan-postbox.quick_open_message": "Nachricht öffnen",
"i18n:govoplan-postbox.quick_compose": "Nachricht verfassen",
"i18n:govoplan-postbox.postboxes": "Postfächer",
"i18n:govoplan-postbox.postbox_inbox": "Postfach-Eingang",
"i18n:govoplan-postbox.postbox_inbox_description": "Ungelesene Nachrichten aus zugänglichen Postfächern.",
+2 -5
View File
@@ -8,6 +8,7 @@ import {
} from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import PostboxInboxWidget from "./features/postbox/PostboxInboxWidget";
import PostboxQuickAccess from "./features/postbox/PostboxQuickAccess";
import "./styles/postbox.css";
@@ -64,11 +65,7 @@ const postboxQuickAccessTools: QuickAccessToolsUiCapability = {
tools: [
{
id: "postbox.messages",
render: ({ settings }) => createElement(PostboxInboxWidget, {
settings,
refreshKey: 0,
configuration: { maxItems: 7 }
})
render: (context) => createElement(PostboxQuickAccess, context)
}
]
};
+22
View File
@@ -554,6 +554,28 @@
resize: vertical;
}
.postbox-quick-detail {
display: grid;
gap: 7px;
margin-top: 12px;
border-top: var(--border-line);
padding-top: 12px;
}
.postbox-quick-detail > span,
.postbox-quick-detail > p {
margin: 0;
color: var(--muted);
font-size: 12px;
}
.postbox-quick-detail > p {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
}
.postbox-dialog-actions.end {
justify-content: flex-end;
gap: 8px;