feat: initialize governed postbox module
This commit is contained in:
31
webui/package.json
Normal file
31
webui/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@govoplan/postbox-webui",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./styles/postbox.css": "./src/styles/postbox.css"
|
||||
},
|
||||
"scripts": {
|
||||
"test:ui-structure": "node scripts/test-postbox-page-structure.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": ">=7.18.2 <8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
29
webui/scripts/test-postbox-page-structure.mjs
Normal file
29
webui/scripts/test-postbox-page-structure.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const page = readFileSync(
|
||||
new URL("../src/features/postbox/PostboxPage.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const admin = readFileSync(
|
||||
new URL("../src/features/postbox/PostboxAdminPanel.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const styles = readFileSync(
|
||||
new URL("../src/styles/postbox.css", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert.match(page, /postbox-shell/);
|
||||
assert.match(page, /postbox-directory/);
|
||||
assert.match(page, /postbox-message-list/);
|
||||
assert.match(page, /postbox-detail/);
|
||||
assert.match(page, /postbox\.inbox\.directory/);
|
||||
assert.match(page, /postbox\.inbox\.messages/);
|
||||
assert.match(admin, /AdminPageLayout/);
|
||||
assert.match(admin, /Postbox templates/);
|
||||
assert.match(admin, /Exact postbox/);
|
||||
assert.match(styles, /\.postbox-shell\s*\{/);
|
||||
assert.match(styles, /grid-template-columns:/);
|
||||
|
||||
console.log("Postbox WebUI structure checks passed.");
|
||||
362
webui/src/api/postbox.ts
Normal file
362
webui/src/api/postbox.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import {
|
||||
apiFetch,
|
||||
apiPath,
|
||||
apiPostJson,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
export type PostboxAccessDecision = {
|
||||
allowed: boolean;
|
||||
action: string;
|
||||
postbox_id: string;
|
||||
reason_code: string;
|
||||
explanation: string;
|
||||
organization_unit_id?: string | null;
|
||||
function_id?: string | null;
|
||||
assignment_ids: string[];
|
||||
assignment_sources: string[];
|
||||
selected_assignment_id?: string | null;
|
||||
holder_count: number;
|
||||
vacant: boolean;
|
||||
};
|
||||
|
||||
export type PostboxDirectoryItem = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
address: string;
|
||||
address_key: string;
|
||||
name: string;
|
||||
status: string;
|
||||
classification: string;
|
||||
organization_unit_id?: string | null;
|
||||
organization_unit_name?: string | null;
|
||||
function_id?: string | null;
|
||||
function_name?: string | null;
|
||||
context_key?: string | null;
|
||||
template_revision_id?: string | null;
|
||||
holder_count: number;
|
||||
vacant: boolean;
|
||||
access?: PostboxAccessDecision | null;
|
||||
};
|
||||
|
||||
export type PostboxParticipant = {
|
||||
kind: string;
|
||||
reference_type: string;
|
||||
reference_id?: string | null;
|
||||
label?: string | null;
|
||||
address?: string | null;
|
||||
};
|
||||
|
||||
export type PostboxAttachment = {
|
||||
reference_type: string;
|
||||
reference_id: string;
|
||||
name?: string | null;
|
||||
media_type?: string | null;
|
||||
size_bytes?: number | null;
|
||||
digest?: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PostboxMessage = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
postbox_id: string;
|
||||
subject: string;
|
||||
body_text?: string | null;
|
||||
status: string;
|
||||
classification: string;
|
||||
sender_label?: string | null;
|
||||
delivered_at: string;
|
||||
read_at?: string | null;
|
||||
acknowledged_at?: string | null;
|
||||
expires_at?: string | null;
|
||||
withdrawn_at?: string | null;
|
||||
producer_module?: string | null;
|
||||
producer_resource_type?: string | null;
|
||||
producer_resource_id?: string | null;
|
||||
encryption_profile: string;
|
||||
key_epoch: number;
|
||||
ciphertext_ref?: string | null;
|
||||
signed_manifest_ref?: string | null;
|
||||
participants: PostboxParticipant[];
|
||||
attachments: PostboxAttachment[];
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PostboxGrouping = {
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
postbox_ids: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type PostboxOrganizationFunction = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
function_type_id?: string | null;
|
||||
delegable: boolean;
|
||||
act_in_place_allowed: boolean;
|
||||
};
|
||||
|
||||
export type PostboxOrganizationUnit = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
unit_type_id?: string | null;
|
||||
parent_id?: string | null;
|
||||
functions: PostboxOrganizationFunction[];
|
||||
};
|
||||
|
||||
export type PostboxTemplateRevision = {
|
||||
id: string;
|
||||
revision: number;
|
||||
function_type_id?: string | null;
|
||||
scope_kind: "tenant" | "unit" | "subtree" | "unit_type";
|
||||
scope_id?: string | null;
|
||||
name_pattern: string;
|
||||
address_pattern: string;
|
||||
classification: string;
|
||||
allow_vacant_delivery: boolean;
|
||||
encryption_profile: string;
|
||||
history_policy: Record<string, unknown>;
|
||||
routing_policy: Record<string, unknown>;
|
||||
retention_policy: Record<string, unknown>;
|
||||
published_at?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type PostboxTemplate = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
current_revision: number;
|
||||
published_revision_id?: string | null;
|
||||
revisions: PostboxTemplateRevision[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type PostboxTemplateRevisionPayload = Pick<
|
||||
PostboxTemplateRevision,
|
||||
| "function_type_id"
|
||||
| "scope_kind"
|
||||
| "scope_id"
|
||||
| "name_pattern"
|
||||
| "address_pattern"
|
||||
| "classification"
|
||||
| "allow_vacant_delivery"
|
||||
>;
|
||||
|
||||
export type PostboxTemplateCreatePayload = PostboxTemplateRevisionPayload & {
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type PostboxExactCreatePayload = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
organization_unit_id: string;
|
||||
function_id: string;
|
||||
address_key?: string | null;
|
||||
classification: string;
|
||||
};
|
||||
|
||||
export async function listPostboxes(settings: ApiSettings): Promise<PostboxDirectoryItem[]> {
|
||||
const response = await apiFetch<{ postboxes: PostboxDirectoryItem[] }>(
|
||||
settings,
|
||||
"/api/v1/postbox/directory"
|
||||
);
|
||||
return response.postboxes;
|
||||
}
|
||||
|
||||
export async function listPostboxMessages(
|
||||
settings: ApiSettings,
|
||||
postboxIds: string[],
|
||||
limit = 100,
|
||||
offset = 0,
|
||||
query = "",
|
||||
state: "all" | "unread" | "read" | "acknowledged" = "all"
|
||||
): Promise<{ messages: PostboxMessage[]; total: number; limit: number; offset: number }> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
apiPath("/api/v1/postbox/messages", {
|
||||
postbox_id: postboxIds,
|
||||
limit,
|
||||
offset,
|
||||
q: query || undefined,
|
||||
state
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function getPostboxMessage(
|
||||
settings: ApiSettings,
|
||||
messageId: string
|
||||
): Promise<PostboxMessage> {
|
||||
return apiFetch(settings, `/api/v1/postbox/messages/${encodeURIComponent(messageId)}`);
|
||||
}
|
||||
|
||||
export function markPostboxMessage(
|
||||
settings: ApiSettings,
|
||||
messageId: string,
|
||||
state: "read" | "acknowledged"
|
||||
): Promise<PostboxMessage> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/postbox/messages/${encodeURIComponent(messageId)}/state`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ state })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function listPostboxGroupings(settings: ApiSettings): Promise<PostboxGrouping[]> {
|
||||
const response = await apiFetch<{ groupings: PostboxGrouping[] }>(
|
||||
settings,
|
||||
"/api/v1/postbox/groupings"
|
||||
);
|
||||
return response.groupings;
|
||||
}
|
||||
|
||||
export function createPostboxGrouping(
|
||||
settings: ApiSettings,
|
||||
payload: Omit<PostboxGrouping, "id" | "created_at" | "updated_at">
|
||||
): Promise<PostboxGrouping> {
|
||||
return apiPostJson(settings, "/api/v1/postbox/groupings", payload);
|
||||
}
|
||||
|
||||
export function updatePostboxGrouping(
|
||||
settings: ApiSettings,
|
||||
groupingId: string,
|
||||
payload: Omit<PostboxGrouping, "id" | "created_at" | "updated_at">
|
||||
): Promise<PostboxGrouping> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/postbox/groupings/${encodeURIComponent(groupingId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function deletePostboxGrouping(
|
||||
settings: ApiSettings,
|
||||
groupingId: string
|
||||
): Promise<void> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/postbox/groupings/${encodeURIComponent(groupingId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
}
|
||||
|
||||
export async function listAdminPostboxes(settings: ApiSettings): Promise<PostboxDirectoryItem[]> {
|
||||
const response = await apiFetch<{ postboxes: PostboxDirectoryItem[] }>(
|
||||
settings,
|
||||
"/api/v1/postbox/admin/postboxes"
|
||||
);
|
||||
return response.postboxes;
|
||||
}
|
||||
|
||||
export async function listPostboxOrganizationTargets(
|
||||
settings: ApiSettings
|
||||
): Promise<PostboxOrganizationUnit[]> {
|
||||
const response = await apiFetch<{ units: PostboxOrganizationUnit[] }>(
|
||||
settings,
|
||||
"/api/v1/postbox/admin/organization-targets"
|
||||
);
|
||||
return response.units;
|
||||
}
|
||||
|
||||
export function createExactPostbox(
|
||||
settings: ApiSettings,
|
||||
payload: PostboxExactCreatePayload
|
||||
): Promise<PostboxDirectoryItem> {
|
||||
return apiPostJson(settings, "/api/v1/postbox/admin/postboxes", payload);
|
||||
}
|
||||
|
||||
export function archivePostbox(
|
||||
settings: ApiSettings,
|
||||
postboxId: string
|
||||
): Promise<PostboxDirectoryItem> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/postbox/admin/postboxes/${encodeURIComponent(postboxId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
}
|
||||
|
||||
export async function listPostboxTemplates(settings: ApiSettings): Promise<PostboxTemplate[]> {
|
||||
const response = await apiFetch<{ templates: PostboxTemplate[] }>(
|
||||
settings,
|
||||
"/api/v1/postbox/admin/templates"
|
||||
);
|
||||
return response.templates;
|
||||
}
|
||||
|
||||
export function createPostboxTemplate(
|
||||
settings: ApiSettings,
|
||||
payload: PostboxTemplateCreatePayload
|
||||
): Promise<PostboxTemplate> {
|
||||
return apiPostJson(settings, "/api/v1/postbox/admin/templates", payload);
|
||||
}
|
||||
|
||||
export function revisePostboxTemplate(
|
||||
settings: ApiSettings,
|
||||
templateId: string,
|
||||
payload: PostboxTemplateRevisionPayload
|
||||
): Promise<PostboxTemplate> {
|
||||
return apiPostJson(
|
||||
settings,
|
||||
`/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/revisions`,
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
export function publishPostboxTemplate(
|
||||
settings: ApiSettings,
|
||||
templateId: string,
|
||||
revision?: number
|
||||
): Promise<PostboxTemplate> {
|
||||
return apiPostJson(
|
||||
settings,
|
||||
`/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/publish`,
|
||||
{ revision: revision ?? null }
|
||||
);
|
||||
}
|
||||
|
||||
export function retirePostboxTemplate(
|
||||
settings: ApiSettings,
|
||||
templateId: string
|
||||
): Promise<PostboxTemplate> {
|
||||
return apiPostJson(
|
||||
settings,
|
||||
`/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/retire`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
export function materializePostboxTemplate(
|
||||
settings: ApiSettings,
|
||||
templateId: string,
|
||||
payload: {
|
||||
organization_unit_id: string;
|
||||
function_id: string;
|
||||
context_key?: string | null;
|
||||
}
|
||||
): Promise<PostboxDirectoryItem> {
|
||||
return apiPostJson(
|
||||
settings,
|
||||
`/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/materialize`,
|
||||
payload
|
||||
);
|
||||
}
|
||||
977
webui/src/features/postbox/PostboxAdminPanel.tsx
Normal file
977
webui/src/features/postbox/PostboxAdminPanel.tsx
Normal 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";
|
||||
}
|
||||
90
webui/src/features/postbox/PostboxInboxWidget.tsx
Normal file
90
webui/src/features/postbox/PostboxInboxWidget.tsx
Normal 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;
|
||||
}
|
||||
800
webui/src/features/postbox/PostboxPage.tsx
Normal file
800
webui/src/features/postbox/PostboxPage.tsx
Normal 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";
|
||||
}
|
||||
3
webui/src/index.ts
Normal file
3
webui/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { postboxModule as default, postboxModule } from "./module";
|
||||
export { default as PostboxPage } from "./features/postbox/PostboxPage";
|
||||
export { default as PostboxAdminPanel } from "./features/postbox/PostboxAdminPanel";
|
||||
151
webui/src/module.ts
Normal file
151
webui/src/module.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import {
|
||||
hasScope,
|
||||
type AdminSectionsUiCapability,
|
||||
type DashboardWidgetsUiCapability,
|
||||
type PlatformWebModule
|
||||
} from "@govoplan/core-webui";
|
||||
import PostboxInboxWidget from "./features/postbox/PostboxInboxWidget";
|
||||
import "./styles/postbox.css";
|
||||
|
||||
|
||||
const PostboxPage = lazy(() => import("./features/postbox/PostboxPage"));
|
||||
const PostboxAdminPanel = lazy(
|
||||
() => import("./features/postbox/PostboxAdminPanel")
|
||||
);
|
||||
|
||||
const readScope = ["postbox:postbox:read"];
|
||||
const postboxDashboardWidgets: DashboardWidgetsUiCapability = {
|
||||
widgets: [
|
||||
{
|
||||
id: "postbox.inbox",
|
||||
surfaceId: "postbox.widget.inbox",
|
||||
title: "Postbox inbox",
|
||||
description: "Unread messages across accessible Postboxes.",
|
||||
moduleId: "postbox",
|
||||
category: "Communication",
|
||||
order: 55,
|
||||
defaultVisible: false,
|
||||
defaultSize: "medium",
|
||||
supportedSizes: ["medium", "wide"],
|
||||
anyOf: readScope,
|
||||
refreshIntervalMs: 30_000,
|
||||
defaultConfiguration: {
|
||||
maxItems: 5
|
||||
},
|
||||
configurationFields: [
|
||||
{
|
||||
id: "maxItems",
|
||||
label: "Maximum messages",
|
||||
kind: "number",
|
||||
min: 1,
|
||||
max: 12,
|
||||
step: 1,
|
||||
required: true
|
||||
}
|
||||
],
|
||||
render: ({ settings, refreshKey, configuration }) =>
|
||||
createElement(PostboxInboxWidget, {
|
||||
settings,
|
||||
refreshKey,
|
||||
configuration
|
||||
})
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const postboxAdminSections: AdminSectionsUiCapability = {
|
||||
sections: [
|
||||
{
|
||||
id: "postbox",
|
||||
label: "Postboxes",
|
||||
group: "TENANT",
|
||||
order: 45,
|
||||
surfaceId: "postbox.admin.templates",
|
||||
anyOf: [
|
||||
"postbox:binding:admin",
|
||||
"postbox:template:admin"
|
||||
],
|
||||
render: ({ settings, auth }) =>
|
||||
createElement(PostboxAdminPanel, {
|
||||
settings,
|
||||
canManageBindings: hasScope(auth, "postbox:binding:admin"),
|
||||
canManageTemplates: hasScope(auth, "postbox:template:admin")
|
||||
})
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const postboxModule: PlatformWebModule = {
|
||||
id: "postbox",
|
||||
label: "Postbox",
|
||||
version: "0.1.0",
|
||||
dependencies: ["identity", "organizations", "idm"],
|
||||
optionalDependencies: [
|
||||
"access",
|
||||
"audit",
|
||||
"campaigns",
|
||||
"files",
|
||||
"mail",
|
||||
"notifications",
|
||||
"policy",
|
||||
"portal",
|
||||
"views",
|
||||
"workflow"
|
||||
],
|
||||
navItems: [
|
||||
{
|
||||
to: "/postbox",
|
||||
label: "Postbox",
|
||||
iconName: "inbox",
|
||||
anyOf: readScope,
|
||||
order: 58
|
||||
}
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
path: "/postbox",
|
||||
anyOf: readScope,
|
||||
order: 58,
|
||||
surfaceId: "postbox.inbox.messages",
|
||||
render: ({ settings, auth }) =>
|
||||
createElement(PostboxPage, { settings, auth })
|
||||
}
|
||||
],
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "postbox.inbox.directory",
|
||||
moduleId: "postbox",
|
||||
kind: "section",
|
||||
label: "Postbox directory",
|
||||
order: 10
|
||||
},
|
||||
{
|
||||
id: "postbox.inbox.messages",
|
||||
moduleId: "postbox",
|
||||
kind: "section",
|
||||
label: "Postbox messages",
|
||||
order: 20
|
||||
},
|
||||
{
|
||||
id: "postbox.widget.inbox",
|
||||
moduleId: "postbox",
|
||||
kind: "section",
|
||||
label: "Postbox inbox widget",
|
||||
order: 25
|
||||
},
|
||||
{
|
||||
id: "postbox.admin.templates",
|
||||
moduleId: "postbox",
|
||||
kind: "section",
|
||||
label: "Postbox templates and bindings",
|
||||
order: 30
|
||||
}
|
||||
],
|
||||
uiCapabilities: {
|
||||
"admin.sections": postboxAdminSections,
|
||||
"dashboard.widgets": postboxDashboardWidgets
|
||||
}
|
||||
};
|
||||
|
||||
export default postboxModule;
|
||||
640
webui/src/styles/postbox.css
Normal file
640
webui/src/styles/postbox.css
Normal file
@@ -0,0 +1,640 @@
|
||||
.postbox-page {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
height: calc(100vh - 115px);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.postbox-page *,
|
||||
.postbox-page *::before,
|
||||
.postbox-page *::after,
|
||||
.postbox-admin-page *,
|
||||
.postbox-admin-page *::before,
|
||||
.postbox-admin-page *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.postbox-shell {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(250px, 310px)
|
||||
minmax(290px, 370px)
|
||||
minmax(360px, 1fr);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.postbox-directory,
|
||||
.postbox-message-list,
|
||||
.postbox-detail {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.postbox-directory,
|
||||
.postbox-message-list {
|
||||
border-right: var(--border-line);
|
||||
}
|
||||
|
||||
.postbox-directory {
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.postbox-bar,
|
||||
.postbox-bar-title,
|
||||
.postbox-icon-actions,
|
||||
.postbox-scope-row,
|
||||
.postbox-item-title,
|
||||
.postbox-message-heading,
|
||||
.postbox-message-meta,
|
||||
.postbox-detail-status,
|
||||
.postbox-detail-byline,
|
||||
.postbox-access-explanation,
|
||||
.postbox-attachments > div,
|
||||
.postbox-participants > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.postbox-bar {
|
||||
flex: 0 0 auto;
|
||||
min-height: 54px;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-header);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.postbox-bar-title {
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.postbox-bar-title strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.postbox-icon-actions {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.postbox-total {
|
||||
min-width: 24px;
|
||||
height: 22px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: var(--line);
|
||||
color: var(--text-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.postbox-scope-control {
|
||||
flex: 0 0 auto;
|
||||
border-bottom: var(--border-line);
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.postbox-scope-control > label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.postbox-scope-row {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.postbox-scope-row select {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.postbox-directory-list,
|
||||
.postbox-messages {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.postbox-directory-list,
|
||||
.postbox-messages {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.postbox-message-filters {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
border-bottom: var(--border-line);
|
||||
padding: 8px 9px;
|
||||
}
|
||||
|
||||
.postbox-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.postbox-search-row input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.postbox-message-list > .data-grid-pagination {
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px 12px;
|
||||
border-top: var(--border-line);
|
||||
}
|
||||
|
||||
.postbox-message-list > .data-grid-pagination .data-grid-page-controls {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.postbox-directory-list .selection-list,
|
||||
.postbox-messages .selection-list,
|
||||
.postbox-admin-list .selection-list {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.postbox-directory-item,
|
||||
.postbox-message-item,
|
||||
.postbox-admin-list .selection-list-item {
|
||||
display: grid;
|
||||
min-height: 64px;
|
||||
gap: 4px;
|
||||
align-content: center;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.postbox-item-title,
|
||||
.postbox-message-heading {
|
||||
min-width: 0;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.postbox-item-title strong,
|
||||
.postbox-message-heading strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-strong);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.postbox-item-context,
|
||||
.postbox-item-address,
|
||||
.postbox-message-preview,
|
||||
.postbox-message-meta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.postbox-message-item.is-read strong {
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.postbox-message-item.is-unread strong {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.postbox-message-heading time {
|
||||
flex: 0 0 auto;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.postbox-message-meta {
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.postbox-message-meta span {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.postbox-message-list > .alert {
|
||||
flex: 0 0 auto;
|
||||
margin: 8px 10px 0;
|
||||
}
|
||||
|
||||
.postbox-message-detail {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 20px 24px 28px;
|
||||
}
|
||||
|
||||
.postbox-message-detail > header,
|
||||
.postbox-provenance,
|
||||
.postbox-body,
|
||||
.postbox-participants,
|
||||
.postbox-attachments {
|
||||
border-bottom: var(--border-line);
|
||||
padding-bottom: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.postbox-detail-status {
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.postbox-message-detail h1 {
|
||||
max-width: 900px;
|
||||
margin: 12px 0 9px;
|
||||
color: var(--text-strong);
|
||||
font-size: 24px;
|
||||
font-weight: 650;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.postbox-detail-byline {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.postbox-provenance h2,
|
||||
.postbox-participants h2,
|
||||
.postbox-attachments h2 {
|
||||
margin: 0 0 10px;
|
||||
color: var(--text-strong);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.postbox-provenance dl,
|
||||
.postbox-admin-properties {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(170px, 1fr));
|
||||
gap: 10px 18px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.postbox-provenance dt,
|
||||
.postbox-admin-properties dt {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.postbox-provenance dd,
|
||||
.postbox-admin-properties dd {
|
||||
min-width: 0;
|
||||
margin: 3px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.postbox-body p {
|
||||
max-width: 900px;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.postbox-participants,
|
||||
.postbox-attachments {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.postbox-participants > div,
|
||||
.postbox-attachments > div {
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.postbox-participants > div strong {
|
||||
min-width: 90px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.postbox-attachments > p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.postbox-attachments > div span {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.postbox-attachments > div strong,
|
||||
.postbox-attachments > div small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.postbox-attachments > div small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.postbox-access-explanation {
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
border-left: 3px solid var(--green);
|
||||
background: var(--success-soft);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.postbox-access-explanation p {
|
||||
margin: 3px 0 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.postbox-empty {
|
||||
min-height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 7px;
|
||||
padding: 30px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.postbox-empty.compact {
|
||||
min-height: 180px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.postbox-empty strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.postbox-empty p,
|
||||
.postbox-note {
|
||||
max-width: 470px;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.postbox-dialog {
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.postbox-template-dialog {
|
||||
width: min(900px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.postbox-form-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.postbox-form-grid.two-columns {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.postbox-form-grid input,
|
||||
.postbox-form-grid select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.postbox-toggle-field {
|
||||
min-height: 62px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
|
||||
.postbox-form-note {
|
||||
margin: 15px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.postbox-dialog-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.postbox-source-selector {
|
||||
max-height: 310px;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
overflow: auto;
|
||||
border: var(--border-line);
|
||||
margin: 16px 0 0;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.postbox-source-selector legend {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.postbox-source-selector label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 7px 8px;
|
||||
}
|
||||
|
||||
.postbox-source-selector label:hover {
|
||||
background: var(--sidebar-hover-bg);
|
||||
}
|
||||
|
||||
.postbox-source-selector label span {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.postbox-source-selector small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.postbox-admin-page > .segmented-control {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.postbox-admin-workspace {
|
||||
min-height: 520px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(250px, 320px) minmax(0, 1fr);
|
||||
border: var(--border-line);
|
||||
background: var(--panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.postbox-admin-list {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
max-height: 680px;
|
||||
overflow: auto;
|
||||
border-right: var(--border-line);
|
||||
background: var(--panel-soft);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.postbox-admin-detail {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
max-height: 680px;
|
||||
overflow: auto;
|
||||
padding: 20px 24px 26px;
|
||||
}
|
||||
|
||||
.postbox-admin-detail-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
border-bottom: var(--border-line);
|
||||
padding-bottom: 17px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.postbox-admin-detail-heading h2 {
|
||||
margin: 2px 0 4px;
|
||||
color: var(--text-strong);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.postbox-admin-detail-heading p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.postbox-detail-kicker {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.postbox-admin-properties {
|
||||
border-bottom: var(--border-line);
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.postbox-revision-history {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.postbox-revision-history h3 {
|
||||
margin: 0 0 9px;
|
||||
color: var(--text-strong);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.postbox-revision-history > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 1fr) minmax(160px, 2fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-top: var(--border-line);
|
||||
padding: 9px 0;
|
||||
}
|
||||
|
||||
.postbox-revision-history > div span:not(.status-badge) {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.postbox-shell {
|
||||
grid-template-columns: minmax(230px, 290px) minmax(280px, 340px) minmax(330px, 1fr);
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.postbox-page {
|
||||
height: auto;
|
||||
min-height: calc(100vh - 115px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.postbox-shell {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.postbox-directory,
|
||||
.postbox-message-list {
|
||||
min-height: 260px;
|
||||
max-height: 42vh;
|
||||
border-right: 0;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.postbox-detail {
|
||||
min-height: 440px;
|
||||
}
|
||||
|
||||
.postbox-admin-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.postbox-admin-list {
|
||||
max-height: 260px;
|
||||
border-right: 0;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.postbox-admin-detail-heading {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.postbox-form-grid.two-columns,
|
||||
.postbox-provenance dl,
|
||||
.postbox-admin-properties {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.postbox-message-detail {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
1
webui/src/vite-env.d.ts
vendored
Normal file
1
webui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
webui/tsconfig.json
Normal file
20
webui/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src", "tests"]
|
||||
}
|
||||
Reference in New Issue
Block a user