feat: initialize governed postbox module

This commit is contained in:
2026-07-29 14:16:29 +02:00
parent 19216e5043
commit d848a9a503
31 changed files with 9776 additions and 1 deletions

View File

@@ -0,0 +1,977 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Archive,
Boxes,
Building2,
Inbox,
Pencil,
Plus,
RefreshCw,
Rocket,
Save,
Trash2
} from "lucide-react";
import {
AdminPageLayout,
Button,
ConfirmDialog,
Dialog,
FormField,
IconButton,
SegmentedControl,
SelectionList,
SelectionListItem,
StatusBadge,
ToggleSwitch,
type ApiSettings
} from "@govoplan/core-webui";
import {
archivePostbox,
createExactPostbox,
createPostboxTemplate,
listAdminPostboxes,
listPostboxOrganizationTargets,
listPostboxTemplates,
materializePostboxTemplate,
publishPostboxTemplate,
retirePostboxTemplate,
revisePostboxTemplate,
type PostboxDirectoryItem,
type PostboxExactCreatePayload,
type PostboxOrganizationFunction,
type PostboxOrganizationUnit,
type PostboxTemplate,
type PostboxTemplateCreatePayload,
type PostboxTemplateRevisionPayload
} from "../../api/postbox";
type AdminMode = "templates" | "postboxes";
type TemplateDraft = PostboxTemplateCreatePayload & { templateId: string };
type ExactDraft = PostboxExactCreatePayload;
type MaterializeDraft = {
templateId: string;
organization_unit_id: string;
function_id: string;
context_key: string;
};
const templateDefaults = (): TemplateDraft => ({
templateId: "",
slug: "",
name: "",
description: "",
function_type_id: null,
scope_kind: "tenant",
scope_id: null,
name_pattern: "{unit_name} / {function_name}",
address_pattern: "{template_slug}.{unit_slug}.{function_slug}",
classification: "internal",
allow_vacant_delivery: true
});
const exactDefaults = (): ExactDraft => ({
name: "",
description: "",
organization_unit_id: "",
function_id: "",
address_key: "",
classification: "internal"
});
export default function PostboxAdminPanel({
settings,
canManageBindings,
canManageTemplates
}: {
settings: ApiSettings;
canManageBindings: boolean;
canManageTemplates: boolean;
}) {
const [mode, setMode] = useState<AdminMode>(
canManageTemplates ? "templates" : "postboxes"
);
const [templates, setTemplates] = useState<PostboxTemplate[]>([]);
const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]);
const [units, setUnits] = useState<PostboxOrganizationUnit[]>([]);
const [selectedTemplateId, setSelectedTemplateId] = useState("");
const [selectedPostboxId, setSelectedPostboxId] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [templateDialogOpen, setTemplateDialogOpen] = useState(false);
const [templateDraft, setTemplateDraft] = useState<TemplateDraft>(templateDefaults);
const [exactDialogOpen, setExactDialogOpen] = useState(false);
const [exactDraft, setExactDraft] = useState<ExactDraft>(exactDefaults);
const [materializeDialogOpen, setMaterializeDialogOpen] = useState(false);
const [materializeDraft, setMaterializeDraft] = useState<MaterializeDraft>({
templateId: "",
organization_unit_id: "",
function_id: "",
context_key: ""
});
const [archiveTarget, setArchiveTarget] = useState<PostboxDirectoryItem | null>(null);
const selectedTemplate = useMemo(
() => templates.find((template) => template.id === selectedTemplateId) ?? templates[0] ?? null,
[templates, selectedTemplateId]
);
const selectedPostbox = useMemo(
() => postboxes.find((postbox) => postbox.id === selectedPostboxId) ?? postboxes[0] ?? null,
[postboxes, selectedPostboxId]
);
const functionTypes = useMemo(() => {
const values = new Map<string, string>();
for (const unit of units) {
for (const fn of unit.functions) {
if (fn.function_type_id && !values.has(fn.function_type_id)) {
values.set(fn.function_type_id, fn.name);
}
}
}
return [...values].map(([id, name]) => ({ id, name }));
}, [units]);
const unitTypes = useMemo(() => {
const values = new Map<string, string>();
for (const unit of units) {
if (unit.unit_type_id && !values.has(unit.unit_type_id)) {
values.set(unit.unit_type_id, unit.name);
}
}
return [...values].map(([id, example]) => ({ id, example }));
}, [units]);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const [nextTemplates, nextPostboxes, nextUnits] = await Promise.all([
canManageTemplates ? listPostboxTemplates(settings) : Promise.resolve([]),
canManageBindings ? listAdminPostboxes(settings) : Promise.resolve([]),
listPostboxOrganizationTargets(settings)
]);
setTemplates(nextTemplates);
setPostboxes(nextPostboxes);
setUnits(nextUnits);
setSelectedTemplateId((current) =>
current && nextTemplates.some((template) => template.id === current)
? current
: nextTemplates[0]?.id ?? ""
);
setSelectedPostboxId((current) =>
current && nextPostboxes.some((postbox) => postbox.id === current)
? current
: nextPostboxes[0]?.id ?? ""
);
} catch (loadError) {
setError(errorMessage(loadError));
} finally {
setLoading(false);
}
}, [canManageBindings, canManageTemplates, settings]);
useEffect(() => {
void load();
}, [load]);
function openNewTemplate() {
setTemplateDraft(templateDefaults());
setTemplateDialogOpen(true);
}
function openTemplateRevision(template: PostboxTemplate) {
const revision = currentRevision(template);
if (!revision) return;
setTemplateDraft({
templateId: template.id,
slug: template.slug,
name: template.name,
description: template.description || "",
function_type_id: revision.function_type_id ?? null,
scope_kind: revision.scope_kind,
scope_id: revision.scope_id ?? null,
name_pattern: revision.name_pattern,
address_pattern: revision.address_pattern,
classification: revision.classification,
allow_vacant_delivery: revision.allow_vacant_delivery
});
setTemplateDialogOpen(true);
}
async function saveTemplate() {
setBusy(true);
setError("");
setSuccess("");
try {
if (templateDraft.templateId) {
await revisePostboxTemplate(
settings,
templateDraft.templateId,
revisionPayload(templateDraft)
);
setSuccess("A new immutable template revision was created.");
} else {
await createPostboxTemplate(settings, {
slug: templateDraft.slug,
name: templateDraft.name,
description: templateDraft.description || null,
...revisionPayload(templateDraft)
});
setSuccess("Postbox template created as a draft.");
}
setTemplateDialogOpen(false);
await load();
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
async function publishSelected() {
if (!selectedTemplate) return;
setBusy(true);
setError("");
setSuccess("");
try {
await publishPostboxTemplate(
settings,
selectedTemplate.id,
selectedTemplate.current_revision
);
setSuccess(`Published revision ${selectedTemplate.current_revision}.`);
await load();
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
async function retireSelected() {
if (!selectedTemplate) return;
setBusy(true);
setError("");
setSuccess("");
try {
await retirePostboxTemplate(settings, selectedTemplate.id);
setSuccess("Postbox template retired. Existing addresses remain durable.");
await load();
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
function openExact() {
const unit = units.find((item) => item.functions.length);
setExactDraft({
...exactDefaults(),
organization_unit_id: unit?.id ?? "",
function_id: unit?.functions[0]?.id ?? ""
});
setExactDialogOpen(true);
}
async function saveExact() {
setBusy(true);
setError("");
setSuccess("");
try {
await createExactPostbox(settings, {
...exactDraft,
description: exactDraft.description || null,
address_key: exactDraft.address_key || null
});
setExactDialogOpen(false);
setSuccess("Exact function-bound Postbox created.");
await load();
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
function openMaterialize(template: PostboxTemplate) {
const revision = currentRevision(template);
const compatible = compatibleTargets(units, revision?.function_type_id);
const unit = compatible[0];
setMaterializeDraft({
templateId: template.id,
organization_unit_id: unit?.id ?? "",
function_id: unit?.functions[0]?.id ?? "",
context_key: ""
});
setMaterializeDialogOpen(true);
}
async function materialize() {
setBusy(true);
setError("");
setSuccess("");
try {
await materializePostboxTemplate(settings, materializeDraft.templateId, {
organization_unit_id: materializeDraft.organization_unit_id,
function_id: materializeDraft.function_id,
context_key: materializeDraft.context_key || null
});
setMaterializeDialogOpen(false);
setSuccess("Stable Postbox address resolved and materialized.");
await load();
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
async function confirmArchive() {
if (!archiveTarget) return;
setBusy(true);
setError("");
setSuccess("");
try {
await archivePostbox(settings, archiveTarget.id);
setSuccess("Postbox archived. Messages and delivery evidence were retained.");
setArchiveTarget(null);
await load();
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
return (
<AdminPageLayout
title="Postboxes"
description="Manage durable organization-function addresses and reusable templates."
loading={loading}
error={error}
success={success}
className="postbox-admin-page"
actions={
<>
<IconButton
label="Refresh"
icon={<RefreshCw size={16} />}
onClick={() => void load()}
disabled={busy}
/>
{mode === "templates" && canManageTemplates ? (
<Button variant="primary" onClick={openNewTemplate}>
<Plus size={16} /> New template
</Button>
) : null}
{mode === "postboxes" && canManageBindings ? (
<Button variant="primary" onClick={openExact}>
<Plus size={16} /> Exact postbox
</Button>
) : null}
</>
}
>
<SegmentedControl
value={mode}
onChange={(value) => setMode(value as AdminMode)}
options={[
...(canManageTemplates ? [{ id: "templates", label: "Templates" }] : []),
...(canManageBindings ? [{ id: "postboxes", label: "Postboxes" }] : [])
]}
ariaLabel="Postbox administration section"
/>
{mode === "templates" ? (
<TemplateWorkspace
templates={templates}
selected={selectedTemplate}
onSelect={setSelectedTemplateId}
onRevise={openTemplateRevision}
onPublish={() => void publishSelected()}
onRetire={() => void retireSelected()}
onMaterialize={openMaterialize}
busy={busy}
canManageBindings={canManageBindings}
/>
) : (
<PostboxWorkspace
postboxes={postboxes}
selected={selectedPostbox}
onSelect={setSelectedPostboxId}
onArchive={setArchiveTarget}
busy={busy}
/>
)}
<TemplateDialog
open={templateDialogOpen}
draft={templateDraft}
units={units}
functionTypes={functionTypes}
unitTypes={unitTypes}
busy={busy}
onChange={setTemplateDraft}
onClose={() => setTemplateDialogOpen(false)}
onSave={() => void saveTemplate()}
/>
<ExactPostboxDialog
open={exactDialogOpen}
draft={exactDraft}
units={units}
busy={busy}
onChange={setExactDraft}
onClose={() => setExactDialogOpen(false)}
onSave={() => void saveExact()}
/>
<MaterializeDialog
open={materializeDialogOpen}
draft={materializeDraft}
template={templates.find((item) => item.id === materializeDraft.templateId) ?? null}
units={units}
busy={busy}
onChange={setMaterializeDraft}
onClose={() => setMaterializeDialogOpen(false)}
onSave={() => void materialize()}
/>
<ConfirmDialog
open={Boolean(archiveTarget)}
title="Archive Postbox"
message={
archiveTarget
? `Archive "${archiveTarget.name}"? Its messages and delivery evidence remain retained, but the address stops accepting new delivery.`
: ""
}
confirmLabel="Archive"
tone="danger"
busy={busy}
onConfirm={() => void confirmArchive()}
onCancel={() => setArchiveTarget(null)}
/>
</AdminPageLayout>
);
}
function TemplateWorkspace({
templates,
selected,
onSelect,
onRevise,
onPublish,
onRetire,
onMaterialize,
busy,
canManageBindings
}: {
templates: PostboxTemplate[];
selected: PostboxTemplate | null;
onSelect: (id: string) => void;
onRevise: (template: PostboxTemplate) => void;
onPublish: () => void;
onRetire: () => void;
onMaterialize: (template: PostboxTemplate) => void;
busy: boolean;
canManageBindings: boolean;
}) {
const revision = selected ? currentRevision(selected) : null;
return (
<div className="postbox-admin-workspace">
<aside className="postbox-admin-list">
{!templates.length ? <p className="postbox-note">No Postbox templates.</p> : null}
{templates.length ? (
<SelectionList label="Postbox templates">
{templates.map((template) => (
<SelectionListItem
key={template.id}
selected={selected?.id === template.id}
onClick={() => onSelect(template.id)}
>
<span className="postbox-item-title">
<strong>{template.name}</strong>
<StatusBadge status={template.status} />
</span>
<span className="postbox-item-context">
{template.slug} · revision {template.current_revision}
</span>
</SelectionListItem>
))}
</SelectionList>
) : null}
</aside>
<section className="postbox-admin-detail">
{selected && revision ? (
<>
<div className="postbox-admin-detail-heading">
<div>
<span className="postbox-detail-kicker">Template</span>
<h2>{selected.name}</h2>
<p>{selected.description || "No description."}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={() => onRevise(selected)} disabled={busy || selected.status === "retired"}>
<Pencil size={16} /> New revision
</Button>
<Button onClick={onPublish} disabled={busy || selected.status === "retired" || Boolean(revision.published_at)}>
<Save size={16} /> Publish
</Button>
{canManageBindings ? (
<Button onClick={() => onMaterialize(selected)} disabled={busy || selected.status !== "published"}>
<Rocket size={16} /> Resolve address
</Button>
) : null}
<Button variant="danger" onClick={onRetire} disabled={busy || selected.status === "retired"}>
<Trash2 size={16} /> Retire
</Button>
</div>
</div>
<dl className="postbox-admin-properties">
<div><dt>Revision</dt><dd>{revision.revision}{revision.published_at ? " · published" : " · draft"}</dd></div>
<div><dt>Function type</dt><dd>{revision.function_type_id || "Any function type"}</dd></div>
<div><dt>Scope</dt><dd>{revision.scope_kind}{revision.scope_id ? ` · ${revision.scope_id}` : ""}</dd></div>
<div><dt>Classification</dt><dd>{revision.classification}</dd></div>
<div><dt>Vacant delivery</dt><dd>{revision.allow_vacant_delivery ? "Accepted" : "Blocked"}</dd></div>
<div><dt>Encryption</dt><dd>{revision.encryption_profile}</dd></div>
<div><dt>Name pattern</dt><dd>{revision.name_pattern}</dd></div>
<div><dt>Address pattern</dt><dd>{revision.address_pattern}</dd></div>
</dl>
<div className="postbox-revision-history">
<h3>Immutable revisions</h3>
{selected.revisions.map((item) => (
<div key={item.id}>
<strong>Revision {item.revision}</strong>
<span>{item.classification} · {item.scope_kind}</span>
<StatusBadge
status={item.published_at ? "published" : "draft"}
label={item.published_at ? "Published" : "Draft"}
/>
</div>
))}
</div>
</>
) : (
<div className="postbox-empty">
<Boxes size={24} />
<strong>Select a template</strong>
<p>Published revisions lazily resolve stable unit-specific addresses.</p>
</div>
)}
</section>
</div>
);
}
function PostboxWorkspace({
postboxes,
selected,
onSelect,
onArchive,
busy
}: {
postboxes: PostboxDirectoryItem[];
selected: PostboxDirectoryItem | null;
onSelect: (id: string) => void;
onArchive: (postbox: PostboxDirectoryItem) => void;
busy: boolean;
}) {
return (
<div className="postbox-admin-workspace">
<aside className="postbox-admin-list">
{!postboxes.length ? <p className="postbox-note">No materialized Postboxes.</p> : null}
{postboxes.length ? (
<SelectionList label="Materialized Postboxes">
{postboxes.map((postbox) => (
<SelectionListItem
key={postbox.id}
selected={selected?.id === postbox.id}
onClick={() => onSelect(postbox.id)}
>
<span className="postbox-item-title">
<strong>{postbox.name}</strong>
<StatusBadge status={postbox.status} />
</span>
<span className="postbox-item-context">
{postbox.organization_unit_name} · {postbox.function_name}
</span>
</SelectionListItem>
))}
</SelectionList>
) : null}
</aside>
<section className="postbox-admin-detail">
{selected ? (
<>
<div className="postbox-admin-detail-heading">
<div>
<span className="postbox-detail-kicker">Postbox</span>
<h2>{selected.name}</h2>
<p>{selected.address}</p>
</div>
<Button
variant="danger"
onClick={() => onArchive(selected)}
disabled={busy || selected.status !== "active"}
>
<Archive size={16} /> Archive
</Button>
</div>
<dl className="postbox-admin-properties">
<div><dt>Organization unit</dt><dd>{selected.organization_unit_name || "None"}</dd></div>
<div><dt>Function</dt><dd>{selected.function_name || "None"}</dd></div>
<div><dt>Address key</dt><dd>{selected.address_key}</dd></div>
<div><dt>Classification</dt><dd>{selected.classification}</dd></div>
<div><dt>Current holders</dt><dd>{selected.holder_count}</dd></div>
<div><dt>Vacancy</dt><dd>{selected.vacant ? "Vacant" : "Staffed"}</dd></div>
<div><dt>Context</dt><dd>{selected.context_key || "None"}</dd></div>
<div><dt>Template revision</dt><dd>{selected.template_revision_id || "Exact Postbox"}</dd></div>
</dl>
</>
) : (
<div className="postbox-empty">
<Inbox size={24} />
<strong>Select a Postbox</strong>
<p>Materialized Postboxes remain durable through vacancy and reassignment.</p>
</div>
)}
</section>
</div>
);
}
function TemplateDialog({
open,
draft,
units,
functionTypes,
unitTypes,
busy,
onChange,
onClose,
onSave
}: {
open: boolean;
draft: TemplateDraft;
units: PostboxOrganizationUnit[];
functionTypes: Array<{ id: string; name: string }>;
unitTypes: Array<{ id: string; example: string }>;
busy: boolean;
onChange: (draft: TemplateDraft) => void;
onClose: () => void;
onSave: () => void;
}) {
const isRevision = Boolean(draft.templateId);
const scopeOptions = draft.scope_kind === "unit_type"
? unitTypes.map((item) => ({ id: item.id, label: `${item.id} (${item.example})` }))
: units.map((unit) => ({ id: unit.id, label: unit.name }));
const valid =
draft.name.trim() &&
draft.slug.trim() &&
draft.name_pattern.trim() &&
draft.address_pattern.trim() &&
(draft.scope_kind === "tenant" || Boolean(draft.scope_id));
return (
<Dialog
open={open}
title={isRevision ? "Create template revision" : "New Postbox template"}
className="postbox-dialog postbox-template-dialog"
onClose={onClose}
closeDisabled={busy}
footer={
<div className="button-row compact-actions">
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button variant="primary" onClick={onSave} disabled={busy || !valid}>
{isRevision ? "Create revision" : "Create draft"}
</Button>
</div>
}
>
<div className="postbox-form-grid two-columns">
<FormField label="Name">
<input
value={draft.name}
disabled={isRevision}
onChange={(event) => onChange({ ...draft, name: event.target.value })}
/>
</FormField>
<FormField label="Slug">
<input
value={draft.slug}
disabled={isRevision}
onChange={(event) => onChange({ ...draft, slug: event.target.value })}
/>
</FormField>
<FormField label="Description">
<input
value={draft.description || ""}
disabled={isRevision}
onChange={(event) => onChange({ ...draft, description: event.target.value })}
/>
</FormField>
<FormField label="Function type">
<select
value={draft.function_type_id || ""}
onChange={(event) => onChange({
...draft,
function_type_id: event.target.value || null
})}
>
<option value="">Any function type</option>
{functionTypes.map((item) => (
<option key={item.id} value={item.id}>{item.name} · {item.id}</option>
))}
</select>
</FormField>
<FormField label="Scope">
<select
value={draft.scope_kind}
onChange={(event) => {
const scope_kind = event.target.value as TemplateDraft["scope_kind"];
onChange({
...draft,
scope_kind,
scope_id: scope_kind === "tenant" ? null : ""
});
}}
>
<option value="tenant">Tenant</option>
<option value="unit">One unit</option>
<option value="subtree">Unit subtree</option>
<option value="unit_type">Unit type</option>
</select>
</FormField>
{draft.scope_kind !== "tenant" ? (
<FormField label="Scope target">
<select
value={draft.scope_id || ""}
onChange={(event) => onChange({ ...draft, scope_id: event.target.value || null })}
>
<option value="">Select target</option>
{scopeOptions.map((item) => (
<option key={item.id} value={item.id}>{item.label}</option>
))}
</select>
</FormField>
) : <div />}
<FormField label="Name pattern">
<input
value={draft.name_pattern}
onChange={(event) => onChange({ ...draft, name_pattern: event.target.value })}
/>
</FormField>
<FormField label="Address pattern">
<input
value={draft.address_pattern}
onChange={(event) => onChange({ ...draft, address_pattern: event.target.value })}
/>
</FormField>
<FormField label="Classification">
<input
value={draft.classification}
onChange={(event) => onChange({ ...draft, classification: event.target.value })}
/>
</FormField>
<div className="postbox-toggle-field">
<ToggleSwitch
label="Accept delivery while vacant"
checked={draft.allow_vacant_delivery}
onChange={(checked) => onChange({ ...draft, allow_vacant_delivery: checked })}
/>
</div>
</div>
<p className="postbox-form-note">
Available pattern variables include template, unit, function, and optional context names or slugs. Published revisions are immutable.
</p>
</Dialog>
);
}
function ExactPostboxDialog({
open,
draft,
units,
busy,
onChange,
onClose,
onSave
}: {
open: boolean;
draft: ExactDraft;
units: PostboxOrganizationUnit[];
busy: boolean;
onChange: (draft: ExactDraft) => void;
onClose: () => void;
onSave: () => void;
}) {
const unit = units.find((item) => item.id === draft.organization_unit_id);
return (
<Dialog
open={open}
title="New exact Postbox"
className="postbox-dialog"
onClose={onClose}
closeDisabled={busy}
footer={
<div className="button-row compact-actions">
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button
variant="primary"
onClick={onSave}
disabled={busy || !draft.name.trim() || !draft.organization_unit_id || !draft.function_id}
>
Create Postbox
</Button>
</div>
}
>
<div className="postbox-form-grid two-columns">
<FormField label="Name">
<input value={draft.name} onChange={(event) => onChange({ ...draft, name: event.target.value })} />
</FormField>
<FormField label="Address key">
<input value={draft.address_key || ""} placeholder="Generated when empty" onChange={(event) => onChange({ ...draft, address_key: event.target.value })} />
</FormField>
<FormField label="Organization unit">
<select
value={draft.organization_unit_id}
onChange={(event) => {
const nextUnit = units.find((item) => item.id === event.target.value);
onChange({
...draft,
organization_unit_id: event.target.value,
function_id: nextUnit?.functions[0]?.id ?? ""
});
}}
>
<option value="">Select unit</option>
{units.filter((item) => item.functions.length).map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Function">
<select value={draft.function_id} onChange={(event) => onChange({ ...draft, function_id: event.target.value })}>
<option value="">Select function</option>
{(unit?.functions || []).map((fn) => (
<option key={fn.id} value={fn.id}>{fn.name}</option>
))}
</select>
</FormField>
<FormField label="Classification">
<input value={draft.classification} onChange={(event) => onChange({ ...draft, classification: event.target.value })} />
</FormField>
<FormField label="Description">
<input value={draft.description || ""} onChange={(event) => onChange({ ...draft, description: event.target.value })} />
</FormField>
</div>
</Dialog>
);
}
function MaterializeDialog({
open,
draft,
template,
units,
busy,
onChange,
onClose,
onSave
}: {
open: boolean;
draft: MaterializeDraft;
template: PostboxTemplate | null;
units: PostboxOrganizationUnit[];
busy: boolean;
onChange: (draft: MaterializeDraft) => void;
onClose: () => void;
onSave: () => void;
}) {
const revision = template ? currentRevision(template) : null;
const compatible = compatibleTargets(units, revision?.function_type_id);
const unit = compatible.find((item) => item.id === draft.organization_unit_id);
return (
<Dialog
open={open}
title={`Resolve address${template ? ` · ${template.name}` : ""}`}
className="postbox-dialog"
onClose={onClose}
closeDisabled={busy}
footer={
<div className="button-row compact-actions">
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button variant="primary" onClick={onSave} disabled={busy || !draft.organization_unit_id || !draft.function_id}>
Resolve address
</Button>
</div>
}
>
<div className="postbox-form-grid">
<FormField label="Organization unit">
<select
value={draft.organization_unit_id}
onChange={(event) => {
const nextUnit = compatible.find((item) => item.id === event.target.value);
onChange({
...draft,
organization_unit_id: event.target.value,
function_id: nextUnit?.functions[0]?.id ?? ""
});
}}
>
<option value="">Select unit</option>
{compatible.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Function">
<select value={draft.function_id} onChange={(event) => onChange({ ...draft, function_id: event.target.value })}>
<option value="">Select function</option>
{(unit?.functions || []).map((fn) => (
<option key={fn.id} value={fn.id}>{fn.name}</option>
))}
</select>
</FormField>
<FormField label="Optional case or service context">
<input value={draft.context_key} onChange={(event) => onChange({ ...draft, context_key: event.target.value })} />
</FormField>
</div>
</Dialog>
);
}
function currentRevision(template: PostboxTemplate) {
return template.revisions.find((revision) => revision.revision === template.current_revision)
?? template.revisions.at(-1)
?? null;
}
function revisionPayload(draft: TemplateDraft): PostboxTemplateRevisionPayload {
return {
function_type_id: draft.function_type_id || null,
scope_kind: draft.scope_kind,
scope_id: draft.scope_kind === "tenant" ? null : draft.scope_id || null,
name_pattern: draft.name_pattern,
address_pattern: draft.address_pattern,
classification: draft.classification,
allow_vacant_delivery: draft.allow_vacant_delivery
};
}
function compatibleTargets(
units: PostboxOrganizationUnit[],
functionTypeId?: string | null
): PostboxOrganizationUnit[] {
return units
.map((unit) => ({
...unit,
functions: functionTypeId
? unit.functions.filter((fn) => fn.function_type_id === functionTypeId)
: unit.functions
}))
.filter((unit) => unit.functions.length);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Postbox request failed";
}

View File

@@ -0,0 +1,90 @@
import { useCallback } from "react";
import { Inbox, Paperclip } from "lucide-react";
import { Link } from "react-router-dom";
import {
DashboardWidgetList,
DismissibleAlert,
LoadingFrame,
useDashboardWidgetData,
type ApiSettings,
type DashboardWidgetConfiguration
} from "@govoplan/core-webui";
import {
listPostboxMessages,
listPostboxes
} from "../../api/postbox";
export default function PostboxInboxWidget({
settings,
refreshKey,
configuration
}: {
settings: ApiSettings;
refreshKey: number;
configuration: DashboardWidgetConfiguration;
}) {
const maxItems = numberSetting(configuration.maxItems, 5, 1, 12);
const load = useCallback(async () => {
const postboxes = await listPostboxes(settings);
if (!postboxes.length) {
return { messages: [], total: 0 };
}
return listPostboxMessages(
settings,
postboxes.map((postbox) => postbox.id),
maxItems,
0,
"",
"unread"
);
}, [maxItems, settings]);
const { data, loading, error } = useDashboardWidgetData(load, refreshKey);
return (
<LoadingFrame loading={loading} label="Loading unread Postbox messages">
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
</DismissibleAlert>
)}
<DashboardWidgetList
emptyText="No unread Postbox messages."
items={(data?.messages ?? []).map((message) => ({
id: message.id,
title: message.subject,
detail: message.sender_label || message.producer_module || "Postbox",
meta: new Intl.DateTimeFormat(undefined, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit"
}).format(new Date(message.delivered_at)),
leading: <Inbox size={17} aria-hidden="true" />,
trailing: message.attachments.length ? (
<span title={`${message.attachments.length} attachments`}>
<Paperclip size={15} aria-hidden="true" />
</span>
) : undefined,
to: `/postbox?message=${encodeURIComponent(message.id)}`
}))}
/>
<div className="dashboard-contribution-footer">
<Link className="btn btn-secondary" to="/postbox">
{data?.total ? `Open Postbox (${data.total} unread)` : "Open Postbox"}
</Link>
</div>
</LoadingFrame>
);
}
function numberSetting(
value: unknown,
fallback: number,
minimum: number,
maximum: number
): number {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric)
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
: fallback;
}

View File

@@ -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";
}